112 lines
4.8 KiB
Go
112 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// dotenv.go: reads backend/.env (gitignored) into the environment. The parser is split
|
|
// out as a pure function (package №4): the «matched quotes» and «do not override what
|
|
// is already set» rules are review findings that used to live only in comments; now they
|
|
// are pinned by unit tests (a regression that «simplified to strings.Trim» would corrupt
|
|
// API key values and surface as inexplicable 401s in the middle of an acceptance run).
|
|
|
|
// dotenvPair is one KEY=VALUE line of a .env file.
|
|
type dotenvPair struct{ K, V string }
|
|
|
|
// parseDotEnv reads KEY=VALUE lines: skips empty ones/comments/those without «=»,
|
|
// trims key and value, strips only MATCHED enclosing quotes — a Trim over a character
|
|
// set would bite off a legitimate quote at the end of the value (review finding).
|
|
func parseDotEnv(r io.Reader) []dotenvPair {
|
|
var out []dotenvPair
|
|
sc := bufio.NewScanner(r)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
|
if n := len(v); n >= 2 && (v[0] == '"' || v[0] == '\'') && v[n-1] == v[0] {
|
|
v = v[1 : n-1]
|
|
}
|
|
out = append(out, dotenvPair{K: k, V: v})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loadKeysFile loads the DEPLOYMENT's provider keys from an explicitly named file (`--keys-file`,
|
|
// backlog row 211) and, unlike loadDotEnv, FAILS when it cannot.
|
|
//
|
|
// The defect it closes is a dev/prod parity trap, not a missing feature. On the SaaS path the engine is
|
|
// spawned by `systemd-run --user` with exactly one variable set (TM_TRACE_ID), and the intent recorded
|
|
// on the platform side was that it would read its keys from a `.env` beside its own book.yaml — which
|
|
// nothing on either side ever writes. The DEV supervisor spawns the engine as an ordinary child and it
|
|
// inherits the platform's own environment, so every gate ran green on a path that had keys while
|
|
// production had none.
|
|
//
|
|
// Loud rather than silent for the same reason: a named file that is not there is a deployment fault, and
|
|
// the alternative is a paid run that starts, calls a provider, gets 401 and burns the operator's time on
|
|
// a mystery. An ABSENT file is included in that — this path is only reached because a caller named one.
|
|
//
|
|
// It is the FIRST link of the chain (see run), so its values win over the conventional .env files. The
|
|
// process environment still outranks all of them: loadDotEnv's "never override what is already set" rule
|
|
// is frozen, and it is also the ordinary precedence a deployment expects.
|
|
func loadKeysFile(path string, warn io.Writer) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("--keys-file %s cannot be read: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
pairs := parseDotEnv(f)
|
|
if len(pairs) == 0 {
|
|
// A file the operator named and that carries nothing is the same fault one step further along: the
|
|
// run would proceed key-less and fail at the first provider call instead of at start-up.
|
|
return fmt.Errorf("--keys-file %s holds no KEY=VALUE line", path)
|
|
}
|
|
setDotEnv(path, pairs, warn)
|
|
return nil
|
|
}
|
|
|
|
// loadDotEnv reads KEY=VALUE lines into the environment without overriding
|
|
// already-set variables. Keys come from backend/.env (gitignored); secrets never
|
|
// end up in configs/the repo. Note: a variable set to an EMPTY string is treated
|
|
// as unset and will be overridden (current behavior, frozen).
|
|
// It WARNS but never fails. A missing file is normal — the keys may come from the real environment —
|
|
// but a file that exists and cannot be read, or a line os.Setenv refuses (an empty key from a `=VALUE`
|
|
// typo), used to be swallowed and surfaced much later as an unexplained 401. Warning rather than
|
|
// returning keeps that visible without taking down `status`/`report`/`export`, which are $0 read-only
|
|
// projections that must not demand keys at all (D20.4), and lets the remaining valid lines still load.
|
|
func loadDotEnv(path string, warn io.Writer) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
fmt.Fprintf(warn, "tmctl: %s exists but cannot be read (%v) — its keys are NOT loaded\n", path, err)
|
|
}
|
|
return
|
|
}
|
|
defer f.Close()
|
|
setDotEnv(path, parseDotEnv(f), warn)
|
|
}
|
|
|
|
// setDotEnv applies parsed pairs to the environment. Shared by both loaders so the two can never differ
|
|
// on the rules that matter: an already-set variable is never overridden, and a line os.Setenv refuses
|
|
// (an empty key from a `=VALUE` typo) is named rather than swallowed.
|
|
func setDotEnv(path string, pairs []dotenvPair, warn io.Writer) {
|
|
for _, p := range pairs {
|
|
if os.Getenv(p.K) != "" {
|
|
continue
|
|
}
|
|
if err := os.Setenv(p.K, p.V); err != nil {
|
|
fmt.Fprintf(warn, "tmctl: %s: malformed line ignored, key %q rejected (%v)\n", path, p.K, err)
|
|
}
|
|
}
|
|
}
|