Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 1 deletion cmd/core/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/cocoonstack/cocoon/gc"
"github.com/cocoonstack/cocoon/hypervisor"
"github.com/cocoonstack/cocoon/lock/vmlock"
"github.com/cocoonstack/cocoon/network"
"github.com/cocoonstack/cocoon/network/bridge"
"github.com/cocoonstack/cocoon/snapshot/localfile"
)
Expand Down Expand Up @@ -39,7 +40,7 @@ func NewGCOrchestrator(ctx context.Context, conf *config.Config, snapOpts ...loc
}
gc.Register(o, hypervisor.CgroupGCModule(conf.CgroupParentDir()))
netProvider.RegisterGC(o)
gc.Register(o, bridge.GCModule())
gc.Register(o, bridge.GCModule(network.BridgeTAPPrefix(conf.NetScope)))
gc.Register(o, vmlock.GCModule(conf.RootDir))
snapBackend.RegisterGC(o)
return o, nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/core/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (n *NetProviders) Quiesce(ctx context.Context, vm *types.VM) error {

// A partial CNI failure leaves the tombstone for retry or GC to resume.
func (n *NetProviders) Cleanup(ctx context.Context, vmID string) error {
bridgenet.CleanupTAPs([]string{vmID})
bridgenet.CleanupTAPs(network.BridgeTAPPrefix(n.conf.NetScope), []string{vmID})
p, err := n.cniOnly()
if err != nil {
// Lazy CNI; OK to skip for bridge-only setups.
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func newRootCmd() *cobra.Command {
viper.SetDefault("meta_backend", "")
viper.SetDefault("cgroup_parent", cgroup.DefaultParent)
viper.SetDefault("cgroup_cpus", "")
viper.SetDefault("net_scope", "")
viper.SetDefault("log.level", "info")
viper.SetDefault("log.max_size", 500)
viper.SetDefault("log.max_age", 28)
Expand Down
6 changes: 6 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
coretypes "github.com/projecteru2/core/types"

"github.com/cocoonstack/cocoon/cgroup"
"github.com/cocoonstack/cocoon/network"
"github.com/cocoonstack/cocoon/utils"
)

Expand Down Expand Up @@ -58,6 +59,8 @@ type Config struct {
CNIBinDir string `json:"cni_bin_dir" mapstructure:"cni_bin_dir"`
// DNS: comma/semicolon-separated DNS servers injected into VM net config. Env: COCOON_DNS. Default: "8.8.8.8,1.1.1.1".
DNS string `json:"dns" mapstructure:"dns"`
// NetScope keys this installation's host network families (bridge TAPs <scope><vmid8>-<nic>, CNI netns <scope>-<vmid>) so co-hosted installations never GC each other's; two alphanumerics, empty keeps the legacy bt / cocoon- names.
NetScope string `json:"net_scope,omitempty" mapstructure:"net_scope"`
// SocketWaitTimeoutSeconds: wait for the CH API socket after start. Default: 5; increase for slow storage.
SocketWaitTimeoutSeconds int `json:"socket_wait_timeout_seconds" mapstructure:"socket_wait_timeout_seconds"`
// TerminateGracePeriodSeconds: SIGTERM→SIGKILL window when force-killing CH. Default: 5.
Expand Down Expand Up @@ -124,6 +127,9 @@ func (c *Config) Validate() error {
if _, err := cgroup.ParseCPUList(c.CgroupCPUs); err != nil {
return fmt.Errorf("cgroup_cpus: %w", err)
}
if err := network.ValidateScope(c.NetScope); err != nil {
return fmt.Errorf("net_scope: %w", err)
}
return nil
}

Expand Down
21 changes: 21 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ func TestDNSServers(t *testing.T) {
}
}

func TestValidate_NetScope(t *testing.T) {
for _, tt := range []struct {
scope string
ok bool
}{
{"", true}, {"mt", true}, {"m", false}, {"mtap", false}, {"m-", false}, {"bt", false}, {"rm", false},
} {
c := &Config{
RootDir: "/r", RunDir: "/r/run", LogDir: "/l",
StopTimeoutSeconds: 30, NetScope: tt.scope,
}
err := c.Validate()
if tt.ok && err != nil {
t.Fatalf("scope %q: unexpected error %v", tt.scope, err)
}
if !tt.ok && err == nil {
t.Fatalf("scope %q: want rejection", tt.scope)
}
}
}

func TestValidate_MetaBackend(t *testing.T) {
for _, tt := range []struct {
backend string
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Config-file / env-only keys (no CLI flag):
| `pull_conns` | `COCOON_PULL_CONNS` | `8` | Concurrent HTTP Range connections per cloud-image download (`image pull`); raise for fat pipes, lower to be gentle on the registry |
| `cgroup_parent` | `COCOON_CGROUP_PARENT` | `cocoon.slice` | cgroup v2 slice under `/sys/fs/cgroup` holding the per-VM CPU scopes; see [CPU Isolation](vm.md#cpu-isolation-cgroup-v2) |
| `cgroup_cpus` | `COCOON_CGROUP_CPUS` | empty (all cores) | Host cpu list fencing the whole VM population (e.g. `0-14` reserves core 15 for the host); kernel cpu-list syntax |
| `net_scope` | `COCOON_NET_SCOPE` | empty (legacy names) | Two alphanumerics keying this installation's host network families — bridge TAPs `<scope><vmid8>-<nic>`, CNI netns `<scope>-<vmid>` — so co-hosted installations never GC each other's; see [Host device namespaces](networking.md#host-device-namespaces) |
| `ch_binary` | `COCOON_CH_BINARY` | `cloud-hypervisor` | cloud-hypervisor executable, path or `$PATH` name |
| `fc_binary` | `COCOON_FC_BINARY` | `firecracker` | firecracker executable, path or `$PATH` name |
| `meta_backend` | `COCOON_META_BACKEND` | auto-resolved | Metadata engine, `json` or `sqlite`; see the note above `Global Flags` |
Expand Down
4 changes: 2 additions & 2 deletions docs/gc.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Reasons:
- **snapshot**: `orphan` (dataDir without DB record), `stale-pending` (a dead save's pending record — its build lease is free), `lru-all` / `lru-age` / `lru-keep` / `lru-size` (multi-criterion uses `+` joiner)
- **cloud-hypervisor / firecracker**: `orphan-runDir`, `orphan-logDir`, `stale-creating` (a dead create/clone's placeholder — its ops lock is free, no age wait)
- **images (oci, cloudimg)**: `unreferenced`
- **cni**: `orphan` (netns without active VM)
- **bridge**: `orphan-tap`
- **cni**: `orphan` (netns in this installation's `net_scope` family without active VM)
- **bridge**: `orphan-tap` (TAP in this installation's `net_scope` family without active VM)
- **vmlock**: `orphan-lease` (lease file for a VM no backend knows)

### Snapshot LRU Eviction
Expand Down
4 changes: 4 additions & 0 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Guest virtio-net ←→ TAP (multi-queue) ←TC redirect→ veth ←→ CN
- **Bridge mode**: `--bridge <device>` creates TAP devices directly on an existing Linux bridge (e.g., `--bridge cni0`), bypassing CNI and TC redirect. VMs get IP via DHCP from the bridge. Mutually exclusive with `--network`
- **DNS**: Use `--dns` to set custom DNS servers (comma separated)

### Host Device Namespaces

Cocoon owns two host-wide name families that [GC](gc.md) sweeps by name: bridge-mode TAPs `bt<vmid8>-<nic>` in the host netns and per-VM CNI netns `cocoon-<vmid>` under `/var/run/netns` (the `rm<vmid8>-<nic>` TAPs a clone restore hands to CH are transient — CH destroys them itself and no sweep touches them). GC reclaims every entry in its families whose VM it does not know, so two installations sharing a host — a second `root_dir`, or a cocoon-derived runner such as cocoon-macos that provisions through cocoon's bridge/CNI backends — must live in different families, or each sweep tears down the other's live guests. `net_scope` re-keys an installation: two alphanumerics (`mt` gives `mt<vmid8>-<nic>` and `mt-<vmid>`); the fixed length keeps distinct scopes from being prefixes of each other, and `bt` / `rm` are rejected because they alias the legacy and restore families. Set it before the first VM is created — existing devices keep their old names.

### CNI Configuration

All `.conflist` files in `--cni-conf-dir` (default `/etc/cni/net.d`) are loaded at startup. Use `--network <name>` to select one by its `name` field; omitting defaults to the first file alphabetically. A typical bridge config:
Expand Down
4 changes: 1 addition & 3 deletions hypervisor/cloudhypervisor/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import (
"github.com/cocoonstack/cocoon/utils"
)

const restoreTAPPrefix = "rm"

type cloneResumeOpts struct {
vmID string
vmCfg *types.VMConfig
Expand Down Expand Up @@ -96,7 +94,7 @@ func (ch *CloudHypervisor) cloneAfterExtractParsed(ctx context.Context, vmID str
// vm.restore attaches the taps named in the snapshot, so concurrent clones of one golden race to attach the source's tap (EBUSY); it cannot attach the clone's real taps either — vm.remove-device releases a tap only after the guest ACKs the eject, so hotSwapNets' add-net would EBUSY on its own tap. Give every restored NIC a clone-unique throwaway tap (auto-created by CH, auto-destroyed once the removed device drops it).
netTAPs := make([]string, len(chCfg.Nets))
for i := range netTAPs {
netTAPs[i] = network.TAPName(restoreTAPPrefix, vmID, i)
netTAPs[i] = network.TAPName(network.RestoreTAPPrefix, vmID, i)
}

consoleSock := hypervisor.ConsoleSockPath(runDir)
Expand Down
18 changes: 9 additions & 9 deletions meta/sqlite/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,6 @@ func OpenForRecovery(dbPath string, namespaces ...Namespace) (*Store, error) {
return openStore(dbPath, namespaces)
}

// RefuseManifest fails when a conversion manifest sits beside dbPath, meaning an offline conversion is unfinished.
func RefuseManifest(dbPath string) error {
manifest := filepath.Join(filepath.Dir(dbPath), ManifestName)
if utils.FileExists(manifest) {
return fmt.Errorf("%s exists: a conversion is in flight, run `cocoon meta convert` to finish it", manifest)
}
return nil
}

func openStore(dbPath string, namespaces []Namespace) (*Store, error) {
// The driver creates a file on first touch; Open never creates — that is Init's job (§6) — and §4 refuses network filesystems before WAL work.
if !utils.FileExists(dbPath) {
Expand Down Expand Up @@ -381,6 +372,15 @@ func (h *txHandle) checkRead(ns string) error {
return nil
}

// RefuseManifest fails when a conversion manifest sits beside dbPath, meaning an offline conversion is unfinished.
func RefuseManifest(dbPath string) error {
manifest := filepath.Join(filepath.Dir(dbPath), ManifestName)
if utils.FileExists(manifest) {
return fmt.Errorf("%s exists: a conversion is in flight, run `cocoon meta convert` to finish it", manifest)
}
return nil
}

func tableName(ns, table string) string {
return quoteIdent(ns + "__" + table)
}
Expand Down
35 changes: 16 additions & 19 deletions network/bridge/bridge_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ var _ network.Network = (*Bridge)(nil)

// Bridge is TAP-on-bridge; requires a pre-existing bridge with DHCP + routing.
type Bridge struct {
conf *config.Config
tapPrefix string
bridgeDev string
bridgeIdx int
}
Expand All @@ -46,7 +46,7 @@ func New(conf *config.Config, bridgeDev string) (*Bridge, error) {
return nil, fmt.Errorf("%s is not a bridge (type: %s)", bridgeDev, br.Type())
}
return &Bridge{
conf: conf,
tapPrefix: network.BridgeTAPPrefix(conf.NetScope),
bridgeDev: bridgeDev,
bridgeIdx: br.Attrs().Index,
}, nil
Expand All @@ -57,8 +57,9 @@ func (b *Bridge) Type() string { return typ }
func (b *Bridge) Verify(_ context.Context, vmID string, expected []*types.NetworkConfig) error {
// Legacy records persisted no NetworkConfigs, so empty means "assume tap0" — callers that legitimately resized to zero NICs must not call Verify.
if len(expected) == 0 {
if _, err := netlink.LinkByName(tapName(vmID, 0)); err != nil {
return fmt.Errorf("tap %s: %w", tapName(vmID, 0), err)
name := network.TAPName(b.tapPrefix, vmID, 0)
if _, err := netlink.LinkByName(name); err != nil {
return fmt.Errorf("tap %s: %w", name, err)
}
return nil
}
Expand Down Expand Up @@ -94,12 +95,12 @@ func (b *Bridge) Add(ctx context.Context, vmID string, vmCfg *types.VMConfig, sp
if retErr == nil || len(added) == 0 {
return
}
_ = tearDownTAPs(vmID, added, true)
_ = tearDownTAPs(b.tapPrefix, vmID, added, true)
}()

configs = make([]*types.NetworkConfig, 0, len(specs))
for _, spec := range specs {
name := tapName(vmID, spec.Index)
name := network.TAPName(b.tapPrefix, vmID, spec.Index)
mac := generateMAC()
if spec.Existing != nil {
mac = spec.Existing.MAC
Expand Down Expand Up @@ -137,15 +138,15 @@ func (b *Bridge) Add(ctx context.Context, vmID string, vmCfg *types.VMConfig, sp
}

func (b *Bridge) Remove(_ context.Context, vmID string, indices ...int) error {
return tearDownTAPs(vmID, indices, false)
return tearDownTAPs(b.tapPrefix, vmID, indices, false)
}

// Quiesce and Unquiesce are no-ops: bridge TAPs sit directly on the host bridge, with no TC redirect to storm when the VM stops.
func (b *Bridge) Quiesce(_ context.Context, _ string) error { return nil }
func (b *Bridge) Unquiesce(_ context.Context, _ string) error { return nil }

func (b *Bridge) Delete(_ context.Context, vmIDs []string) ([]string, error) {
return CleanupTAPs(vmIDs), nil
return CleanupTAPs(b.tapPrefix, vmIDs), nil
}

// Inspect: bridge has no persistent records.
Expand All @@ -158,23 +159,23 @@ func (b *Bridge) List(_ context.Context) ([]*types.Network, error) {
return nil, nil
}

// RegisterGC reclaims orphan bt* TAP devices.
// RegisterGC reclaims orphan bridge TAP devices.
func (b *Bridge) RegisterGC(orch *gc.Orchestrator) {
gc.Register(orch, GCModule())
gc.Register(orch, GCModule(b.tapPrefix))
}

// CleanupTAPs removes bridge TAP devices per VM ID; safe without a Bridge instance.
func CleanupTAPs(vmIDs []string) []string {
func CleanupTAPs(tapPrefix string, vmIDs []string) []string {
cleaned := make([]string, 0, len(vmIDs))
for _, vmID := range vmIDs {
var indices []int
for i := 0; ; i++ {
if _, err := netlink.LinkByName(tapName(vmID, i)); err != nil {
if _, err := netlink.LinkByName(network.TAPName(tapPrefix, vmID, i)); err != nil {
break
}
indices = append(indices, i)
}
_ = tearDownTAPs(vmID, indices, true)
_ = tearDownTAPs(tapPrefix, vmID, indices, true)
cleaned = append(cleaned, vmID)
}
return cleaned
Expand All @@ -200,9 +201,9 @@ func attachBridgeUp(tapIndex, bridgeIndex, mtu int) error {
return nil
}

func tearDownTAPs(vmID string, indices []int, bestEffort bool) error {
func tearDownTAPs(tapPrefix, vmID string, indices []int, bestEffort bool) error {
for _, i := range indices {
name := tapName(vmID, i)
name := network.TAPName(tapPrefix, vmID, i)
link, err := netlink.LinkByName(name)
if err != nil {
if bestEffort {
Expand All @@ -220,10 +221,6 @@ func tearDownTAPs(vmID string, indices []int, bestEffort bool) error {
return nil
}

func tapName(vmID string, nic int) string {
return network.TAPName(tapPrefix, vmID, nic)
}

func generateMAC() string {
buf := make([]byte, 6) //nolint:mnd
_, _ = rand.Read(buf)
Expand Down
2 changes: 1 addition & 1 deletion network/bridge/bridge_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ func (b *Bridge) Inspect(_ context.Context, _ string) (*types.Network, error) {

func (b *Bridge) List(_ context.Context) ([]*types.Network, error) { return nil, errUnsupported }

func CleanupTAPs(_ []string) []string { return nil }
func CleanupTAPs(_ string, _ []string) []string { return nil }
14 changes: 6 additions & 8 deletions network/bridge/gc_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ import (
"github.com/cocoonstack/cocoon/utils"
)

const tapPrefix = "bt"

type bridgeSnapshot struct {
prefixes map[string]struct{}
}

// GCModule returns a GC module reclaiming orphan bt* TAP devices; it needs no Bridge instance.
func GCModule() gc.Module[bridgeSnapshot] {
// GCModule returns a GC module reclaiming orphan TAP devices under tapPrefix; it needs no Bridge instance.
func GCModule(tapPrefix string) gc.Module[bridgeSnapshot] {
return gc.Module[bridgeSnapshot]{
Name: typ,
ReadDB: func(_ context.Context) (bridgeSnapshot, error) {
Expand All @@ -34,7 +32,7 @@ func GCModule() gc.Module[bridgeSnapshot] {
return snap, err
}
for _, l := range links {
if prefix, ok := parseTAPName(l.Attrs().Name); ok {
if prefix, ok := parseTAPName(tapPrefix, l.Attrs().Name); ok {
snap.prefixes[prefix] = struct{}{}
}
}
Expand Down Expand Up @@ -64,7 +62,7 @@ func GCModule() gc.Module[bridgeSnapshot] {
}
for _, l := range links {
name := l.Attrs().Name
prefix, ok := parseTAPName(name)
prefix, ok := parseTAPName(tapPrefix, name)
if !ok {
continue
}
Expand All @@ -82,8 +80,8 @@ func GCModule() gc.Module[bridgeSnapshot] {
}
}

// parseTAPName extracts the vmID prefix from a bridge TAP name like "bt<prefix>-<nic>".
func parseTAPName(name string) (string, bool) {
// parseTAPName extracts the vmID prefix from a bridge TAP name "<tapPrefix><vmid8>-<nic>".
func parseTAPName(tapPrefix, name string) (string, bool) {
rest, ok := strings.CutPrefix(name, tapPrefix)
if !ok {
return "", false
Expand Down
32 changes: 16 additions & 16 deletions network/bridge/gc_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,33 @@ import "testing"

func TestParseTAPName(t *testing.T) {
tests := []struct {
tapPrefix string
name string
wantPrefix string
wantOK bool
}{
{name: "bt12345678-0", wantPrefix: "12345678", wantOK: true},
{name: "bt12345678-1", wantPrefix: "12345678", wantOK: true},
{name: "btabc-3", wantPrefix: "abc", wantOK: true},
{name: "btabc-def-5", wantPrefix: "abc-def", wantOK: true},
{tapPrefix: "bt", name: "bt12345678-0", wantPrefix: "12345678", wantOK: true},
{tapPrefix: "bt", name: "bt12345678-1", wantPrefix: "12345678", wantOK: true},
{tapPrefix: "bt", name: "btabc-3", wantPrefix: "abc", wantOK: true},
{tapPrefix: "bt", name: "btabc-def-5", wantPrefix: "abc-def", wantOK: true},
{tapPrefix: "mt", name: "mt12345678-0", wantPrefix: "12345678", wantOK: true},

{name: "wrong-prefix-0"},
{name: "bt"},
{name: "bt-0"}, // empty prefix
{name: "bt12345678"},
{name: ""},
{tapPrefix: "bt", name: "wrong-prefix-0"},
{tapPrefix: "bt", name: "bt"},
{tapPrefix: "bt", name: "bt-0"}, // empty prefix
{tapPrefix: "bt", name: "bt12345678"},
{tapPrefix: "bt", name: ""},
{tapPrefix: "mt", name: "bt12345678-0"}, // another installation's TAP
}
for _, tt := range tests {
label := tt.name
if label == "" {
label = "<empty>"
}
label := tt.tapPrefix + "/" + tt.name
t.Run(label, func(t *testing.T) {
gotPrefix, gotOK := parseTAPName(tt.name)
gotPrefix, gotOK := parseTAPName(tt.tapPrefix, tt.name)
if gotOK != tt.wantOK {
t.Errorf("parseTAPName(%q) ok = %v, want %v", tt.name, gotOK, tt.wantOK)
t.Errorf("parseTAPName(%q, %q) ok = %v, want %v", tt.tapPrefix, tt.name, gotOK, tt.wantOK)
}
if gotPrefix != tt.wantPrefix {
t.Errorf("parseTAPName(%q) prefix = %q, want %q", tt.name, gotPrefix, tt.wantPrefix)
t.Errorf("parseTAPName(%q, %q) prefix = %q, want %q", tt.tapPrefix, tt.name, gotPrefix, tt.wantPrefix)
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion network/bridge/gc_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
type bridgeSnapshot struct{}

// GCModule returns a no-op GC module on non-Linux — bridge TAPs don't exist.
func GCModule() gc.Module[bridgeSnapshot] {
func GCModule(_ string) gc.Module[bridgeSnapshot] {
return gc.Module[bridgeSnapshot]{
Name: "bridge",
ReadDB: func(_ context.Context) (bridgeSnapshot, error) {
Expand Down
Loading