Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fdb1a5f
review: path.Base, strings.CutLast and time.RFC3339 for three renderings
CMGS Sep 20, 2026
98c1882
test: drop the shortfall test that never reached provision.go
CMGS Sep 20, 2026
bd82bc1
fix: delete an orphan ENI on a detached context and stop the create l…
CMGS Sep 20, 2026
a075c30
fix: teardown keeps the detach errors a cut propagation wait would drop
CMGS Sep 20, 2026
bdfbc69
fix: bound the wait on a canceled subprocess's pipe
CMGS Sep 20, 2026
43813dc
fix: order a RELEASE against the REQUEST it raced by receive time
CMGS Sep 20, 2026
68a70da
fix: name the lease-file outcome the daemon starts from
CMGS Sep 20, 2026
efe4a1e
fix: reject a --dns entry that is not an IPv4 address
CMGS Sep 20, 2026
616e237
docs: --dns takes IPv4 entries, lease-file startup outcomes, an overt…
CMGS Sep 20, 2026
990c3de
cut: one nic0 alias update for assign and teardown
CMGS Sep 21, 2026
8daca2d
fix: an ENI attached before a cut propagation wait stays in the resul…
CMGS Sep 21, 2026
16964f5
fix: a failed init merges the attached ENIs into the existing pool state
CMGS Sep 21, 2026
50ed1d3
fix: the daemon re-applies the GKE guest-agent route fix on every start
CMGS Sep 21, 2026
99794e4
fix: teardown refuses while the daemon holds the pidfile
CMGS Sep 21, 2026
e12661f
fix: the daemon keeps serving when a secondary NIC is missing
CMGS Sep 21, 2026
3a5a9de
review: drop two err shadows and clean the pidfile path
CMGS Sep 21, 2026
bf5eddd
test: the cut-wait ENI test cancels after the attach instead of racin…
CMGS Sep 21, 2026
0f1371a
review: name the local logger in the two single-call warn sites
CMGS Sep 21, 2026
a1ca4ae
review: the gauge's help string already carries the secondary NIC godoc
CMGS Sep 21, 2026
ad7469c
fix: the bridge and the CNI veths follow the primary NIC MTU
CMGS Sep 21, 2026
306cd6f
docs: the conflist mtu follows the primary NIC and invalidates old FC…
CMGS Sep 21, 2026
4656ebb
docs: a snapshot after an MTU change needs the source VM recreated first
CMGS Sep 21, 2026
a944b1e
docs: simplify README navigation and section order
CMGS Sep 21, 2026
aebaa6e
fix: init without --pool-size gets its own 140 default, not adopt's 253
CMGS Sep 21, 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
54 changes: 19 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,16 @@ iptables DNAT, no external DHCP server dependency.

