Skip to content

Commit 989d7a9

Browse files
committed
fix: harden remote Parallels snapshot checks
1 parent c75e95a commit 989d7a9

4 files changed

Lines changed: 40 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Fixed
1010

1111
- Fixed portal and documentation theme toggles so dark mode shows only the sun icon and light mode shows only the moon icon.
12+
- Fixed remote Parallels hosts so `prlctl` is found on standard Mac install paths, and made snapshot fork dry-runs reject non-forkable power-on snapshots consistently.
1213

1314
### Changed
1415

internal/cli/checkpoint.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,11 @@ func (a App) checkpointListParallelsSnapshots(ctx context.Context, cfg Config, i
361361
return json.NewEncoder(a.Stdout).Encode(views)
362362
}
363363
if len(views) == 0 {
364-
fmt.Fprintf(a.Stdout, "no snapshots source=%s\n", vm.ID)
364+
kind := "snapshots"
365+
if opts.ForkableOnly {
366+
kind = "forkable snapshots"
367+
}
368+
fmt.Fprintf(a.Stdout, "no %s source=%s\n", kind, vm.ID)
365369
return nil
366370
}
367371
for _, view := range views {
@@ -831,6 +835,9 @@ func (a App) checkpointForkParallelsSnapshot(ctx context.Context, fs *flag.FlagS
831835
if err != nil {
832836
return err
833837
}
838+
if err := validateParallelsSnapshotCloneMode(snapshot, cfg.Parallels.CloneMode); err != nil {
839+
return err
840+
}
834841
fmt.Fprintf(a.Stdout, "would fork provider=parallels host=%s source=%s snapshot=%s name=%q slug=%s\n", blank(selected.Parallels.SelectedHost, "local"), cfg.Parallels.Source, snapshot.ID, snapshot.Name, blank(requestedSlug, "-"))
835842
return nil
836843
}

internal/cli/parallels.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ func (c *ParallelsClient) Clone(ctx context.Context, source, snapshotID, leaseID
199199
if err != nil {
200200
return Server{}, err
201201
}
202-
if ok && !strings.EqualFold(snapshot.State, "poweroff") {
203-
return Server{}, exit(2, "Parallels linked clones require a power-off snapshot; snapshot %q state=%s", snapshot.Name, blank(snapshot.State, "unknown"))
202+
if ok {
203+
if err := validateParallelsSnapshotCloneMode(snapshot, c.Cfg.Parallels.CloneMode); err != nil {
204+
return Server{}, err
205+
}
204206
}
205207
}
206208
args = append(args, "--linked")
@@ -598,7 +600,7 @@ touch /var/lib/crabbox/bootstrapped 2>/dev/null || true
598600

599601
func (c *ParallelsClient) prlctl(ctx context.Context, extraEnv []string, args ...string) (LocalCommandResult, error) {
600602
if c.Cfg.Parallels.Host != "" {
601-
remote := append([]string{"prlctl"}, args...)
603+
remote := "PATH=/usr/local/bin:/opt/homebrew/bin:$PATH " + strings.Join(shellWords(append([]string{"prlctl"}, args...)), " ")
602604
sshArgs := []string{}
603605
if c.Cfg.Parallels.HostKey != "" {
604606
sshArgs = append(sshArgs, "-i", c.Cfg.Parallels.HostKey, "-o", "IdentitiesOnly=yes")
@@ -607,12 +609,26 @@ func (c *ParallelsClient) prlctl(ctx context.Context, extraEnv []string, args ..
607609
if c.Cfg.Parallels.HostUser != "" {
608610
host = c.Cfg.Parallels.HostUser + "@" + host
609611
}
610-
sshArgs = append(sshArgs, host, strings.Join(shellWords(remote), " "))
612+
sshArgs = append(sshArgs, host, remote)
611613
return c.Runner.Run(ctx, LocalCommandRequest{Name: "ssh", Args: sshArgs, Env: extraEnv})
612614
}
613615
return c.Runner.Run(ctx, LocalCommandRequest{Name: "prlctl", Args: args, Env: extraEnv})
614616
}
615617

618+
func validateParallelsSnapshotCloneMode(snapshot ParallelsSnapshot, cloneMode string) error {
619+
switch strings.ToLower(strings.TrimSpace(cloneMode)) {
620+
case "", "linked":
621+
if !strings.EqualFold(snapshot.State, "poweroff") {
622+
return exit(2, "Parallels linked clones require a power-off snapshot; snapshot %q state=%s", snapshot.Name, blank(snapshot.State, "unknown"))
623+
}
624+
return nil
625+
case "full", "unlink":
626+
return exit(2, "Parallels snapshot forks require cloneMode=linked; prlctl selects snapshots only for linked clones")
627+
default:
628+
return exit(2, "parallels.cloneMode must be linked, full, or unlink")
629+
}
630+
}
631+
616632
func parseParallelsVMs(data string) ([]ParallelsVM, error) {
617633
var raw []map[string]any
618634
if err := json.Unmarshal([]byte(data), &raw); err != nil {

internal/cli/parallels_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ func TestParallelsRemoteCommandUsesSSHHostThenCommand(t *testing.T) {
122122
if strings.HasPrefix(runner.lastReq.Args[1], "-- ") {
123123
t.Fatalf("remote command should not start with --: %#v", runner.lastReq.Args)
124124
}
125+
if !strings.HasPrefix(runner.lastReq.Args[1], "PATH=/usr/local/bin:/opt/homebrew/bin:$PATH ") {
126+
t.Fatalf("remote command should add Mac binary dirs to PATH: %q", runner.lastReq.Args[1])
127+
}
125128
if !strings.Contains(runner.lastReq.Args[1], "prlctl") || !strings.Contains(runner.lastReq.Args[1], "--version") {
126129
t.Fatalf("remote command=%q", runner.lastReq.Args[1])
127130
}
@@ -161,6 +164,14 @@ func TestParallelsLinkedCloneRequiresExplicitSnapshot(t *testing.T) {
161164
}
162165
}
163166

167+
func TestValidateParallelsSnapshotCloneModeRejectsPowerOnDryRun(t *testing.T) {
168+
snapshot := ParallelsSnapshot{Name: "macOS 26.3.1 LATEST", State: "poweron"}
169+
err := validateParallelsSnapshotCloneMode(snapshot, "linked")
170+
if err == nil || !strings.Contains(err.Error(), "power-off snapshot") {
171+
t.Fatalf("err=%v", err)
172+
}
173+
}
174+
164175
func TestApplyParallelsTemplateConfig(t *testing.T) {
165176
cfg := baseConfig()
166177
cfg.Provider = "parallels"

0 commit comments

Comments
 (0)