Skip to content

Commit f6e71c9

Browse files
committed
Merge pull request 'feat: runtime env var forwarding via SSH SetEnv' (#6) from worktree-env-forwarding into main
Reviewed-on: https://forgejo.tail9a847c.ts.net/sh/pixels/pulls/6
2 parents 8f74409 + c811836 commit f6e71c9

12 files changed

Lines changed: 298 additions & 39 deletions

File tree

cmd/console.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,5 @@ func runConsole(cmd *cobra.Command, args []string) error {
7272
}
7373

7474
// Console replaces the process — does not return on success.
75-
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key)
75+
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
7676
}

cmd/create.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
297297
go func() {
298298
defer close(done)
299299
ssh.Exec(journalCtx, ip, "root", cfg.SSH.Key,
300-
[]string{"journalctl", "-fu", "pixels-devtools", "--no-pager", "-o", "cat"})
300+
[]string{"journalctl", "-fu", "pixels-devtools", "--no-pager", "-o", "cat"}, nil)
301301
}()
302302
}
303303

@@ -309,7 +309,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
309309
<-done
310310
}
311311
}
312-
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key)
312+
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
313313
}
314314

315315
return nil

cmd/exec.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func runExec(cmd *cobra.Command, args []string) error {
6565
return err
6666
}
6767

68-
exitCode, err := ssh.Exec(ctx, ip, cfg.SSH.User, cfg.SSH.Key, command)
68+
exitCode, err := ssh.Exec(ctx, ip, cfg.SSH.User, cfg.SSH.Key, command, cfg.EnvForward)
6969
if err != nil {
7070
return err
7171
}

cmd/network.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func resolveNetworkContext(cmd *cobra.Command, name string) (*networkContext, er
9393

9494
// sshAsRoot runs a command on the container as root via SSH.
9595
func sshAsRoot(cmd *cobra.Command, ip string, command []string) (int, error) {
96-
return ssh.Exec(cmd.Context(), ip, "root", cfg.SSH.Key, command)
96+
return ssh.Exec(cmd.Context(), ip, "root", cfg.SSH.Key, command, nil)
9797
}
9898

9999
func runNetworkShow(cmd *cobra.Command, args []string) error {

internal/config/config.go

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ import (
1111
)
1212

1313
type Config struct {
14-
TrueNAS TrueNAS `toml:"truenas"`
15-
Defaults Defaults `toml:"defaults"`
16-
SSH SSH `toml:"ssh"`
17-
Checkpoint Checkpoint `toml:"checkpoint"`
18-
Provision Provision `toml:"provision"`
19-
Network Network `toml:"network"`
20-
Env map[string]string `toml:"env"`
14+
TrueNAS TrueNAS `toml:"truenas"`
15+
Defaults Defaults `toml:"defaults"`
16+
SSH SSH `toml:"ssh"`
17+
Checkpoint Checkpoint `toml:"checkpoint"`
18+
Provision Provision `toml:"provision"`
19+
Network Network `toml:"network"`
20+
RawEnv map[string]any `toml:"env"`
21+
22+
// Resolved env vars (not from TOML directly).
23+
Env map[string]string `toml:"-"` // image vars → /etc/environment
24+
EnvForward map[string]string `toml:"-"` // session vars → SSH SetEnv
2125
}
2226

2327
type TrueNAS struct {
@@ -109,13 +113,63 @@ func Load() (*Config, error) {
109113

110114
cfg.SSH.Key = expandHome(cfg.SSH.Key)
111115

112-
for k, v := range cfg.Env {
113-
cfg.Env[k] = os.ExpandEnv(v)
116+
if err := resolveEnv(cfg); err != nil {
117+
return nil, err
114118
}
115119

116120
return cfg, nil
117121
}
118122

123+
// resolveEnv splits RawEnv entries into image vars (Env) and session vars (EnvForward).
124+
//
125+
// Supported forms:
126+
//
127+
// KEY = "value" → image var (with $VAR expansion)
128+
// KEY = { value = "x" } → image var (with $VAR expansion)
129+
// KEY = { forward = true } → session var (from host env, skip if unset)
130+
// KEY = { value = "x", session_only = true } → session var (literal, with $VAR expansion)
131+
func resolveEnv(cfg *Config) error {
132+
if len(cfg.RawEnv) == 0 {
133+
return nil
134+
}
135+
136+
cfg.Env = make(map[string]string)
137+
cfg.EnvForward = make(map[string]string)
138+
139+
for k, raw := range cfg.RawEnv {
140+
switch v := raw.(type) {
141+
case string:
142+
cfg.Env[k] = os.ExpandEnv(v)
143+
case map[string]any:
144+
forward, _ := v["forward"].(bool)
145+
sessionOnly, _ := v["session_only"].(bool)
146+
value, _ := v["value"].(string)
147+
148+
switch {
149+
case forward:
150+
if hostVal, ok := os.LookupEnv(k); ok {
151+
cfg.EnvForward[k] = hostVal
152+
}
153+
case sessionOnly && value != "":
154+
cfg.EnvForward[k] = os.ExpandEnv(value)
155+
case value != "":
156+
cfg.Env[k] = os.ExpandEnv(value)
157+
}
158+
default:
159+
return fmt.Errorf("env %q: unsupported type %T", k, raw)
160+
}
161+
}
162+
163+
if len(cfg.Env) == 0 {
164+
cfg.Env = nil
165+
}
166+
if len(cfg.EnvForward) == 0 {
167+
cfg.EnvForward = nil
168+
}
169+
170+
return nil
171+
}
172+
119173
func configPath() string {
120174
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
121175
return filepath.Join(dir, "pixels", "config.toml")

internal/config/config_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,113 @@ LITERAL = "no-expansion-here"
288288
}
289289
}
290290

291+
func TestEnvForward(t *testing.T) {
292+
dir := t.TempDir()
293+
t.Setenv("XDG_CONFIG_HOME", dir)
294+
295+
cfgDir := filepath.Join(dir, "pixels")
296+
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
297+
t.Fatal(err)
298+
}
299+
300+
content := `
301+
[env]
302+
IMAGE_VAR = "baked-in"
303+
EXPLICIT_IMAGE = { value = "explicit" }
304+
FORWARDED = { forward = true }
305+
FORWARDED_UNSET = { forward = true }
306+
SESSION_LITERAL = { value = "session-val", session_only = true }
307+
EXPANDED_IMAGE = { value = "$PIXELS_TEST_EXPAND" }
308+
`
309+
if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(content), 0o644); err != nil {
310+
t.Fatal(err)
311+
}
312+
313+
t.Setenv("FORWARDED", "host-value")
314+
// FORWARDED_UNSET intentionally not set.
315+
t.Setenv("PIXELS_TEST_EXPAND", "expanded-val")
316+
317+
cfg, err := Load()
318+
if err != nil {
319+
t.Fatalf("Load() error: %v", err)
320+
}
321+
322+
// Image vars (cfg.Env).
323+
if cfg.Env["IMAGE_VAR"] != "baked-in" {
324+
t.Errorf("Env[IMAGE_VAR] = %q, want %q", cfg.Env["IMAGE_VAR"], "baked-in")
325+
}
326+
if cfg.Env["EXPLICIT_IMAGE"] != "explicit" {
327+
t.Errorf("Env[EXPLICIT_IMAGE] = %q, want %q", cfg.Env["EXPLICIT_IMAGE"], "explicit")
328+
}
329+
if cfg.Env["EXPANDED_IMAGE"] != "expanded-val" {
330+
t.Errorf("Env[EXPANDED_IMAGE] = %q, want %q", cfg.Env["EXPANDED_IMAGE"], "expanded-val")
331+
}
332+
333+
// Image vars should NOT include session entries.
334+
if _, ok := cfg.Env["FORWARDED"]; ok {
335+
t.Error("Env should not contain FORWARDED (it's a session var)")
336+
}
337+
if _, ok := cfg.Env["SESSION_LITERAL"]; ok {
338+
t.Error("Env should not contain SESSION_LITERAL (it's a session var)")
339+
}
340+
341+
// Session vars (cfg.EnvForward).
342+
if cfg.EnvForward["FORWARDED"] != "host-value" {
343+
t.Errorf("EnvForward[FORWARDED] = %q, want %q", cfg.EnvForward["FORWARDED"], "host-value")
344+
}
345+
if _, ok := cfg.EnvForward["FORWARDED_UNSET"]; ok {
346+
t.Error("EnvForward should not contain FORWARDED_UNSET (not set on host)")
347+
}
348+
if cfg.EnvForward["SESSION_LITERAL"] != "session-val" {
349+
t.Errorf("EnvForward[SESSION_LITERAL] = %q, want %q", cfg.EnvForward["SESSION_LITERAL"], "session-val")
350+
}
351+
352+
// Session vars should NOT include image entries.
353+
if _, ok := cfg.EnvForward["IMAGE_VAR"]; ok {
354+
t.Error("EnvForward should not contain IMAGE_VAR (it's an image var)")
355+
}
356+
}
357+
358+
func TestEnvForwardNilWhenEmpty(t *testing.T) {
359+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
360+
361+
cfg, err := Load()
362+
if err != nil {
363+
t.Fatalf("Load() error: %v", err)
364+
}
365+
366+
if cfg.Env != nil {
367+
t.Errorf("Env = %v, want nil (no env configured)", cfg.Env)
368+
}
369+
if cfg.EnvForward != nil {
370+
t.Errorf("EnvForward = %v, want nil (no env configured)", cfg.EnvForward)
371+
}
372+
}
373+
374+
func TestEnvUnsupportedType(t *testing.T) {
375+
dir := t.TempDir()
376+
t.Setenv("XDG_CONFIG_HOME", dir)
377+
378+
cfgDir := filepath.Join(dir, "pixels")
379+
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
380+
t.Fatal(err)
381+
}
382+
383+
// An integer value in [env] is not a valid env entry.
384+
content := `
385+
[env]
386+
BAD = 42
387+
`
388+
if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(content), 0o644); err != nil {
389+
t.Fatal(err)
390+
}
391+
392+
_, err := Load()
393+
if err == nil {
394+
t.Fatal("expected error for unsupported env type, got nil")
395+
}
396+
}
397+
291398
func TestNetworkDefaults(t *testing.T) {
292399
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
293400
for _, key := range []string{

internal/ssh/console_unix.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import (
1010
)
1111

1212
// Console replaces the current process with an interactive SSH session.
13-
func Console(host, user, keyPath string) error {
13+
// If env is non-nil, the entries are forwarded via SSH SetEnv.
14+
func Console(host, user, keyPath string, env map[string]string) error {
1415
sshBin, err := exec.LookPath("ssh")
1516
if err != nil {
1617
return fmt.Errorf("ssh binary not found: %w", err)
1718
}
18-
args := append([]string{"ssh"}, sshArgs(host, user, keyPath)...)
19+
args := append([]string{"ssh"}, sshArgs(host, user, keyPath, env)...)
1920
return syscall.Exec(sshBin, args, os.Environ())
2021
}

internal/ssh/console_windows.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import (
99
)
1010

1111
// Console runs an interactive SSH session as a child process.
12-
func Console(host, user, keyPath string) error {
12+
// If env is non-nil, the entries are forwarded via SSH SetEnv.
13+
func Console(host, user, keyPath string, env map[string]string) error {
1314
sshBin, err := exec.LookPath("ssh")
1415
if err != nil {
1516
return fmt.Errorf("ssh binary not found: %w", err)
1617
}
17-
cmd := exec.Command(sshBin, sshArgs(host, user, keyPath)...)
18+
cmd := exec.Command(sshBin, sshArgs(host, user, keyPath, env)...)
1819
cmd.Stdin = os.Stdin
1920
cmd.Stdout = os.Stdout
2021
cmd.Stderr = os.Stderr

internal/ssh/ssh.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"net"
99
"os"
1010
"os/exec"
11+
"sort"
12+
"strings"
1113
"time"
1214

1315
"github.com/deevus/pixels/internal/retry"
@@ -46,8 +48,9 @@ func WaitReady(ctx context.Context, host string, timeout time.Duration, log io.W
4648
}
4749

4850
// Exec runs a command on the remote host via SSH and returns its exit code.
49-
func Exec(ctx context.Context, host, user, keyPath string, command []string) (int, error) {
50-
args := append(sshArgs(host, user, keyPath), command...)
51+
// If env is non-nil, the entries are forwarded via SSH SetEnv.
52+
func Exec(ctx context.Context, host, user, keyPath string, command []string, env map[string]string) (int, error) {
53+
args := append(sshArgs(host, user, keyPath, env), command...)
5154
cmd := exec.CommandContext(ctx, "ssh", args...)
5255
cmd.Stdin = os.Stdin
5356
cmd.Stdout = os.Stdout
@@ -65,7 +68,7 @@ func Exec(ctx context.Context, host, user, keyPath string, command []string) (in
6568

6669
// Output runs a command on the remote host via SSH and returns its stdout.
6770
func Output(ctx context.Context, host, user, keyPath string, command []string) ([]byte, error) {
68-
args := append(sshArgs(host, user, keyPath), command...)
71+
args := append(sshArgs(host, user, keyPath, nil), command...)
6972
cmd := exec.CommandContext(ctx, "ssh", args...)
7073
cmd.Stderr = os.Stderr
7174
return cmd.Output()
@@ -74,20 +77,20 @@ func Output(ctx context.Context, host, user, keyPath string, command []string) (
7477
// WaitProvisioned polls the remote host until /root/.devtools-provisioned exists.
7578
func WaitProvisioned(ctx context.Context, host, user, keyPath string, timeout time.Duration) error {
7679
return retry.Poll(ctx, 2*time.Second, timeout, func(ctx context.Context) (bool, error) {
77-
code, err := Exec(ctx, host, user, keyPath, []string{"sudo", "test", "-f", "/root/.devtools-provisioned"})
80+
code, err := Exec(ctx, host, user, keyPath, []string{"sudo", "test", "-f", "/root/.devtools-provisioned"}, nil)
7881
return err == nil && code == 0, nil
7982
})
8083
}
8184

8285
// TestAuth runs a quick SSH connection test (ssh ... true) to verify
8386
// key-based authentication works. Returns nil on success.
8487
func TestAuth(ctx context.Context, host, user, keyPath string) error {
85-
args := append(sshArgs(host, user, keyPath), "true")
88+
args := append(sshArgs(host, user, keyPath, nil), "true")
8689
cmd := exec.CommandContext(ctx, "ssh", args...)
8790
return cmd.Run()
8891
}
8992

90-
func sshArgs(host, user, keyPath string) []string {
93+
func sshArgs(host, user, keyPath string, env map[string]string) []string {
9194
args := []string{
9295
"-o", "StrictHostKeyChecking=no",
9396
"-o", "UserKnownHostsFile=" + os.DevNull,
@@ -97,6 +100,28 @@ func sshArgs(host, user, keyPath string) []string {
97100
if keyPath != "" {
98101
args = append(args, "-i", keyPath)
99102
}
103+
104+
// Forward env vars via SSH protocol (requires AcceptEnv on server).
105+
// All vars must be in a single SetEnv directive (multiple -o SetEnv
106+
// flags don't stack in OpenSSH — only the first takes effect).
107+
if len(env) > 0 {
108+
keys := make([]string, 0, len(env))
109+
for k := range env {
110+
keys = append(keys, k)
111+
}
112+
sort.Strings(keys)
113+
114+
var setenv strings.Builder
115+
setenv.WriteString("SetEnv=")
116+
for i, k := range keys {
117+
if i > 0 {
118+
setenv.WriteByte(' ')
119+
}
120+
fmt.Fprintf(&setenv, "%s=%s", k, env[k])
121+
}
122+
args = append(args, "-o", setenv.String())
123+
}
124+
100125
args = append(args, user+"@"+host)
101126
return args
102127
}

0 commit comments

Comments
 (0)