**Documentation: [cocoonstack.github.io/cocoon-net](https://cocoonstack.github.io/cocoon-net/)** (source in [`docs/`](docs/)).

```
cocoon-net init cocoon-net daemon
| |
v v
Cloud provisioning Node setup (NICs up, bridge, sysctl, iptables, CNI conflist)
(alias IPs / ENIs) |
| v
v DHCP server on cni0
pool.json <---------- |
v
On lease: add /32 route
On release: del /32 route
## Architecture

```mermaid
flowchart LR
provision["init / adopt"] --> state["pool.json"]
provision --> setup["Node networking"]
state --> daemon["daemon"]
daemon --> setup
daemon --> dhcp["DHCP on cni0"]
dhcp --> routes["Lease-based /32 routes"]
```

**Two-phase operation**: `cocoon-net init` does one-time cloud provisioning
Expand All @@ -30,7 +28,7 @@ long-running service that sets up node networking, serves DHCP, and manages
routes.
Full details in [Architecture](docs/architecture.md).

## Supported platforms
### Supported platforms

| Platform | Mechanism | Max IPs/node |
|---|---|---|
Expand All @@ -47,18 +45,15 @@ sudo cocoon-net daemon

Full steps in [Installation](docs/installation.md).

## Documentation
## Related projects

- [Architecture](docs/architecture.md) -- the two-phase model, package
layout, CNI integration, credentials
- [Embedded DHCP server](docs/dhcp.md) -- lease lifecycle, the lease file,
dynamic host routes, VM traffic isolation
- [Configuration](docs/configuration.md) -- every flag and environment
variable, plus the `pool.json` state schema
- [Installation](docs/installation.md) -- prebuilt binary, building from
source, the systemd unit, command usage
- [GKE VPC-native networking](docs/gke.md)
- [Volcengine VPC-native networking](docs/volcengine.md)
| Project | Role |
|---|---|
| [cocoon](https://github.com/cocoonstack/cocoon) | MicroVM engine (Cloud Hypervisor + Firecracker) |
| [cocoon-common](https://github.com/cocoonstack/cocoon-common) | Shared metadata, Kubernetes, and logging helpers |
| [cocoon-operator](https://github.com/cocoonstack/cocoon-operator) | CocoonSet and Hibernation CRDs |
| [cocoon-webhook](https://github.com/cocoonstack/cocoon-webhook) | Admission webhook for sticky scheduling |
| [vk-cocoon](https://github.com/cocoonstack/vk-cocoon) | Virtual kubelet provider |

## Development

Expand All @@ -67,19 +62,8 @@ make build # build binary
make test # run tests with coverage
make lint # golangci-lint (linux + darwin)
make fmt # gofumpt + goimports
make help # show all targets
```

## Related projects

| Project | Role |
|---|---|
| [cocoon](https://github.com/cocoonstack/cocoon) | MicroVM engine (Cloud Hypervisor + Firecracker) |
| [cocoon-common](https://github.com/cocoonstack/cocoon-common) | Shared metadata, Kubernetes, and logging helpers |
| [cocoon-operator](https://github.com/cocoonstack/cocoon-operator) | CocoonSet and Hibernation CRDs |
| [cocoon-webhook](https://github.com/cocoonstack/cocoon-webhook) | Admission webhook for sticky scheduling |
| [vk-cocoon](https://github.com/cocoonstack/vk-cocoon) | Virtual kubelet provider |

## License

[MIT](LICENSE)
11 changes: 9 additions & 2 deletions cmd/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ func runAdopt(cmd *cobra.Command, _ []string) error {
if err := resolveSubnet(); err != nil {
return err
}
dnsServers := splitTrim(flagDNS, ",")
dnsServers, err := parseDNSFlag()
if err != nil {
return err
}
poolSize, err := cmd.Flags().GetInt("pool-size")
if err != nil {
return err
}

gateway, err := platform.ResolveGateway(flagGateway, flagSubnet)
if err != nil {
Expand All @@ -57,7 +64,7 @@ func runAdopt(cmd *cobra.Command, _ []string) error {
if err != nil {
return fmt.Errorf("adopt platform: %w", err)
}
ips, poolENIs, err := adoptPool(ctx, plat, flagSubnet, gateway, flagPoolSize)
ips, poolENIs, err := adoptPool(ctx, plat, flagSubnet, gateway, poolSize)
if err != nil {
return fmt.Errorf("compute ip list: %w", err)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/cocoonstack/cocoon-net/metrics"
"github.com/cocoonstack/cocoon-net/node"
"github.com/cocoonstack/cocoon-net/platform"
"github.com/cocoonstack/cocoon-net/platform/gke"
)

const (
Expand Down Expand Up @@ -79,6 +80,11 @@ func runDaemon(cmd *cobra.Command, _ []string) error {
logger.Infof(ctx, "pool loaded: %d IPs, subnet %s, gateway %s", len(state.IPs), state.Subnet, state.Gateway)

state.PrimaryNIC = cmp.Or(state.PrimaryNIC, platform.DefaultNIC(state.Platform))
if state.Platform == platform.PlatformGKE {
if err = gke.New().Adopt(ctx, &platform.Config{NodeName: state.NodeName, SubnetCIDR: state.Subnet, Gateway: state.Gateway, PrimaryNIC: state.PrimaryNIC}); err != nil {
return fmt.Errorf("re-apply the guest-agent route fix: %w", err)
}
}
if setupErr := node.Setup(ctx, nodeConfigFromState(state, flagSkipIPTables)); setupErr != nil {
return fmt.Errorf("node setup: %w", setupErr)
}
Expand Down
24 changes: 20 additions & 4 deletions cmd/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"cmp"
"fmt"
"strings"

Expand Down Expand Up @@ -34,12 +35,19 @@ func runInit(cmd *cobra.Command, _ []string) error {
if err := resolveSubnet(); err != nil {
return err
}
dnsServers := splitTrim(flagDNS, ",")
dnsServers, err := parseDNSFlag()
if err != nil {
return err
}
poolSize, err := cmd.Flags().GetInt("pool-size")
if err != nil {
return err
}

cfg := &platform.Config{
NodeName: flagNodeName,
SubnetCIDR: flagSubnet,
PoolSize: flagPoolSize,
PoolSize: poolSize,
Gateway: flagGateway,
PrimaryNIC: flagPrimaryNIC,
}
Expand Down Expand Up @@ -67,9 +75,10 @@ func runInit(cmd *cobra.Command, _ []string) error {
DropCIDRs: flagDropCIDRs,
StateDir: flagStateDir,
}
if _, loadErr := pool.Load(ctx, flagStateDir); loadErr != nil {
existing, loadErr := pool.Load(ctx, flagStateDir)
if loadErr != nil {
// a first provisioning that fails midway still leaves teardown something to act on
if err := state.Save(ctx); err != nil {
if err = state.Save(ctx); err != nil {
return fmt.Errorf("save seed pool state: %w", err)
}
}
Expand All @@ -82,6 +91,13 @@ func runInit(cmd *cobra.Command, _ []string) error {

result, err := plat.ProvisionNetwork(ctx, cfg)
if err != nil {
if result != nil && len(result.ENIIDs) > 0 {
recorded := cmp.Or(existing, state)
recorded.ENIIDs = mergeENIIDs(recorded.ENIIDs, result.ENIIDs)
if saveErr := recorded.Save(ctx); saveErr != nil {
logger.Error(ctx, saveErr, "save the attached ENIs after the failed provisioning")
}
}
return fmt.Errorf("provision network: %w", err)
}
logger.Infof(ctx, "provisioned %d IPs on subnet %s", len(result.IPs), result.SubnetCIDR)
Expand Down
6 changes: 3 additions & 3 deletions cmd/pidfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func acquirePIDFile() error {
if !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("create pid file: %w", err)
}
if err := checkExistingPID(); err != nil {
if err := checkExistingPID(pidFile); err != nil {
return err
}
_ = os.Remove(pidFile)
Expand All @@ -43,8 +43,8 @@ func acquirePIDFile() error {
}

// a missing, corrupt, or stale (process dead) PID file is safe to overwrite
func checkExistingPID() error {
data, err := os.ReadFile(pidFile)
func checkExistingPID(path string) error {
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"strings"
"time"

"github.com/projecteru2/core/log"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -44,7 +45,7 @@ func runStatus(cmd *cobra.Command, _ []string) error {
fmt.Printf("Subnet: %s\n", state.Subnet)
fmt.Printf("Gateway: %s\n", state.Gateway)
fmt.Printf("IPs: %d\n", len(state.IPs))
fmt.Printf("Updated: %s\n", state.UpdatedAt.Format("2006-01-02T15:04:05Z"))
fmt.Printf("Updated: %s\n", state.UpdatedAt.Format(time.RFC3339))
if status != nil {
if len(status.ENIs) > 0 {
fmt.Printf("ENIs: %d\n", len(status.ENIs))
Expand Down
6 changes: 6 additions & 0 deletions cmd/teardown.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func newTeardownCmd() *cobra.Command {
cmd.Flags().StringVar(&flagStateDir, "state-dir", defaultStateDir, "state directory")
cmd.Flags().StringVar(&flagLeaseFile, "lease-file", defaultLeaseFile, "lease persistence file")
cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "show what would be done without making changes")
cmd.Flags().BoolVar(&flagForce, "force", false, "tear down even while a cocoon-net daemon is running")

return cmd
}
Expand All @@ -41,6 +42,11 @@ func runTeardown(cmd *cobra.Command, _ []string) error {
state.Platform, state.NodeName, state.Subnet)
return nil
}
if !flagForce {
if err = checkExistingPID(pidFile); err != nil {
return fmt.Errorf("%w; stop it first or pass --force", err)
}
}

plat, err := newPlatform(ctx, state.Platform)
if err != nil {
Expand Down
32 changes: 31 additions & 1 deletion cmd/teardown_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package cmd

import "testing"
import (
"os"
"path/filepath"
"strconv"
"testing"
)

func TestTeardownLeaseFileFlag(t *testing.T) {
cmd := newTeardownCmd()
Expand All @@ -12,3 +17,28 @@ func TestTeardownLeaseFileFlag(t *testing.T) {
t.Fatalf("lease-file default = %q, want %q", flag.DefValue, defaultLeaseFile)
}
}

func TestTeardownForceFlag(t *testing.T) {
if newTeardownCmd().Flags().Lookup("force") == nil {
t.Fatal("teardown has no force flag")
}
}

func TestCheckExistingPIDRefusesALiveDaemon(t *testing.T) {
path := filepath.Join(t.TempDir(), "cocoon-net.pid")
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil {
t.Fatal(err)
}
if err := checkExistingPID(path); err == nil {
t.Fatal("a pidfile naming a live process must be refused")
}
if err := os.WriteFile(path, []byte("999999"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkExistingPID(path); err != nil {
t.Fatalf("a stale pidfile must not block: %v", err)
}
if err := checkExistingPID(filepath.Join(t.TempDir(), "absent")); err != nil {
t.Fatalf("a missing pidfile must not block: %v", err)
}
}
26 changes: 23 additions & 3 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
"slices"
"strings"

"github.com/spf13/cobra"
Expand All @@ -22,12 +23,12 @@ var (
flagPlatform string
flagNodeName string
flagSubnet string
flagPoolSize int
flagGateway string
flagPrimaryNIC string
flagDNS string
flagStateDir string
flagDryRun bool
flagForce bool
flagDropInternal bool
flagDropCIDRs []string

Expand All @@ -39,10 +40,10 @@ func registerCommonFlags(cmd *cobra.Command, defaultPoolSize int) {
cmd.Flags().StringVar(&flagPlatform, "platform", "", "cloud platform (gke|volcengine); auto-detected from instance metadata if omitted")
cmd.Flags().StringVar(&flagNodeName, "node-name", "", "virtual node name (required)")
cmd.Flags().StringVar(&flagSubnet, "subnet", "", "VM subnet CIDR, e.g. 172.20.100.0/24 (required)")
cmd.Flags().IntVar(&flagPoolSize, "pool-size", defaultPoolSize, "number of IPs in the pool")
cmd.Flags().Int("pool-size", defaultPoolSize, "number of IPs in the pool")
cmd.Flags().StringVar(&flagGateway, "gateway", "", "gateway IP on cni0 (default: first IP in subnet)")
cmd.Flags().StringVar(&flagPrimaryNIC, "primary-nic", "", "host primary NIC (default: eth0 on volcengine, ens4 otherwise)")
cmd.Flags().StringVar(&flagDNS, "dns", "8.8.8.8,1.1.1.1", "comma-separated DNS servers for DHCP clients")
cmd.Flags().StringVar(&flagDNS, "dns", "8.8.8.8,1.1.1.1", "comma-separated IPv4 DNS servers for DHCP clients")
cmd.Flags().StringVar(&flagStateDir, "state-dir", defaultStateDir, "state directory")
cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "show what would be done without making changes")
cmd.Flags().BoolVar(&flagDropInternal, "drop-internal-access", false, "block VM-to-VM traffic within the cocoon subnet")
Expand Down Expand Up @@ -94,6 +95,25 @@ func resolveSubnet() error {
return nil
}

func parseDNSFlag() ([]string, error) {
servers := splitTrim(flagDNS, ",")
for _, s := range servers {
if net.ParseIP(s).To4() == nil {
return nil, fmt.Errorf("--dns entry %q is not an IPv4 address", s)
}
}
return servers, nil
}

func mergeENIIDs(recorded, attached []string) []string {
for _, id := range attached {
if !slices.Contains(recorded, id) {
recorded = append(recorded, id)
}
}
return recorded
}

func splitTrim(s, sep string) []string {
var parts []string
for p := range strings.SplitSeq(s, sep) {
Expand Down
Loading
Loading