diff --git a/cmd/vm/datadisk.go b/cmd/vm/datadisk.go index e5fba45..da75cbe 100644 --- a/cmd/vm/datadisk.go +++ b/cmd/vm/datadisk.go @@ -133,7 +133,7 @@ func copyDataDisks(dir string, src []string) ([]string, error) { paths := make([]string, 0, len(src)) for _, srcPath := range src { dst := filepath.Join(dir, filepath.Base(srcPath)) - if err := utils.ReflinkCopy(dst, srcPath); err != nil { + if err := utils.ReflinkCopy(dst, srcPath, utils.Sync); err != nil { return nil, fmt.Errorf("copy data disk %s: %w", filepath.Base(srcPath), err) } paths = append(paths, dst) diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index ed3ee8d..21d9d15 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -64,6 +64,7 @@ func (h *Handler) Start(cmd *cobra.Command, args []string) error { if err := h.launch(cmd, dir, r); err != nil { return err } + unquiesceNet(cmd, r) fmt.Printf("%s (pid %d)\n", n, r.PID) return nil }); err != nil { @@ -85,6 +86,7 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { return err } terminate(ctx, r, grace) + quiesceNet(cmd, r) stopVNCProxy(ctx, dir) r.PID, r.VNCDisp, r.VNCPass = 0, -1, "" // VNC is launch-scoped: gone with the qemu it belonged to return saveRec(dir, r) diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index cf01e3e..298e799 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -12,6 +12,7 @@ import ( "github.com/projecteru2/core/log" "github.com/spf13/cobra" + "github.com/vishvananda/netlink" "github.com/cocoonstack/cocoon/cmd/cliutil" "github.com/cocoonstack/cocoon/config" @@ -83,8 +84,15 @@ func teardownNet(cmd *cobra.Command, r *record) { // warn instead of failing: rm must proceed, but a leaked TAP/netns should leave a trail if provider, err := newProvider(cmd, r); err != nil { logger.Warnf(ctx, "teardown network for %s: %v", r.VMID, err) - } else if _, err := provider.Delete(ctx, []string{r.VMID}); err != nil { - logger.Warnf(ctx, "teardown network for %s: %v", r.VMID, err) + } else { + // quiesce before Delete closes the same idle-TAP softirq window quiesceNet guards, for the gap + // between the VMM dying and Delete dropping the redirect. + if err := provider.Quiesce(ctx, r.VMID); err != nil { + logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) + } + if _, err := provider.Delete(ctx, []string{r.VMID}); err != nil { + logger.Warnf(ctx, "teardown network for %s: %v", r.VMID, err) + } } // CleanupTAPs runs unconditionally: it removes bt-* by name and must not be gated on // newProvider succeeding (rm has no --bridge flag), or an auto-created TAP would leak. @@ -93,6 +101,59 @@ func teardownNet(cmd *cobra.Command, r *record) { } } +// quiesceNet brings a stopped VM's owned host NICs down so a dead VMM's carrier-less TAP can't storm +// host softirqs (tc mirred redirect firing against the down device per broadcast packet) — CNI via +// the provider's veths, tap/bridge via setTapLink. unquiesceNet reverses it on start. +func quiesceNet(cmd *cobra.Command, r *record) { + if !r.TapOwned { + return + } + ctx := cliutil.CommandContext(cmd) + logger := log.WithFunc("cmd.vm.quiesceNet") + if provider, err := newProvider(cmd, r); err != nil { + logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) + } else if err := provider.Quiesce(ctx, r.VMID); err != nil { + logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) + } + setTapLink(ctx, r, false) +} + +func unquiesceNet(cmd *cobra.Command, r *record) { + if !r.TapOwned { + return + } + ctx := cliutil.CommandContext(cmd) + logger := log.WithFunc("cmd.vm.unquiesceNet") + if provider, err := newProvider(cmd, r); err != nil { + logger.Warnf(ctx, "unquiesce network for %s: %v", r.VMID, err) + } else if err := provider.Unquiesce(ctx, r.VMID); err != nil { + logger.Warnf(ctx, "unquiesce network for %s: %v", r.VMID, err) + } + setTapLink(ctx, r, true) +} + +// setTapLink flips a host TAP's admin state (down on stop, up on start). QEMU opens it with script=no +// and cocoon's bridge backend no-ops Quiesce, so cocoon-macos owns the toggle; a CNI TAP lives in a +// netns (r.Netns != "") and is the provider's job, so this only reaches host-netns TAPs. +func setTapLink(ctx context.Context, r *record, up bool) { + if r.Tap == "" || r.Netns != "" { + return + } + logger := log.WithFunc("cmd.vm.setTapLink") + link, err := netlink.LinkByName(r.Tap) + if err != nil { + logger.Warnf(ctx, "find tap %s: %v", r.Tap, err) + return + } + set := netlink.LinkSetUp + if !up { + set = netlink.LinkSetDown + } + if err := set(link); err != nil { + logger.Warnf(ctx, "set tap %s up=%v: %v", r.Tap, up, err) + } +} + // ensureNetnsLoopback brings up lo inside the CNI netns. A freshly-created netns has its loopback // DOWN, so qemu's -vnc 127.0.0.1:N (and any other loopback bind) fails with EADDRNOTAVAIL until lo // is up. No-op outside CNI (no netns). Shells out to `ip` because the qemu launch already runs via diff --git a/cmd/vm/net_other.go b/cmd/vm/net_other.go index 8e053d5..64838ba 100644 --- a/cmd/vm/net_other.go +++ b/cmd/vm/net_other.go @@ -19,6 +19,10 @@ func provisionNet(_ *cobra.Command, _ *record) (tap, netns, mac string, err erro func teardownNet(_ *cobra.Command, _ *record) {} +func quiesceNet(_ *cobra.Command, _ *record) {} + +func unquiesceNet(_ *cobra.Command, _ *record) {} + // launchCmd builds the qemu exec; qemu-system-x86_64 is the authoritative VMM with no Go-native // equivalent (and off Linux there is no netns to enter). func launchCmd(_ *record, args []string) *exec.Cmd { diff --git a/cmd/vm/utils.go b/cmd/vm/utils.go index 5cdee8e..cba8de9 100644 --- a/cmd/vm/utils.go +++ b/cmd/vm/utils.go @@ -78,7 +78,7 @@ func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, return "", "", "", "", err } ovmfVars = filepath.Join(dir, varsName) - if err = utils.ReflinkCopy(ovmfVars, varsSrc); err != nil { + if err = utils.ReflinkCopy(ovmfVars, varsSrc, utils.Sync); err != nil { return "", "", "", "", fmt.Errorf("copy OVMF_VARS: %w", err) } return dir, overlay, ovmfVars, digest, nil diff --git a/cmd/vm/utils_test.go b/cmd/vm/utils_test.go new file mode 100644 index 0000000..1e3e6e4 --- /dev/null +++ b/cmd/vm/utils_test.go @@ -0,0 +1,35 @@ +package vm + +import ( + "testing" + "time" + + "github.com/spf13/cobra" +) + +// TestGraceFromFlags pins the shared stop/rm force mapping: --force is an immediate SIGKILL +// (grace 0), everything else waits the ACPI grace window. +func TestGraceFromFlags(t *testing.T) { + tests := []struct { + name string + force bool + want time.Duration + }{ + {name: "force is immediate", force: true, want: 0}, + {name: "default waits the grace window", force: false, want: stopGracePeriod}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("force", false, "") + if tt.force { + if err := cmd.Flags().Set("force", "true"); err != nil { + t.Fatalf("set force: %v", err) + } + } + if got := graceFromFlags(cmd); got != tt.want { + t.Errorf("graceFromFlags(force=%v): got %v, want %v", tt.force, got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 87601fe..cefbd12 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/cocoonstack/cocoon-macos go 1.26.4 require ( - github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105 + github.com/cocoonstack/cocoon v0.5.2-0.20260713182614-0f6c21b5b6f6 github.com/docker/go-units v0.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c github.com/spf13/cobra v1.10.2 + github.com/vishvananda/netlink v1.3.1 golang.org/x/sync v0.21.0 howett.net/plist v1.0.1 oras.land/oras-go/v2 v2.6.1 @@ -36,7 +37,6 @@ require ( github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.50.0 // indirect diff --git a/go.sum b/go.sum index 9557d2d..d6cfda4 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105 h1:kY2QEEK+RiJflgTGRfvRZJvN4X87d36dkrneyOZiAMI= -github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105/go.mod h1:Rl/SAzj1RbyL8XJaIyWiHdT96yK2D1e0xCr8flVqIcA= +github.com/cocoonstack/cocoon v0.5.2-0.20260713182614-0f6c21b5b6f6 h1:OKpbRm7j/WoON1iB26jiIshYwzLibu+5f/p49Um0rzc= +github.com/cocoonstack/cocoon v0.5.2-0.20260713182614-0f6c21b5b6f6/go.mod h1:Rl/SAzj1RbyL8XJaIyWiHdT96yK2D1e0xCr8flVqIcA= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ=