Skip to content

Commit 4985992

Browse files
committed
feat(sylvevm): add WinRM auto-tunnel and boot_wait support
- Add StepWinRMTunnel: automatically opens SSH port-forward from a random localhost port to the VM's WinRM port through the Sylve host when communicator = "winrm"; overrides Config.WinRMHost/WinRMPort and instance_ip state so communicator.StepConnect is transparent - Add StepBootWait: waits boot_wait duration after VM start before IP discovery; useful for Windows guests that need time to start WinRM - Add resolveBastionSSHParams() and dialBastionSSH() to ssh_bastion.go - Gate applyAutoBastion to SSH communicator only - Add windows.pkr.hcl example with WinRM communicator, boot_wait=1m, winrm_timeout=3m, shutdown_command for graceful Windows shutdown, and sentinel rollback verification in the PowerShell provisioner - Add winrm_username/password/port variables to variables.pkr.hcl - Fix: update instance_ip state bag to 127.0.0.1 when tunnel is active so communicator.StepConnect Host function uses tunnel address - Docs: add boot_wait, WinRM options, auto-tunnel section and Windows example to docs/builders/sylve-vm.mdx Verified with two consecutive builds: rollback removes the sentinel file created by the provisioner, confirming ZFS snapshot rollback works end-to-end.
1 parent 62c9d79 commit 4985992

53 files changed

Lines changed: 9314 additions & 217 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ examples/sylve-iso/hello-debian
2323
examples/sylve-iso/hello-freebsd
2424
examples/sylve-iso/hello-openbsd
2525

26-
# Packer debug log
26+
# Packer debug logs (may contain JWTs from Sylve API responses)
2727
packer.log
28+
packer-*.log
2829

2930
# SBOM and security scan artifacts
3031
sbom.cdx.xml

.gitleaks.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ useDefault = true
1111

1212
[allowlist]
1313
description = "Packer debug log path (gitignored; may exist locally after bin/run_example.sh)"
14-
paths = ['''(?:^|/)packer\.log$''']
14+
paths = ['''(?:^|/)packer(?:-[^/]+)?\.log$''']

bin/run_unit_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
set -eu
3838

3939
script_name="$(basename "${0}")"
40-
minimum_coverage="98"
40+
minimum_coverage="100"
4141

4242
step_text="Run Go unit tests with race detector and coverage"
4343
printf "\n%b %b INFO: ==>> STEP: %b:\n" "$(date "+%Y-%m-%d %H:%M:%S")" "${script_name}" "${step_text}"

builder/sylveiso/builder.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ var isoBuildStepsHook func(*Builder) []multistep.Step
4646
// is temporarily unreachable. Overridable in tests.
4747
var sylveLoginRetryInterval = 5 * time.Second
4848

