summaryrefslogtreecommitdiff
path: root/internal/envfile
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-08-06 23:22:03 -0500
committerClaude <noreply@anthropic.com>2026-08-06 23:22:03 -0500
commitf34ba20fc566ab861179a8ac2fd55eaabbc6a327 (patch)
treef3b421a59cff06d1fa83ae3f75065971dcc750df /internal/envfile
parentfc0c6951d7ac36dd8f30ace929991197dc99ae54 (diff)
Add skiff config set/unset/list, backed by /app/env in the app's dataset
Config vars are plain env vars, injected into both the running app (via rc.d) and the release phase, and versioned on the same ZFS snapshot timeline as deploys - a bad config change gets the same automatic snapshot/restart/health-check/rollback guarantee a bad deploy already gets. Extracts deploy.go's health check into internal/health so both paths share it. Verified end-to-end against a FreeBSD VM: config set/unset, automatic rollback on a health-check failure, and explicit `skiff rollback` all correctly carry the env file along with the rest of the dataset.
Diffstat (limited to 'internal/envfile')
-rw-r--r--internal/envfile/envfile.go90
1 files changed, 90 insertions, 0 deletions
diff --git a/internal/envfile/envfile.go b/internal/envfile/envfile.go
new file mode 100644
index 0000000..e729ab2
--- /dev/null
+++ b/internal/envfile/envfile.go
@@ -0,0 +1,90 @@
+// Package envfile reads and writes the KEY='value' shell-sourceable env
+// file skiff stores at each app's /app/env - the on-disk form of `skiff
+// config`. It only ever parses the format Save itself writes, not general
+// POSIX shell syntax.
+package envfile
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+var keyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+// ValidKey reports whether key is a legal env var name. Save is the
+// authoritative guard and re-checks every key itself; callers can use
+// ValidKey for an earlier, pre-snapshot failure on a typo'd key.
+func ValidKey(key string) bool {
+ return keyPattern.MatchString(key)
+}
+
+// Load reads path into a KEY->value map. A missing file is not an error -
+// it's treated as an empty config, covering a fresh app and list/unset
+// against one.
+func Load(path string) (map[string]string, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return map[string]string{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ kv := map[string]string{}
+ for _, line := range strings.Split(string(data), "\n") {
+ if line == "" {
+ continue
+ }
+ eq := strings.IndexByte(line, '=')
+ if eq < 0 {
+ continue
+ }
+ key := line[:eq]
+ quoted := line[eq+1:]
+ if !strings.HasPrefix(quoted, "'") || !strings.HasSuffix(quoted, "'") {
+ continue
+ }
+ value := strings.ReplaceAll(quoted[1:len(quoted)-1], `'\''`, `'`)
+ kv[key] = value
+ }
+ return kv, nil
+}
+
+// Save validates every key and writes kv to path atomically (write to a
+// temp file, then rename over path) at mode 0600 - a deliberate departure
+// from the codebase's universal 0644, since this file holds secrets.
+func Save(path string, kv map[string]string) error {
+ keys := make([]string, 0, len(kv))
+ for key := range kv {
+ if !keyPattern.MatchString(key) {
+ return fmt.Errorf("invalid env var key %q", key)
+ }
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ var b strings.Builder
+ for _, key := range keys {
+ b.WriteString(key)
+ b.WriteByte('=')
+ b.WriteString(escape(kv[key]))
+ b.WriteByte('\n')
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, []byte(b.String()), 0600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+func escape(value string) string {
+ return `'` + strings.ReplaceAll(value, `'`, `'\''`) + `'`
+}