First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Package config loads runtime configuration from a JSON file, with environment
|
||||
// variables taking precedence. Currently it carries the PostgreSQL connection.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Database is the PostgreSQL connection config. Either set DSN directly, or set
|
||||
// the component fields and a DSN is assembled from them.
|
||||
type Database struct {
|
||||
DSN string `json:"dsn"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
SSLMode string `json:"sslmode"`
|
||||
}
|
||||
|
||||
// Config is the on-disk config file shape.
|
||||
type Config struct {
|
||||
Database Database `json:"database"`
|
||||
SkillDir string `json:"skill_dir"`
|
||||
}
|
||||
|
||||
// BaseDir is the directory that anchors all runtime artifacts (config.json and
|
||||
// the data/ store). It is the directory the running binary lives in, so a
|
||||
// distributed executable keeps its files next to itself on any OS (Windows,
|
||||
// Linux, …) regardless of the working directory it is launched from.
|
||||
//
|
||||
// When launched via `go run`, the binary is throwaway: it sits either in a temp
|
||||
// build dir (cache miss → fresh link) OR straight inside the Go build cache
|
||||
// (cache hit → run from $GOCACHE/.../...-d). We detect both and fall back to the
|
||||
// current working directory so dev artifacts (data/, transcripts) and config
|
||||
// resolve against the project dir, not the throwaway binary's location.
|
||||
func BaseDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if isGoRunDir(dir) {
|
||||
return "." // throwaway `go run` binary → use CWD
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// isGoRunDir reports whether dir is where `go run` parked its executable: under
|
||||
// the system temp dir (cache miss), or anywhere inside a Go build cache
|
||||
// (…/go-build/…, the cache-hit case — NOT under os.TempDir(), which is why the
|
||||
// old temp-only check failed intermittently). In both cases the binary is
|
||||
// throwaway, so config/data must resolve against the CWD.
|
||||
func isGoRunDir(dir string) bool {
|
||||
if tmp := os.TempDir(); tmp != "" {
|
||||
if rel, err := filepath.Rel(tmp, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.ToSlash(dir), "/") {
|
||||
if seg == "go-build" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Path returns the config file path. Resolution order:
|
||||
// 1. env ARTEX_CONFIG (explicit override)
|
||||
// 2. ./config.json in the current working directory (running from the project
|
||||
// dir — robust no matter where `go run` placed the temp/cached binary)
|
||||
// 3. config.json next to the executable (a distributed binary keeps it beside)
|
||||
//
|
||||
// The first existing file wins. If none exist, the CWD path is returned so the
|
||||
// "not found" message points at the project dir the user most likely expected.
|
||||
func Path() string {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_CONFIG")); v != "" {
|
||||
return v
|
||||
}
|
||||
var candidates []string
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "config.json"))
|
||||
}
|
||||
candidates = append(candidates, filepath.Join(BaseDir(), "config.json"))
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// Load reads and parses the config file. A missing/unreadable file yields a zero
|
||||
// Config (so callers fall back to defaults) rather than an error.
|
||||
func Load() Config {
|
||||
var c Config
|
||||
b, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
return c
|
||||
}
|
||||
|
||||
// SkillDir returns the skill root directory with precedence:
|
||||
//
|
||||
// env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills
|
||||
//
|
||||
// The directory is created if it does not exist.
|
||||
func SkillDir() string {
|
||||
var d string
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
|
||||
d = v
|
||||
} else if v := strings.TrimSpace(Load().SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
// PostgresDSN resolves the connection string with precedence:
|
||||
//
|
||||
// env ARTEX_PG_DSN > config file (database.dsn, or assembled from fields)
|
||||
//
|
||||
// There is NO built-in fallback: when neither source supplies a database config,
|
||||
// it returns an error naming the config path it inspected, so startup fails loudly
|
||||
// instead of silently connecting to a wrong default. source describes where the
|
||||
// DSN came from (for startup logging).
|
||||
func PostgresDSN() (dsn, source string, err error) {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
||||
return v, "환경 변수 ARTEX_PG_DSN", nil
|
||||
}
|
||||
db := Load().Database
|
||||
if d := strings.TrimSpace(db.DSN); d != "" {
|
||||
return d, "설정 파일 " + Path() + " (database.dsn)", nil
|
||||
}
|
||||
if db.Host != "" || db.DBName != "" || db.User != "" {
|
||||
return db.buildDSN(), "설정 파일 " + Path() + " (database 필드)", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("데이터베이스 설정을 찾을 수 없습니다: 환경 변수 ARTEX_PG_DSN 이 설정되어 있지 않고, 설정 파일 %s 에도 database (dsn 또는 host/user/dbname) 설정이 없습니다. 설정 파일을 만들거나 환경 변수를 설정한 뒤 다시 시도하세요", Path())
|
||||
}
|
||||
|
||||
func (d Database) buildDSN() string {
|
||||
host := d.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := d.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
ssl := d.SSLMode
|
||||
if ssl == "" {
|
||||
ssl = "disable"
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
Host: host + ":" + strconv.Itoa(port),
|
||||
Path: "/" + d.DBName,
|
||||
}
|
||||
if d.User != "" {
|
||||
if d.Password != "" {
|
||||
u.User = url.UserPassword(d.User, d.Password)
|
||||
} else {
|
||||
u.User = url.User(d.User)
|
||||
}
|
||||
}
|
||||
u.RawQuery = url.Values{"sslmode": {ssl}}.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// String is a redacted view of the resolved DSN (password masked) for logging.
|
||||
func Redact(dsn string) string {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return dsn
|
||||
}
|
||||
if u.User != nil {
|
||||
if _, hasPw := u.User.Password(); hasPw {
|
||||
u.User = url.UserPassword(u.User.Username(), "****")
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s", u.String())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hasHan reports whether s contains a CJK Han ideograph (the Chinese source text
|
||||
// we are replacing). Hangul and ASCII identifiers must survive; Han must not.
|
||||
func hasHan(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0x4e00 && r <= 0x9fff {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasHangul(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0xac00 && r <= 0xd7a3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertKorean(t *testing.T, label, s string) {
|
||||
t.Helper()
|
||||
if hasHan(s) {
|
||||
t.Errorf("%s: Chinese Han ideograph remains: %q", label, s)
|
||||
}
|
||||
if !hasHangul(s) {
|
||||
t.Errorf("%s: no Hangul found (expected Korean): %q", label, s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresDSNErrorLocalized pins the install-path startup message to Korean.
|
||||
// When neither ARTEX_PG_DSN nor a config file supplies a database, PostgresDSN
|
||||
// returns an error that propagates verbatim (db.DSN → server.NewManager → the
|
||||
// `log.Fatalf("open stores: %v", err)` in cmd/artex/main.go). An operator who
|
||||
// boots with missing config sees it first, so it must read as Korean.
|
||||
func TestPostgresDSNErrorLocalized(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
|
||||
|
||||
_, _, err := PostgresDSN()
|
||||
if err == nil {
|
||||
t.Fatal("missing config should error")
|
||||
}
|
||||
assertKorean(t, "startup error", err.Error())
|
||||
|
||||
// The identifiers an operator must act on stay verbatim (not translated).
|
||||
for _, want := range []string{"ARTEX_PG_DSN", "database", "dsn", "host/user/dbname"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("startup error should keep identifier %q verbatim: %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// What the operator actually sees after propagation (no %w wrapping anywhere
|
||||
// on the path), recorded so the message can be eyeballed in context.
|
||||
t.Logf("open stores: %v", err)
|
||||
}
|
||||
|
||||
// TestPostgresDSNSourceLocalized pins the three source labels (logged at
|
||||
// server/manager.go:361) to Korean. They live in the same function as the
|
||||
// startup error, so they are localized together to avoid a mixed-language file.
|
||||
func TestPostgresDSNSourceLocalized(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
// env source
|
||||
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
|
||||
t.Setenv("ARTEX_PG_DSN", "postgres://envwins/x")
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("env source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "env source", source)
|
||||
}
|
||||
|
||||
// config file dsn source
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
if err := os.WriteFile(cfgPath, []byte(`{"database":{"dsn":"postgres://full/dsn"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("dsn source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "dsn source", source)
|
||||
}
|
||||
|
||||
// config file fields source
|
||||
if err := os.WriteFile(cfgPath, []byte(`{"database":{"host":"10.1.2.3","dbname":"d","user":"u"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("fields source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "fields source", source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostgresDSNPrecedence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
os.WriteFile(cfgPath, []byte(`{"database":{"host":"10.1.2.3","port":6000,"user":"u","password":"p","dbname":"d","sslmode":"require"}}`), 0o644)
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
|
||||
// no env DSN → built from config file fields
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
got, _, err := PostgresDSN()
|
||||
want := "postgres://u:p@10.1.2.3:6000/d?sslmode=require"
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("from file: got %q err %v want %q", got, err, want)
|
||||
}
|
||||
|
||||
// env wins over config file
|
||||
t.Setenv("ARTEX_PG_DSN", "postgres://envwins/x")
|
||||
if got, _, err := PostgresDSN(); err != nil || got != "postgres://envwins/x" {
|
||||
t.Fatalf("env should win, got %q err %v", got, err)
|
||||
}
|
||||
|
||||
// no env, no file → error (no built-in default)
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
|
||||
if got, _, err := PostgresDSN(); err == nil {
|
||||
t.Fatalf("missing config should error, got %q", got)
|
||||
}
|
||||
|
||||
// full dsn in config file is used verbatim
|
||||
os.WriteFile(cfgPath, []byte(`{"database":{"dsn":"postgres://full/dsn"}}`), 0o644)
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
if got, _, err := PostgresDSN(); err != nil || got != "postgres://full/dsn" {
|
||||
t.Fatalf("file dsn verbatim, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user