49+
// isoEnsureAuthLoginFn calls Sylve POST /auth/login via c (see Builder.ensureAuth).
50+
// Tests may substitute a custom implementation to exercise the outer retry loop
51+
// without relying on nested HTTP retries within a single Login call.
52+
var isoEnsureAuthLoginFn = func(c *client.Client, username, password, authType string) (string, error) {
53+
return c.Login(username, password, authType)
54+
}
55+
4956
// Builder implements packersdk.Builder for source "sylve-iso".
5057
type Builder struct {
5158
config Config
@@ -88,7 +95,7 @@ func (b *Builder) ensureAuth(ui packersdk.Ui) (cleanup func(), err error) {
8895

8996
for {
9097
c := client.New(b.config.SylveURL, "", b.config.TLSSkipVerify)
91-
token, err := c.Login(b.config.SylveUser, b.config.SylvePassword, b.config.SylveAuthType)
98+
token, err := isoEnsureAuthLoginFn(c, b.config.SylveUser, b.config.SylvePassword, b.config.SylveAuthType)
9299
if err == nil {
93100
b.config.SylveToken = token
94101
ui.Say(fmt.Sprintf("Logged in to Sylve as %q", b.config.SylveUser))
@@ -115,6 +122,15 @@ func (b *Builder) ensureAuth(ui packersdk.Ui) (cleanup func(), err error) {
115122
}
116123
}
117124

125+
// isoBuildStepsForRun returns injected steps during tests when isoBuildStepsHook
126+
// is non-nil; otherwise production defaultISOSteps().
127+
func (b *Builder) isoBuildStepsForRun() []multistep.Step {
128+
if isoBuildStepsHook != nil && testing.Testing() {
129+
return isoBuildStepsHook(b)
130+
}
131+
return b.defaultISOSteps()
132+
}
133+
118134
// Run executes the full ISO build lifecycle: authentication, pre-flight checks,
119135
// ISO download, VM creation, VNC-driven OS installation, SSH provisioning, and
120136
// VM cleanup. It returns the resulting Artifact on success.
@@ -142,12 +158,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook)
142158
state.Put("ui", ui)
143159
state.Put("config", &b.config)
144160

145-
var steps []multistep.Step
146-
if isoBuildStepsHook != nil && testing.Testing() {
147-
steps = isoBuildStepsHook(b)
148-
} else {
149-
steps = b.defaultISOSteps()
150-
}
161+
steps := b.isoBuildStepsForRun()
151162

152163
b.runner = &multistep.BasicRunner{Steps: steps}
153164
b.runner.Run(ctx, state)

builder/sylveiso/builder_auth_test.go

Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package sylveiso
55

66
import (
77
"encoding/json"
8+
"errors"
89
"net/http"
910
"net/http/httptest"
1011
"strings"
@@ -172,22 +173,25 @@ func TestEnsureAuth_LogoutError(t *testing.T) {
172173
}
173174
}
174175

175-
// TestEnsureAuth_SucceedsAfter503Burst verifies the outer login loop: the first
176-
// Login exhausts HTTP retries on 503; the second attempt succeeds. Uses a zero
177-
// sylveLoginRetryInterval so the sleep path uses the sub-millisecond fallback.
176+
// TestEnsureAuth_SucceedsAfter503Burst verifies the outer ensureAuth retry loop
177+
// plus the microsecond sleep shim when retry interval is zero.
178178
func TestEnsureAuth_SucceedsAfter503Burst(t *testing.T) {
179-
orig := sylveLoginRetryInterval
179+
loginTransportErr := errors.New(
180+
`sylve login as "alice": execute request POST /auth/login: dial tcp :0: connect: connection refused`,
181+
)
182+
183+
origLoginFn := isoEnsureAuthLoginFn
184+
origRetry := sylveLoginRetryInterval
180185
sylveLoginRetryInterval = 0
181-
t.Cleanup(func() { sylveLoginRetryInterval = orig })
186+
t.Cleanup(func() {
187+
isoEnsureAuthLoginFn = origLoginFn
188+
sylveLoginRetryInterval = origRetry
189+
})
182190

183-
var n int32
191+
var srvCalls int32
184192
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185193
if r.URL.Path == "/api/auth/login" && r.Method == http.MethodPost {
186-
c := atomic.AddInt32(&n, 1)
187-
if c <= 5 {
188-
http.Error(w, "bad", http.StatusServiceUnavailable)
189-
return
190-
}
194+
atomic.AddInt32(&srvCalls, 1)
191195
w.Header().Set("Content-Type", "application/json")
192196
_ = json.NewEncoder(w).Encode(client.APIResponse[client.LoginResponse]{
193197
Status: "ok",
@@ -199,6 +203,15 @@ func TestEnsureAuth_SucceedsAfter503Burst(t *testing.T) {
199203
}))
200204
defer srv.Close()
201205

206+
var loginAttempts int
207+
isoEnsureAuthLoginFn = func(c *client.Client, u, pw, auth string) (string, error) {
208+
loginAttempts++
209+
if loginAttempts == 1 {
210+
return "", loginTransportErr
211+
}
212+
return origLoginFn(c, u, pw, auth)
213+
}
214+
202215
b := &Builder{config: Config{
203216
SylveURL: srv.URL,
204217
SylveUser: "alice",
@@ -219,25 +232,34 @@ func TestEnsureAuth_SucceedsAfter503Burst(t *testing.T) {
219232
t.Fatalf("token = %q", b.config.SylveToken)
220233
}
221234
cleanup()
222-
if n != 6 {
223-
t.Fatalf("login HTTP requests = %d, want 6", n)
235+
if loginAttempts != 2 {
236+
t.Fatalf("outer Login attempts=%d want 2", loginAttempts)
237+
}
238+
if srvCalls != 1 {
239+
t.Fatalf("HTTP logins=%d want 1", srvCalls)
224240
}
225241
}
226242

227-
// TestEnsureAuth_TimesOutWaitingForAPI covers the deadline branch after a full
228-
// Login fails with retriable errors (inner HTTP retries add a few seconds).
243+
// TestEnsureAuth_TimesOutWaitingForAPI covers the deadline branch when login
244+
// failures are retriable at the ensureAuth layer only.
229245
func TestEnsureAuth_TimesOutWaitingForAPI(t *testing.T) {
230-
orig := sylveLoginRetryInterval
246+
loginTransportErr := errors.New(
247+
`sylve login as "alice": execute request POST /auth/login: dial tcp :0: connect: connection refused`,
248+
)
249+
250+
origLoginFn := isoEnsureAuthLoginFn
251+
origRetry := sylveLoginRetryInterval
231252
sylveLoginRetryInterval = time.Millisecond
232-
t.Cleanup(func() { sylveLoginRetryInterval = orig })
253+
t.Cleanup(func() {
254+
isoEnsureAuthLoginFn = origLoginFn
255+
sylveLoginRetryInterval = origRetry
256+
})
233257

234-
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235-
if r.URL.Path == "/api/auth/login" && r.Method == http.MethodPost {
236-
http.Error(w, "bad", http.StatusServiceUnavailable)
237-
return
238-
}
239-
http.NotFound(w, r)
240-
}))
258+
isoEnsureAuthLoginFn = func(*client.Client, string, string, string) (string, error) {
259+
return "", loginTransportErr
260+
}
261+
262+
srv := httptest.NewServer(http.HandlerFunc(http.NotFound))
241263
defer srv.Close()
242264

243265
b := &Builder{config: Config{
@@ -261,18 +283,22 @@ func TestEnsureAuth_TimesOutWaitingForAPI(t *testing.T) {
261283
// TestEnsureAuth_TruncatesRetrySleepToDeadline exercises ensureAuth when the
262284
// retry interval is larger than the time remaining until the login deadline.
263285
func TestEnsureAuth_TruncatesRetrySleepToDeadline(t *testing.T) {
264-
orig := sylveLoginRetryInterval
286+
loginTransportErr := errors.New(
287+
`sylve login as "alice": execute request POST /auth/login: dial tcp :0: connect: connection refused`,
288+
)
289+
290+
origLoginFn := isoEnsureAuthLoginFn
291+
origRetry := sylveLoginRetryInterval
265292
sylveLoginRetryInterval = time.Minute
266-
t.Cleanup(func() { sylveLoginRetryInterval = orig })
293+
t.Cleanup(func() {
294+
isoEnsureAuthLoginFn = origLoginFn
295+
sylveLoginRetryInterval = origRetry
296+
})
267297

268-
var n int32
298+
var srvCalls int32
269299
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
270300
if r.URL.Path == "/api/auth/login" && r.Method == http.MethodPost {
271-
c := atomic.AddInt32(&n, 1)
272-
if c == 1 {
273-
http.Error(w, "bad", http.StatusServiceUnavailable)
274-
return
275-
}
301+
atomic.AddInt32(&srvCalls, 1)
276302
w.Header().Set("Content-Type", "application/json")
277303
_ = json.NewEncoder(w).Encode(client.APIResponse[client.LoginResponse]{
278304
Status: "ok",
@@ -284,6 +310,15 @@ func TestEnsureAuth_TruncatesRetrySleepToDeadline(t *testing.T) {
284310
}))
285311
defer srv.Close()
286312

313+
var loginAttempts int
314+
isoEnsureAuthLoginFn = func(c *client.Client, u, pw, auth string) (string, error) {
315+
loginAttempts++
316+
if loginAttempts == 1 {
317+
return "", loginTransportErr
318+
}
319+
return origLoginFn(c, u, pw, auth)
320+
}
321+
287322
b := &Builder{config: Config{
288323
SylveURL: srv.URL,
289324
SylveUser: "alice",
@@ -299,10 +334,13 @@ func TestEnsureAuth_TruncatesRetrySleepToDeadline(t *testing.T) {
299334
t.Fatalf("ensureAuth: %v", err)
300335
}
301336
cleanup()
302-
if n != 2 {
303-
t.Fatalf("login attempts = %d, want 2", n)
337+
if loginAttempts != 2 {
338+
t.Fatalf("outer Login attempts=%d want 2", loginAttempts)
339+
}
340+
if srvCalls != 1 {
341+
t.Fatalf("HTTP logins=%d want 1", srvCalls)
304342
}
305-
if time.Since(start) > 3*time.Second {
343+
if time.Since(start) > 4*time.Second {
306344
t.Fatalf("expected truncated sleep (deadline ~1.5s), took %v", time.Since(start))
307345
}
308346
}

builder/sylveiso/builder_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,43 @@ func TestDefaultISOSteps_RestartAfterInstallAddsStep(t *testing.T) {
6666
}
6767
}
6868

69+
// ---------------------------------------------------------------------------
70+
// isoBuildStepsForRun
71+
// ---------------------------------------------------------------------------
72+
73+
func TestIsoBuildStepsForRun_NoHookUsesDefaultSteps(t *testing.T) {
74+
t.Cleanup(func() { isoBuildStepsHook = nil })
75+
b := &Builder{config: Config{}}
76+
nWithout := len(b.isoBuildStepsForRun())
77+
b.config.RestartAfterInstall = true
78+
nWith := len(b.isoBuildStepsForRun())
79+
if nWithout != 10 {
80+
t.Fatalf("isoBuildStepsForRun without restart = %d, want 10", nWithout)
81+
}
82+
if nWith != 11 {
83+
t.Fatalf("isoBuildStepsForRun with restart = %d, want 11", nWith)
84+
}
85+
}
86+
87+
func TestIsoBuildStepsForRun_UsesHookWhenSet(t *testing.T) {
88+
t.Cleanup(func() { isoBuildStepsHook = nil })
89+
var hooked *Builder
90+
isoBuildStepsHook = func(b *Builder) []multistep.Step {
91+
hooked = b
92+
return []multistep.Step{stubArtifactStep{}}
93+
}
94+
95+
b := &Builder{}
96+
steps := b.isoBuildStepsForRun()
97+
98+
if hooked != b {
99+
t.Fatal("hook did not receive builder receiver")
100+
}
101+
if len(steps) != 1 {
102+
t.Fatalf("hook steps=%d want 1", len(steps))
103+
}
104+
}
105+
69106
// ---------------------------------------------------------------------------
70107
// switchNames
71108
// ---------------------------------------------------------------------------

builder/sylveiso/config.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,12 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, []string, error) {
472472
return nil, nil, nil
473473
}
474474

475+
// interfaceAddrsFn and userHomeDirFn are overridable in tests.
476+
var (
477+
interfaceAddrsFn = net.InterfaceAddrs
478+
userHomeDirFn = os.UserHomeDir
479+
)
480+
475481
// sylveHostIsLocal reports whether hostname resolves to an IP address that is
476482
// assigned to a local network interface. When true, Packer is running on the
477483
// same machine as Sylve and can reach the VM subnet directly — no SSH bastion
@@ -483,7 +489,7 @@ func sylveHostIsLocal(hostname string) bool {
483489
}
484490

485491
// Collect all local interface addresses.
486-
addrs, err := net.InterfaceAddrs()
492+
addrs, err := interfaceAddrsFn()
487493
if err != nil {
488494
return false
489495
}
@@ -518,7 +524,7 @@ func sylveHostIsLocal(hostname string) bool {
518524
// The ProxyJump value is returned for diagnostic purposes only — the plugin's
519525
// built-in bastion supports a single hop and cannot honour ProxyJump chains.
520526
func sshConfigForHost(hostname string) (user, identityFile, proxyJump string) {
521-
home, err := os.UserHomeDir()
527+
home, err := userHomeDirFn()
522528
if err != nil {
523529
return "", "", ""
524530
}

0 commit comments

Comments
 (0)