Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a515f7f
feat(netresize): extend interface + chVMInfoConfig.Nets
CMGS May 12, 2026
100a08c
refactor(network): replace Network.Config with Add/Remove
CMGS May 12, 2026
5c65f0c
refactor(network): dedup Delete/Remove via shared tearDown helpers
CMGS May 12, 2026
68a58b8
feat(cloudhypervisor): implement netresize.Resizer
CMGS May 12, 2026
1e23e1a
feat(vm): cocoon vm net --nics N
CMGS May 12, 2026
20578be
fix(clone): verify each NIC removed before re-adding in hotSwapNets
CMGS May 12, 2026
01187b3
docs: cocoon vm net --nics N
CMGS May 12, 2026
8067417
revert(clone): drop post-remove vm.info check in hotSwapNets
CMGS May 12, 2026
bebc11c
remove(netresize): drop --keep-host-on-remove flag
CMGS May 12, 2026
c95d796
refactor(cloudhypervisor): single inspect bootstrap + consolidate pro…
CMGS May 12, 2026
a90a5d5
fix(netresize): rollback CH device + always truncate on partial failure
CMGS May 12, 2026
0333437
fix(network): propagate teardown errors and rollback bridge.Add partials
CMGS May 12, 2026
f5cf1ce
docs(readme): rephrase NIC hot-resize add/remove summary
CMGS May 12, 2026
1dc67a7
docs(netresize): correct leak-reclaim hint
CMGS May 12, 2026
7c97cbf
fix: CNI Add rollback TAP cleanup + skip vm.info on add-only resize
CMGS May 12, 2026
6b1f164
feat(netresize): surface non-fatal warnings in Result
CMGS May 12, 2026
ca0534c
chore: trim verbose comments across vm-net-resize branch
CMGS May 12, 2026
d1cd8c7
fix(cni): always attempt deleteNetns in deleteVM
CMGS May 12, 2026
63649d7
fix(cni): sweep partial records when Remove aborts mid-loop
CMGS May 12, 2026
feb7a43
refactor(cni): drop delNICs, route gc.Collect through tearDownNICs
CMGS May 12, 2026
836f47e
refactor(cloudhypervisor): share addCocoonNIC across resize-up and clone
CMGS May 12, 2026
b5ddeb8
docs: restore 1-line godoc on exported netresize/network items
CMGS May 12, 2026
e882433
perf: pre-size Result.Added/Removed and bridge added slices
CMGS May 12, 2026
609fc4d
docs(netresize): point leak warning at the actual reclaim path
CMGS May 12, 2026
e4ad25b
chore: tighten a few inline/godoc comments
CMGS May 12, 2026
e52dc03
fix(cni): converge Remove on CNI DEL / TAP failure
CMGS May 12, 2026
5ee5837
fix(cni): join Remove teardown and delete-records errors
CMGS May 12, 2026
d5ae027
fix(cni): always sweep DB records in deleteVM regardless of teardown
CMGS May 12, 2026
edb767d
fix: nil-guard NetworkConfig derefs in addCocoonNIC and netResizeRemove
CMGS May 12, 2026
a3d41cc
fix(netresize): wait for guest B0EJ before host plumbing teardown
CMGS May 12, 2026
e642cce
docs: note clone hot-swap can't wait for B0EJ (paused guest)
CMGS May 12, 2026
534a9f6
fix(netresize): symmetric eject-wait in Add persist-fail rollback + 1…
CMGS May 12, 2026
3962951
chore: trim multi-line godocs introduced by this branch to 1 line
CMGS May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ tmp/*

.claude/*
cocoon-test

# Local scratch / test scripts (not for upstream)
/scratch/
17 changes: 17 additions & 0 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,23 @@ Mitigations for production users:

Control-plane traffic from cocoon-managed hosts (vk-cocoon, `cocoon vm exec`) goes through cocoon-agent over vsock and never depends on SSH credentials.

## NIC hot-remove leaves the PCI slot pending on Cloud Hypervisor

`cocoon vm net --nics N` tears down host TAP/veth/CNI plumbing immediately after `vm.remove-device` returns. CH only frees the PCI slot (and unregisters the device's ioeventfds) when the guest writes to the ACPI hot-plug controller (B0EJ) in response to the SCI raised by `remove-device`. If the guest is stopped, paused, or its NIC driver is wedged, the slot stays `Allocated` until the guest cooperates.

Cocoon does not wait for B0EJ — there is no reliable signal from the CH HTTP API. The user accepts:

- The guest may continue to reference a NIC whose host plumbing is gone, hanging the in-guest driver.
- The pending eject may surface later as `Cannot register ioevent: File exists` when CH next tries to reuse that slot (e.g. a subsequent hot-add).

Quiesce the guest NIC (ip link set down + NetworkManager remove on Linux; Disable-PnpDevice + driver unbind on Windows) before reducing the count.

Firecracker is not supported: FC has no NIC hot-plug / hot-unplug API.

## NIC hot-remove during clone hot-swap cannot wait for guest B0EJ

Plain `cocoon vm net --nics N` polls CH's `device_tree` after `vm.remove-device` until the guest ACKs B0EJ via ACPI/SCI (typically < 1 s on Linux, a few seconds on Windows). The clone path's `hotSwapNets` runs while the VM is paused, so the guest cannot process SCI — eject stays pending until resume. The clone path therefore returns without waiting and adds the fresh NICs against half-removed device-tree state. This is the long-standing CH limitation cocoon was designed around; the new wait only applies to the running-VM resize.

## Android cocoon-agent service may be blocked by SELinux

`os-image/android/{14.0,15.0}` install the cocoon-agent binary at `/system/bin/cocoon-agent` and register it via `/system/etc/init/cocoon-agent.rc`. Android's SELinux policies don't ship with a domain for cocoon-agent, so the service may run in `init`'s domain or be denied outright depending on the redroid build.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ cocoon
│ ├── device
│ │ ├── attach [flags] VM Attach a VFIO PCI device (CH only)
│ │ └── detach [flags] VM Detach a VFIO PCI device by --id
│ ├── net [flags] VM Resize NIC count on a running VM (CH only)
│ └── debug [flags] IMAGE Generate hypervisor launch command (dry run)
├── snapshot
│ ├── save [flags] VM Create a snapshot from a running VM
Expand Down Expand Up @@ -520,6 +521,22 @@ cocoon vm device detach my-vm --id mygpu

`cocoon vm inspect VM` includes an `attached_devices` field for running VMs that surfaces every attached vhost-user-fs share and VFIO device, read live from CH `vm.info`. The field is omitted for stopped VMs.

## NIC Hot-Resize (Cloud Hypervisor only)

`cocoon vm net --nics N VM` brings the running VM's NIC count to `N`. To add NICs, cocoon allocates new host TAP/CNI/bridge plumbing and hot-plugs a fresh NIC into the guest. To remove NICs, it pops from the tail (LIFO) via `vm.remove-device` and tears down the host plumbing.

```bash
# Add a second NIC (or any number).
cocoon vm net my-vm --nics 2

# Remove a NIC (LIFO from the tail).
cocoon vm net my-vm --nics 1
```

Cocoon manages **host-side** plumbing only. CH's `vm.remove-device` marks the slot for ejection but the actual eject only happens when the guest cooperates via ACPI (B0EJ write). The host TAP / veth / CNI lease are torn down immediately after the API call regardless. Quiesce in-guest NIC state (driver unbind, NetworkManager removal, Windows NDIS halt) **before** reducing the count, or the in-guest driver will reference plumbing that no longer exists.

A VM started with zero NICs cannot be resized up (the VM record carries no provider hint). Start with at least one NIC if you plan to resize.

## Windows Support

Cocoon supports Windows guests via the `--windows` flag:
Expand Down
23 changes: 11 additions & 12 deletions cmd/vm/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import (
"github.com/spf13/cobra"

cmdcore "github.com/cocoonstack/cocoon/cmd/core"
"github.com/cocoonstack/cocoon/config"
"github.com/cocoonstack/cocoon/extend/fs"
"github.com/cocoonstack/cocoon/extend/vfio"
"github.com/cocoonstack/cocoon/hypervisor"
)

func (h Handler) FsAttach(cmd *cobra.Command, args []string) error {
ctx, a, err := resolveAttacher[fs.Attacher](h, cmd, args, "fs attach", fs.ErrUnsupportedBackend)
ctx, _, _, a, err := resolveAttacher[fs.Attacher](h, cmd, args, "fs attach", fs.ErrUnsupportedBackend)
if err != nil {
return err
}
Expand All @@ -35,7 +36,7 @@ func (h Handler) FsAttach(cmd *cobra.Command, args []string) error {
}

func (h Handler) FsDetach(cmd *cobra.Command, args []string) error {
ctx, a, err := resolveAttacher[fs.Attacher](h, cmd, args, "fs detach", fs.ErrUnsupportedBackend)
ctx, _, _, a, err := resolveAttacher[fs.Attacher](h, cmd, args, "fs detach", fs.ErrUnsupportedBackend)
if err != nil {
return err
}
Expand All @@ -51,7 +52,7 @@ func (h Handler) FsDetach(cmd *cobra.Command, args []string) error {
}

func (h Handler) DeviceAttach(cmd *cobra.Command, args []string) error {
ctx, a, err := resolveAttacher[vfio.Attacher](h, cmd, args, "device attach", vfio.ErrUnsupportedBackend)
ctx, _, _, a, err := resolveAttacher[vfio.Attacher](h, cmd, args, "device attach", vfio.ErrUnsupportedBackend)
if err != nil {
return err
}
Expand All @@ -69,7 +70,7 @@ func (h Handler) DeviceAttach(cmd *cobra.Command, args []string) error {
}

func (h Handler) DeviceDetach(cmd *cobra.Command, args []string) error {
ctx, a, err := resolveAttacher[vfio.Attacher](h, cmd, args, "device detach", vfio.ErrUnsupportedBackend)
ctx, _, _, a, err := resolveAttacher[vfio.Attacher](h, cmd, args, "device detach", vfio.ErrUnsupportedBackend)
if err != nil {
return err
}
Expand All @@ -84,24 +85,22 @@ func (h Handler) DeviceDetach(cmd *cobra.Command, args []string) error {
return nil
}

// resolveAttacher resolves args[0] to a hypervisor implementing A
// (fs.Attacher / vfio.Attacher). op ("fs attach", "device detach") prefixes
// both error wraps so callers see the operation, not just the type-assert.
func resolveAttacher[A any](h Handler, cmd *cobra.Command, args []string, op string, errUnsupported error) (context.Context, A, error) {
// resolveAttacher resolves args[0] to a hypervisor implementing A; also returns conf+hyper for further ops.
func resolveAttacher[A any](h Handler, cmd *cobra.Command, args []string, op string, errUnsupported error) (context.Context, *config.Config, hypervisor.Hypervisor, A, error) {
var zero A
ctx, conf, err := h.Init(cmd)
if err != nil {
return ctx, zero, err
return ctx, nil, nil, zero, err
}
hyper, err := cmdcore.FindHypervisor(ctx, conf, args[0])
if err != nil {
return ctx, zero, fmt.Errorf("%s: %w", op, err)
return ctx, conf, nil, zero, fmt.Errorf("%s: %w", op, err)
}
a, ok := hyper.(A)
if !ok {
return ctx, zero, fmt.Errorf("%s: backend %s: %w", op, hyper.Type(), errUnsupported)
return ctx, conf, hyper, zero, fmt.Errorf("%s: backend %s: %w", op, hyper.Type(), errUnsupported)
}
return ctx, a, nil
return ctx, conf, hyper, a, nil
}

// classifyAttachErr surfaces ErrNotRunning more clearly than the generic wrap.
Expand Down
15 changes: 15 additions & 0 deletions cmd/vm/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Actions interface {
FsDetach(cmd *cobra.Command, args []string) error
DeviceAttach(cmd *cobra.Command, args []string) error
DeviceDetach(cmd *cobra.Command, args []string) error
NetResize(cmd *cobra.Command, args []string) error
}

// Command builds the "vm" parent command with all subcommands.
Expand Down Expand Up @@ -188,10 +189,24 @@ func Command(h Actions) *cobra.Command {
statusCmd,
buildFsCommand(h),
buildDeviceCommand(h),
buildNetCommand(h),
)
return vmCmd
}

func buildNetCommand(h Actions) *cobra.Command {
cmd := &cobra.Command{
Use: "net VM",
Short: "Resize a running VM's NIC count (CH only); quiesce in-guest NIC state before reducing",
Args: cobra.ExactArgs(1),
RunE: h.NetResize,
}
cmd.Flags().Int("nics", -1, "target NIC count (required, >= 0)")
_ = cmd.MarkFlagRequired("nics")
cmdcore.AddOutputFlag(cmd)
Comment thread
CMGS marked this conversation as resolved.
return cmd
}

func buildFsCommand(h Actions) *cobra.Command {
parent := &cobra.Command{
Use: "fs",
Expand Down
12 changes: 6 additions & 6 deletions cmd/vm/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,20 +386,20 @@ func (h Handler) recoverNetwork(ctx context.Context, conf *config.Config, hyper
continue
}
logger.Warnf(ctx, "network missing for VM %s, recovering", vm.ID)
if _, recoverErr := netProvider.Config(ctx, vm.ID, len(vm.NetworkConfigs), &vm.Config, vm.NetworkConfigs...); recoverErr != nil {
if _, recoverErr := netProvider.Add(ctx, vm.ID, &vm.Config, network.AddRecover(vm.NetworkConfigs)...); recoverErr != nil {
logger.Warnf(ctx, "recover network for VM %s: %v (start will fail)", vm.ID, recoverErr)
}
}
}

// providerForVM selects network provider from persisted NetworkConfig.
// providerForVM picks the network provider from persisted NetworkConfig; cniProvider may be nil (lazy-init), bridgeCache must be non-nil.
func providerForVM(conf *config.Config, cniProvider network.Network, bridgeCache map[string]network.Network, configs []*types.NetworkConfig) (network.Network, error) {
if len(configs) == 0 {
return nil, fmt.Errorf("no network configs")
}
// All NICs on a VM share the same backend.
cfg := configs[0]
if cfg.Backend == "bridge" {
if cfg.Backend == types.BackendBridge {
if cfg.BridgeDev == "" {
return nil, fmt.Errorf("bridge backend but no bridge device persisted")
}
Expand All @@ -414,10 +414,10 @@ func providerForVM(conf *config.Config, cniProvider network.Network, bridgeCache
return p, nil
}
// "cni" or empty (backward compat).
if cniProvider == nil {
return nil, fmt.Errorf("cni provider not available")
if cniProvider != nil {
return cniProvider, nil
}
return cniProvider, nil
return cmdcore.InitNetwork(conf)
}

func batchRoutedCmd(ctx context.Context, cmd *cobra.Command, name, pastTense string, routed map[hypervisor.Hypervisor][]string, fn func(hypervisor.Hypervisor, []string) ([]string, error)) error {
Expand Down
52 changes: 52 additions & 0 deletions cmd/vm/netresize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package vm

import (
"fmt"

"github.com/projecteru2/core/log"
"github.com/spf13/cobra"

cmdcore "github.com/cocoonstack/cocoon/cmd/core"
"github.com/cocoonstack/cocoon/config"
"github.com/cocoonstack/cocoon/extend/netresize"
"github.com/cocoonstack/cocoon/network"
"github.com/cocoonstack/cocoon/types"
)

func (h Handler) NetResize(cmd *cobra.Command, args []string) error {
ctx, conf, hyper, resizer, err := resolveAttacher[netresize.Resizer](h, cmd, args, "vm net", netresize.ErrUnsupportedBackend)
if err != nil {
return err
}
vm, err := hyper.Inspect(ctx, args[0])
if err != nil {
return fmt.Errorf("vm net: %w", err)
}
plumbing, err := plumbingForVM(conf, vm.NetworkConfigs)
if err != nil {
return fmt.Errorf("vm net: %w", err)
}
target, _ := cmd.Flags().GetInt("nics")
res, err := resizer.NetResize(ctx, args[0], netresize.Spec{Target: target}, plumbing)
if err != nil {
return classifyAttachErr(err)
}
if done, jsonErr := cmdcore.MaybeOutputJSON(cmd, res); done {
return jsonErr
}
logger := log.WithFunc("cmd.vm.net")
logger.Infof(ctx, "resized %s: before=%d after=%d added=%d removed=%d",
args[0], res.Before, res.After, len(res.Added), len(res.Removed))
for _, w := range res.Warnings {
logger.Warnf(ctx, "%s: %s", args[0], w)
}
return nil
}

// plumbingForVM picks the provider for the VM's existing NICs; fails on zero NICs (VMConfig has no bridge hint).
func plumbingForVM(conf *config.Config, configs []*types.NetworkConfig) (network.Network, error) {
if len(configs) == 0 {
return nil, fmt.Errorf("vm has zero NICs; resize up is not supported (start the VM with at least one NIC)")
}
return providerForVM(conf, nil, map[string]network.Network{}, configs)
}
2 changes: 1 addition & 1 deletion cmd/vm/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ func initNetwork(ctx context.Context, conf *config.Config, vmID string, nics int
// The network layer derives TAP queues from vmCfg.CPU.
origCPU := vmCfg.CPU
vmCfg.CPU = queues
configs, err := netProvider.Config(ctx, vmID, nics, vmCfg)
configs, err := netProvider.Add(ctx, vmID, vmCfg, network.AddRange(0, nics)...)
vmCfg.CPU = origCPU
if err != nil {
return nil, nil, fmt.Errorf("configure network: %w", err)
Expand Down
54 changes: 54 additions & 0 deletions extend/netresize/netresize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package netresize is the runtime interface for resizing a VM's NIC count.
package netresize

import (
"context"
"errors"
"fmt"

"github.com/cocoonstack/cocoon/network"
"github.com/cocoonstack/cocoon/types"
)

// ErrUnsupportedBackend signals the resolved hypervisor cannot resize NICs.
var ErrUnsupportedBackend = errors.New("backend does not support net resize")

// Spec is one resize request.
type Spec struct {
Target int
}

// NIC is one NIC summary surfaced through Result.Added / Result.Removed.
type NIC struct {
Index int `json:"index"`
TAP string `json:"tap"`
MAC string `json:"mac"`
}

// Result reports the before/after count, NICs touched, and non-fatal Warnings.
type Result struct {
Before int `json:"before"`
After int `json:"after"`
Added []NIC `json:"added,omitempty"`
Removed []NIC `json:"removed,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}

// Plumbing is the host-side network ops NetResize delegates to; network.Network satisfies it.
type Plumbing interface {
Add(ctx context.Context, vmID string, vmCfg *types.VMConfig, specs ...network.AddSpec) ([]*types.NetworkConfig, error)
Remove(ctx context.Context, vmID string, indices ...int) error
}

// Resizer resizes a running VM's NIC count.
type Resizer interface {
NetResize(ctx context.Context, vmRef string, spec Spec, plumbing Plumbing) (Result, error)
}

// Normalize validates the spec.
func (s *Spec) Normalize() error {
if s.Target < 0 {
return fmt.Errorf("--nics must be non-negative, got %d", s.Target)
}
return nil
}
6 changes: 5 additions & 1 deletion hypervisor/cloudhypervisor/api.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package cloudhypervisor

import "encoding/json"

type chVMConfig struct {
// Optional — pointer + omitempty (nil → omitted from JSON).
Payload *chPayload `json:"payload,omitempty"`
Expand Down Expand Up @@ -110,7 +112,8 @@ type chPciDeviceInfo struct {
}

type chVMInfoResponse struct {
Config chVMInfoConfig `json:"config"`
Config chVMInfoConfig `json:"config"`
DeviceTree map[string]json.RawMessage `json:"device_tree,omitempty"`
}

type chVMInfoConfig struct {
Expand All @@ -119,4 +122,5 @@ type chVMInfoConfig struct {
Memory chMemory `json:"memory"`
Fs []chFs `json:"fs,omitempty"`
Devices []chDevice `json:"devices,omitempty"`
Nets []chNet `json:"net,omitempty"`
}
7 changes: 7 additions & 0 deletions hypervisor/cloudhypervisor/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const (
// defaultDiskQueueSize is the virtio-blk queue depth per device.
defaultDiskQueueSize = 512
cidataFile = "cidata.img"

cocoonNetIDPrefix = "cocoon-net-"
)

// kvBuilder accumulates key=value CLI fragments.
Expand Down Expand Up @@ -179,6 +181,11 @@ func networkConfigToNet(nc *types.NetworkConfig) chNet {
}
}

// cocoonNetID is the deterministic CH device id for a cocoon-managed NIC.
func cocoonNetID(mac string) string {
return cocoonNetIDPrefix + strings.ReplaceAll(mac, ":", "")
}

func storageConfigToDisk(storageConfig *types.StorageConfig, cpuCount, diskQueueSize int, noDirectIO bool) chDisk {
if diskQueueSize <= 0 {
diskQueueSize = defaultDiskQueueSize
Expand Down
3 changes: 1 addition & 2 deletions hypervisor/cloudhypervisor/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,7 @@ func hotSwapNets(ctx context.Context, hc *http.Client, oldNets []chNet, networkC
logger.Infof(ctx, "removed snapshot NIC %s (old MAC %s)", oldNet.ID, oldNet.MAC)
}
for i, nc := range networkConfigs {
newNet := networkConfigToNet(nc)
if err := addNetVM(ctx, hc, newNet); err != nil {
if _, err := addCocoonNIC(ctx, hc, nc); err != nil {
return fmt.Errorf("add net device %d/%d (MAC %s, TAP %s): %w",
i+1, len(networkConfigs), nc.MAC, nc.TAP, err)
}
Expand Down
Loading
Loading