Skip to content

Commit 9cc4b3e

Browse files
committed
Merge pull request 'feat: self-contained zmx provisioning' (#7) from worktree-zmx-provision into main
Reviewed-on: https://forgejo.tail9a847c.ts.net/sh/pixels/pulls/7
2 parents f6e71c9 + 54c8ac4 commit 9cc4b3e

14 files changed

Lines changed: 937 additions & 168 deletions

File tree

cmd/console.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"fmt"
55
"time"
66

7+
"github.com/briandowns/spinner"
78
"github.com/spf13/cobra"
89

910
"github.com/deevus/pixels/internal/cache"
11+
"github.com/deevus/pixels/internal/provision"
1012
"github.com/deevus/pixels/internal/ssh"
1113
)
1214

@@ -71,6 +73,26 @@ func runConsole(cmd *cobra.Command, args []string) error {
7173
return err
7274
}
7375

76+
// Wait for provisioning to finish before opening the console.
77+
runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
78+
var spin *spinner.Spinner
79+
if !verbose {
80+
spin = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(cmd.ErrOrStderr()))
81+
}
82+
runner.WaitProvisioned(ctx, func(status string) {
83+
if spin != nil {
84+
spin.Suffix = " " + status
85+
if !spin.Active() {
86+
spin.Start()
87+
}
88+
} else {
89+
logv(cmd, "Provision: %s", status)
90+
}
91+
})
92+
if spin != nil && spin.Active() {
93+
spin.Stop()
94+
}
95+
7496
// Console replaces the process — does not return on success.
7597
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
7698
}

cmd/create.go

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/briandowns/spinner"
1112
truenas "github.com/deevus/truenas-go"
1213
"github.com/spf13/cobra"
1314

