Skip to content

Commit f7936df

Browse files
committed
feat(env): env_files — SOPS+age encrypted env files (GitOps secrets)
HashiCorp-parity Tier 3 #9: file-based at-rest secrets, no daemon. `env_files:` in teploy.yml lists local dotenv/YAML files merged into the container env at deploy: *.age decrypts via the age identity (TEPLOY_AGE_IDENTITY / SOPS_AGE_KEY_FILE / ~/.config/teploy/age.txt), *.sops.* and *.enc.* via `sops -d` (whatever backend .sops.yaml configures), everything else parses as plain dotenv; YAML/JSON payloads contribute top-level scalar keys. Decryption shells out to the standard local binaries (mirrors the server-side secret store's age pattern — zero new Go deps, full interop with files the standard tools produce). Explicit env: keys win over file values; missing/undecryptable files fail the deploy loudly rather than shipping without secrets. Resolved once in runDeploy so single- and multi-server paths share it; overlay merge appends. Tested incl. a REAL age round-trip (keygen -> encrypt -> decrypt through LoadLocalEnvFiles).
1 parent 00a91da commit f7936df

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ assets:
193193

194194
notifications:
195195
webhook: https://hooks.slack.com/services/xxx
196+
197+
# GitOps secrets: local env files merged into the container env at deploy.
198+
# Encrypted files decrypt on YOUR machine (never plaintext in the repo or
199+
# on the server): *.age via age (identity from TEPLOY_AGE_IDENTITY,
200+
# SOPS_AGE_KEY_FILE, or ~/.config/teploy/age.txt), *.sops.* / *.enc.* via
201+
# `sops -d` (any backend in your .sops.yaml — age, KMS, PGP). Later files
202+
# and explicit env: keys win.
203+
env_files:
204+
- secrets.env.age
196205
```
197206
198207
TOML is also supported (`teploy.toml`).

‎internal/cli/deploy.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/useteploy/teploy/internal/deploy"
2020
"github.com/useteploy/teploy/internal/dns"
2121
"github.com/useteploy/teploy/internal/docker"
22+
"github.com/useteploy/teploy/internal/env"
2223
"github.com/useteploy/teploy/internal/multideploy"
2324
"github.com/useteploy/teploy/internal/notify"
2425
"github.com/useteploy/teploy/internal/secret"
@@ -143,6 +144,24 @@ func runDeploy(flags *Flags, serverName, image, version string, skipDNSCheck boo
143144
return err
144145
}
145146

147+
// Resolve env_files (SOPS/age-encrypted or plain, decrypted locally)
148+
// once, before dispatch — both the single- and multi-server paths then
149+
// pick them up from appCfg.Env. Explicit env: keys win over file values.
150+
if len(appCfg.EnvFiles) > 0 {
151+
fileVars, err := env.LoadLocalEnvFiles(".", appCfg.EnvFiles)
152+
if err != nil {
153+
return err
154+
}
155+
if appCfg.Env == nil {
156+
appCfg.Env = map[string]string{}
157+
}
158+
for k, v := range fileVars {
159+
if _, explicit := appCfg.Env[k]; !explicit {
160+
appCfg.Env[k] = v
161+
}
162+
}
163+
}
164+
146165
// Multi-server deploy: if teploy.yml lists multiple servers and no explicit
147166
// server argument was provided, deploy to all of them in parallel.
148167
if len(appCfg.Servers) > 1 && serverName == "" {

‎internal/config/app.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ type AppConfig struct {
251251
// for the main wave. Absent (nil) = existing behavior (parallel batches,
252252
// fail-fast + full-fleet rollback on any failure).
253253
Rollout *RolloutConfig `yaml:"rollout,omitempty" toml:"rollout"`
254+
// EnvFiles are local dotenv/YAML files merged into the container env at
255+
// deploy, resolved relative to teploy.yml. Encrypted files are decrypted
256+
// on the operator's machine: *.age via the age identity, *.sops.*/*.enc.*
257+
// via `sops -d` — the GitOps pattern (secrets encrypted in the repo,
258+
// never plaintext on disk). Later files and explicit env: keys win.
259+
EnvFiles []string `yaml:"env_files,omitempty" toml:"env_files"`
254260
// KeepVersions caps the number of past app versions retained after a
255261
// successful deploy (containers + images). Zero (default) keeps
256262
// everything — historical behavior. Set to 2 or 3 to enable auto-prune
@@ -679,6 +685,9 @@ func mergeConfigs(base, overlay *AppConfig) {
679685
if overlay.Rollout != nil {
680686
base.Rollout = overlay.Rollout
681687
}
688+
if len(overlay.EnvFiles) > 0 {
689+
base.EnvFiles = append(base.EnvFiles, overlay.EnvFiles...)
690+
}
682691
if overlay.KeepVersions != 0 {
683692
base.KeepVersions = overlay.KeepVersions
684693
}

‎internal/env/localfiles.go‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package env
2+
3+
// Local env files with at-rest encryption (the SOPS+age GitOps pattern):
4+
// secrets live encrypted in the repo, get decrypted on the operator's
5+
// machine at deploy time, and ride the existing deploy-env path to the
6+
// container. Nothing new is persisted anywhere — the encrypted file stays
7+
// the source of truth.
8+
//
9+
// Decryption shells out to the local `age` / `sops` binaries (mirroring how
10+
// the server-side secret store shells out to age on the host) so teploy
11+
// carries no crypto dependencies and interoperates with files produced by
12+
// the standard tools.
13+
14+
import (
15+
"errors"
16+
"fmt"
17+
"os"
18+
"os/exec"
19+
"path/filepath"
20+
"strings"
21+
22+
"gopkg.in/yaml.v3"
23+
)
24+
25+
// LoadLocalEnvFiles reads each file (paths relative to dir), decrypting as
26+
// needed, and returns the merged KEY=value map (later files win).
27+
//
28+
// - `*.age` — age-encrypted dotenv; decrypted with the
29+
// identity file from $TEPLOY_AGE_IDENTITY, $SOPS_AGE_KEY_FILE, or
30+
// ~/.config/teploy/age.txt (first that exists).
31+
// - `*.sops.*` / `*.enc.*` — SOPS-encrypted (any age/KMS backend
32+
// configured in .sops.yaml); decrypted via `sops -d`. dotenv, YAML, and
33+
// JSON payloads are supported — YAML/JSON contribute their top-level
34+
// scalar keys.
35+
// - anything else — plain dotenv, read as-is.
36+
func LoadLocalEnvFiles(dir string, paths []string) (map[string]string, error) {
37+
merged := make(map[string]string)
38+
for _, p := range paths {
39+
full := p
40+
if !filepath.IsAbs(full) {
41+
full = filepath.Join(dir, p)
42+
}
43+
var (
44+
content string
45+
err error
46+
)
47+
switch {
48+
case strings.HasSuffix(full, ".age"):
49+
content, err = decryptAgeFile(full)
50+
case isSopsName(full):
51+
content, err = decryptSopsFile(full)
52+
default:
53+
var raw []byte
54+
raw, err = os.ReadFile(full)
55+
content = string(raw)
56+
}
57+
if err != nil {
58+
return nil, fmt.Errorf("env file %s: %w", p, err)
59+
}
60+
vars, err := parseEnvContent(full, content)
61+
if err != nil {
62+
return nil, fmt.Errorf("env file %s: %w", p, err)
63+
}
64+
for k, v := range vars {
65+
merged[k] = v
66+
}
67+
}
68+
return merged, nil
69+
}
70+
71+
func isSopsName(path string) bool {
72+
base := filepath.Base(path)
73+
return strings.Contains(base, ".sops.") || strings.Contains(base, ".enc.")
74+
}
75+
76+
// ageIdentityFile resolves the age identity (private key) file to decrypt
77+
// with. Explicit env vars first, then the teploy-conventional location.
78+
func ageIdentityFile() (string, error) {
79+
for _, envVar := range []string{"TEPLOY_AGE_IDENTITY", "SOPS_AGE_KEY_FILE"} {
80+
if p := os.Getenv(envVar); p != "" {
81+
return p, nil
82+
}
83+
}
84+
home, err := os.UserHomeDir()
85+
if err == nil {
86+
conventional := filepath.Join(home, ".config", "teploy", "age.txt")
87+
if _, statErr := os.Stat(conventional); statErr == nil {
88+
return conventional, nil
89+
}
90+
}
91+
return "", fmt.Errorf("no age identity found — set TEPLOY_AGE_IDENTITY (or SOPS_AGE_KEY_FILE), or put the key at ~/.config/teploy/age.txt")
92+
}
93+
94+
func decryptAgeFile(path string) (string, error) {
95+
if _, err := exec.LookPath("age"); err != nil {
96+
return "", fmt.Errorf("`age` binary not found — install age (https://age-encryption.org) to use .age env files")
97+
}
98+
identity, err := ageIdentityFile()
99+
if err != nil {
100+
return "", err
101+
}
102+
out, err := exec.Command("age", "-d", "-i", identity, path).Output()
103+
if err != nil {
104+
return "", fmt.Errorf("age decrypt failed: %w%s", err, stderrOf(err))
105+
}
106+
return string(out), nil
107+
}
108+
109+
func decryptSopsFile(path string) (string, error) {
110+
if _, err := exec.LookPath("sops"); err != nil {
111+
return "", fmt.Errorf("`sops` binary not found — install sops (https://github.com/getsops/sops) to use SOPS env files")
112+
}
113+
out, err := exec.Command("sops", "-d", path).Output()
114+
if err != nil {
115+
return "", fmt.Errorf("sops decrypt failed: %w%s", err, stderrOf(err))
116+
}
117+
return string(out), nil
118+
}
119+
120+
func stderrOf(err error) string {
121+
var ee *exec.ExitError
122+
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
123+
return ": " + strings.TrimSpace(string(ee.Stderr))
124+
}
125+
return ""
126+
}
127+
128+
// parseEnvContent interprets decrypted payloads: YAML/JSON files contribute
129+
// their top-level scalar keys; everything else is parsed as dotenv.
130+
func parseEnvContent(path, content string) (map[string]string, error) {
131+
name := strings.ToLower(filepath.Base(path))
132+
// Strip encryption suffixes to find the underlying format:
133+
// secrets.yaml.age -> secrets.yaml, secrets.sops.json -> secrets.json.
134+
name = strings.TrimSuffix(name, ".age")
135+
name = strings.ReplaceAll(name, ".sops.", ".")
136+
name = strings.ReplaceAll(name, ".enc.", ".")
137+
if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".json") {
138+
var doc map[string]any
139+
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
140+
return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err)
141+
}
142+
vars := make(map[string]string, len(doc))
143+
for k, v := range doc {
144+
switch val := v.(type) {
145+
case string:
146+
vars[k] = val
147+
case int, int64, float64, bool:
148+
vars[k] = fmt.Sprintf("%v", val)
149+
default:
150+
// Nested structures (and SOPS's own `sops:` metadata block,
151+
// which survives in the plaintext of some formats) are not
152+
// env material.
153+
}
154+
}
155+
delete(vars, "sops")
156+
return vars, nil
157+
}
158+
return parseEnv(content), nil
159+
}

‎internal/env/localfiles_test.go‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package env
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"strings"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func TestLoadLocalEnvFilesPlainDotenv(t *testing.T) {
12+
dir := t.TempDir()
13+
os.WriteFile(filepath.Join(dir, "a.env"), []byte("FOO=1\nBAR=two\n# comment\n"), 0o600)
14+
os.WriteFile(filepath.Join(dir, "b.env"), []byte("BAR=three\nBAZ=4\n"), 0o600)
15+
got, err := LoadLocalEnvFiles(dir, []string{"a.env", "b.env"})
16+
if err != nil {
17+
t.Fatal(err)
18+
}
19+
// Later files win.
20+
if got["FOO"] != "1" || got["BAR"] != "three" || got["BAZ"] != "4" {
21+
t.Errorf("merged = %v", got)
22+
}
23+
}
24+
25+
func TestLoadLocalEnvFilesYAMLScalars(t *testing.T) {
26+
dir := t.TempDir()
27+
os.WriteFile(filepath.Join(dir, "vars.yaml"), []byte("KEY: value\nNUM: 7\nFLAG: true\nnested:\n x: 1\n"), 0o600)
28+
got, err := LoadLocalEnvFiles(dir, []string{"vars.yaml"})
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
if got["KEY"] != "value" || got["NUM"] != "7" || got["FLAG"] != "true" {
33+
t.Errorf("yaml scalars = %v", got)
34+
}
35+
if _, ok := got["nested"]; ok {
36+
t.Error("nested structures must not become env vars")
37+
}
38+
}
39+
40+
func TestLoadLocalEnvFilesMissingFile(t *testing.T) {
41+
if _, err := LoadLocalEnvFiles(t.TempDir(), []string{"nope.env"}); err == nil {
42+
t.Fatal("missing file must error, not silently deploy without secrets")
43+
}
44+
}
45+
46+
func TestIsSopsName(t *testing.T) {
47+
for name, want := range map[string]bool{
48+
"secrets.sops.yaml": true,
49+
"secrets.enc.env": true,
50+
"plain.env": false,
51+
"key.age": false,
52+
} {
53+
if got := isSopsName(name); got != want {
54+
t.Errorf("isSopsName(%q) = %v, want %v", name, got, want)
55+
}
56+
}
57+
}
58+
59+
// End-to-end age round-trip — skipped when age isn't installed locally.
60+
func TestLoadLocalEnvFilesAgeRoundTrip(t *testing.T) {
61+
if _, err := exec.LookPath("age"); err != nil {
62+
t.Skip("age not installed")
63+
}
64+
if _, err := exec.LookPath("age-keygen"); err != nil {
65+
t.Skip("age-keygen not installed")
66+
}
67+
dir := t.TempDir()
68+
identity := filepath.Join(dir, "key.txt")
69+
keygenOut, err := exec.Command("age-keygen", "-o", identity).CombinedOutput()
70+
if err != nil {
71+
t.Fatalf("age-keygen: %v: %s", err, keygenOut)
72+
}
73+
recipient := ""
74+
for _, line := range strings.Split(string(mustRead(t, identity)), "\n") {
75+
if strings.HasPrefix(line, "# public key: ") {
76+
recipient = strings.TrimPrefix(line, "# public key: ")
77+
}
78+
}
79+
if recipient == "" {
80+
t.Fatal("no recipient in age-keygen output")
81+
}
82+
83+
plain := filepath.Join(dir, "secrets.env")
84+
os.WriteFile(plain, []byte("TOKEN=s3cret\n"), 0o600)
85+
encPath := filepath.Join(dir, "secrets.env.age")
86+
if out, err := exec.Command("age", "-r", recipient, "-o", encPath, plain).CombinedOutput(); err != nil {
87+
t.Fatalf("age encrypt: %v: %s", err, out)
88+
}
89+
os.Remove(plain)
90+
91+
t.Setenv("TEPLOY_AGE_IDENTITY", identity)
92+
got, err := LoadLocalEnvFiles(dir, []string{"secrets.env.age"})
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
if got["TOKEN"] != "s3cret" {
97+
t.Errorf("TOKEN = %q", got["TOKEN"])
98+
}
99+
}
100+
101+
func mustRead(t *testing.T, p string) []byte {
102+
t.Helper()
103+
b, err := os.ReadFile(p)
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
return b
108+
}

0 commit comments

Comments
 (0)