1415
"github.com/deevus/pixels/internal/cache"
16+
"github.com/deevus/pixels/internal/provision"
1517
"github.com/deevus/pixels/internal/retry"
1618
"github.com/deevus/pixels/internal/ssh"
1719
tnc "github.com/deevus/pixels/internal/truenas"
@@ -66,6 +68,26 @@ func runCreate(cmd *cobra.Command, args []string) error {
6668

6769
logv(cmd, "Config: image=%s cpu=%s memory=%dMiB egress=%s", image, cpu, memory, egressMode)
6870

71+
// Spinner for non-verbose mode — shows current phase on stderr.
72+
var spin *spinner.Spinner
73+
if !verbose {
74+
spin = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(cmd.ErrOrStderr()))
75+
}
76+
setStatus := func(msg string) {
77+
if spin != nil {
78+
spin.Suffix = " " + msg
79+
if !spin.Active() {
80+
spin.Start()
81+
}
82+
}
83+
}
84+
stopSpinner := func() {
85+
if spin != nil && spin.Active() {
86+
spin.Stop()
87+
}
88+
}
89+
defer stopSpinner()
90+
6991
// Parse --from flag: "container" or "container:label"
7092
var fromSource, fromLabel string
7193
var tempSnapshot bool
@@ -163,9 +185,9 @@ func runCreate(cmd *cobra.Command, args []string) error {
163185
// (pool.dataset.* APIs can't see .ix-virt managed datasets).
164186
if skipProvision {
165187
if tempSnapshot {
166-
fmt.Fprintf(cmd.ErrOrStderr(), "Cloning from %s...\n", fromSource)
188+
setStatus(fmt.Sprintf("Cloning from %s...", fromSource))
167189
} else {
168-
fmt.Fprintf(cmd.ErrOrStderr(), "Cloning from %s checkpoint %q...\n", fromSource, fromLabel)
190+
setStatus(fmt.Sprintf("Cloning from %s checkpoint %q...", fromSource, fromLabel))
169191
}
170192

171193
logv(cmd, "Stopping %s for rootfs replacement...", containerName(name))
@@ -197,6 +219,9 @@ func runCreate(cmd *cobra.Command, args []string) error {
197219
}
198220
}
199221

222+
// Compute provisioning steps (devtools, egress) before writing files.
223+
steps := provision.Steps(egressMode, cfg.Provision.DevToolsEnabled())
224+
200225
// Provision while the container is running (rootfs only mounted when up).
201226
noProvision, _ := cmd.Flags().GetBool("no-provision")
202227
provisionEnabled := cfg.Provision.IsEnabled() && !noProvision && !skipProvision
@@ -211,14 +236,17 @@ func runCreate(cmd *cobra.Command, args []string) error {
211236
Egress: egressMode,
212237
EgressAllow: cfg.Network.Allow,
213238
}
239+
if len(steps) > 0 {
240+
provOpts.ProvisionScript = provision.Script(steps)
241+
}
214242
if verbose {
215243
provOpts.Log = cmd.ErrOrStderr()
216244
}
217245
needsProvision := pubKey != "" || len(cfg.Defaults.DNS) > 0 ||
218246
len(cfg.Env) > 0 || provOpts.DevTools
219247

220248
if needsProvision {
221-
fmt.Fprintf(cmd.ErrOrStderr(), "Provisioning...\n")
249+
setStatus("Provisioning...")
222250
logv(cmd, "SSH key: %v, DNS: %d, Env: %d, DevTools: %v, Egress: %s",
223251
pubKey != "", len(cfg.Defaults.DNS), len(cfg.Env), provOpts.DevTools, egressMode)
224252

@@ -255,6 +283,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
255283
timeout = 30 * time.Second
256284
}
257285
if provisionEnabled || skipProvision {
286+
setStatus("Waiting for SSH...")
258287
var sshLog io.Writer
259288
if verbose {
260289
sshLog = cmd.ErrOrStderr()
@@ -269,6 +298,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
269298
cache.Put(name, &cache.Entry{IP: ip, Status: instance.Status})
270299
logv(cmd, "Cached IP=%s status=%s for %s", ip, instance.Status, name)
271300

301+
stopSpinner()
272302
elapsed := time.Since(start).Truncate(100 * time.Millisecond)
273303
fmt.Fprintf(cmd.OutOrStdout(), "Created %s in %s\n", containerName(name), elapsed)
274304
fmt.Fprintf(cmd.OutOrStdout(), " Hostname: %s\n", containerName(name))
@@ -277,38 +307,18 @@ func runCreate(cmd *cobra.Command, args []string) error {
277307
}
278308
fmt.Fprintf(cmd.OutOrStdout(), " Console: pixels console %s\n", name)
279309
openConsole, _ := cmd.Flags().GetBool("console")
280-
devToolsActive := provisionEnabled && cfg.Provision.DevToolsEnabled()
281310

282-
if devToolsActive && !openConsole {
283-
fmt.Fprintf(cmd.OutOrStdout(), " Dev tools installing in background (sudo journalctl -fu pixels-devtools)\n")
311+
if len(steps) > 0 && !openConsole {
312+
fmt.Fprintf(cmd.OutOrStdout(), " Status: pixels status %s\n", name)
284313
}
285314

286315
if openConsole && ip != "" {
287-
if devToolsActive {
288-
fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for dev tools to finish installing...\n")
289-
290-
// Stream journal output so the user can see progress.
291-
var journalCancel context.CancelFunc
292-
var done chan struct{}
293-
if verbose {
294-
var journalCtx context.Context
295-
journalCtx, journalCancel = context.WithCancel(ctx)
296-
done = make(chan struct{})
297-
go func() {
298-
defer close(done)
299-
ssh.Exec(journalCtx, ip, "root", cfg.SSH.Key,
300-
[]string{"journalctl", "-fu", "pixels-devtools", "--no-pager", "-o", "cat"}, nil)
301-
}()
302-
}
303-
304-
if err := ssh.WaitProvisioned(ctx, ip, cfg.SSH.User, cfg.SSH.Key, 10*time.Minute); err != nil {
305-
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err)
306-
}
307-
if journalCancel != nil {
308-
journalCancel()
309-
<-done
310-
}
311-
}
316+
runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
317+
runner.WaitProvisioned(ctx, func(status string) {
318+
setStatus(status)
319+
logv(cmd, "Provision: %s", status)
320+
})
321+
stopSpinner()
312322
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
313323
}
314324

cmd/status.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"text/tabwriter"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/deevus/pixels/internal/cache"
12+
"github.com/deevus/pixels/internal/provision"
13+
"github.com/deevus/pixels/internal/ssh"
14+
)
15+
16+
func init() {
17+
rootCmd.AddCommand(&cobra.Command{
18+
Use: "status <name>",
19+
Short: "Show provisioning step status",
20+
Args: cobra.ExactArgs(1),
21+
RunE: runStatus,
22+
})
23+
}
24+
25+
func runStatus(cmd *cobra.Command, args []string) error {
26+
ctx := cmd.Context()
27+
name := args[0]
28+
29+
var ip string
30+
if cached := cache.Get(name); cached != nil && cached.IP != "" && cached.Status == "RUNNING" {
31+
ip = cached.IP
32+
}
33+
34+
if ip == "" {
35+
client, err := connectClient(ctx)
36+
if err != nil {
37+
return err
38+
}
39+
defer client.Close()
40+
41+
instance, err := client.Virt.GetInstance(ctx, containerName(name))
42+
if err != nil {
43+
return fmt.Errorf("looking up %s: %w", name, err)
44+
}
45+
if instance == nil {
46+
return fmt.Errorf("pixel %q not found", name)
47+
}
48+
if instance.Status != "RUNNING" {
49+
return fmt.Errorf("pixel %q is not running (status: %s)", name, instance.Status)
50+
}
51+
52+
ip = resolveIP(instance)
53+
if ip == "" {
54+
return fmt.Errorf("no IP address for %s", name)
55+
}
56+
}
57+
58+
if err := ssh.WaitReady(ctx, ip, 10*time.Second, nil); err != nil {
59+
return fmt.Errorf("waiting for SSH: %w", err)
60+
}
61+
62+
runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
63+
raw, err := runner.List(ctx)
64+
if err != nil {
65+
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "No such file") {
66+
fmt.Fprintln(cmd.OutOrStdout(), "No provisioning steps found (zmx not installed)")
67+
return nil
68+
}
69+
return err
70+
}
71+
72+
sessions := provision.ParseSessions(raw)
73+
74+
// Filter to px-* sessions (our provisioning steps).
75+
var steps []provision.Session
76+
for _, s := range sessions {
77+
if strings.HasPrefix(s.Name, "px-") {
78+
steps = append(steps, s)
79+
}
80+
}
81+
82+
if len(steps) == 0 {
83+
if runner.IsProvisioned(ctx) {
84+
fmt.Fprintln(cmd.OutOrStdout(), "Provisioning complete")
85+
} else if runner.HasProvisionScript(ctx) {
86+
fmt.Fprintln(cmd.OutOrStdout(), "Provisioning in progress...")
87+
} else {
88+
fmt.Fprintln(cmd.OutOrStdout(), "No provisioning steps found")
89+
}
90+
return nil
91+
}
92+
93+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
94+
fmt.Fprintln(w, "STEP\tSTATUS\tEXIT")
95+
for _, s := range steps {
96+
status := "running"
97+
exit := "-"
98+
if s.EndedAt != "" {
99+
status = "done"
100+
exit = s.ExitCode
101+
if exit != "0" {
102+
status = "failed"
103+
}
104+
}
105+
fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, status, exit)
106+
}
107+
w.Flush()
108+
109+
return nil
110+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ require (
1212

1313
require (
1414
al.essio.dev/pkg/shellescape v1.6.0 // indirect
15+
github.com/briandowns/spinner v1.23.2 // indirect
16+
github.com/fatih/color v1.7.0 // indirect
1517
github.com/gorilla/websocket v1.5.3 // indirect
1618
github.com/inconshreveable/mousetrap v1.1.0 // indirect
19+
github.com/mattn/go-colorable v0.1.2 // indirect
20+
github.com/mattn/go-isatty v0.0.8 // indirect
1721
github.com/spf13/pflag v1.0.9 // indirect
1822
golang.org/x/crypto v0.48.0 // indirect
1923
golang.org/x/sys v0.41.0 // indirect
24+
golang.org/x/term v0.40.0 // indirect
2025
golang.org/x/time v0.14.0 // indirect
2126
)

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,27 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeX
22
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
33
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
44
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
5+
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
6+
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
57
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
68
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
79
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
810
github.com/deevus/truenas-go v0.4.0 h1:gESJ0naqtwzgdN1/gG5wBrp/Lm/5HF8xCBsRcwNgg78=
911
github.com/deevus/truenas-go v0.4.0/go.mod h1:a5MwZEqT4NE8jwSA9BHONOAO8yH4kCaS5a+d4ad6sLA=
1012
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
1113
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
14+
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
15+
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
1216
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
1317
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
1418
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
1519
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
1620
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
1721
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
22+
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
23+
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
24+
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
25+
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
1826
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
1927
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
2028
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -23,6 +31,7 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
2331
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
2432
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
2533
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
34+
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
2635
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
2736
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
2837
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=

0 commit comments

Comments
 (0)