diff --git a/README.md b/README.md index 7c5ef69..de86221 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The server exposes exactly three MCP tools: - `describe_api` returns the exact input and output shape for one capability. - `execute` runs a Starlark program and returns the value from its zero-argument `main()` function. -Slice 1 provides 13 capabilities: sandbox create/list/get/extend/delete, image listing, instance create/list/get/delete/exec, and network create/attach. Sandboxes are restricted Incus projects with persisted TTLs and a default NAT bridge. The startup/30-second reaper deletes expired sandboxes. +Capabilities cover sandbox TTLs; instance lifecycle, exec, files, snapshots, and sandbox-local image publishing; and network creation, NICs, peering, ACLs, forwards, and Linux link impairment. Sandboxes are restricted Incus projects. OVN provides cross-member networking; member-local bridges remain available. The startup/30-second reaper retries expired sandboxes when their backend becomes available. ## Local bootstrap @@ -33,15 +33,16 @@ Create `agentcompute.yaml` beside `images/`: ```yaml incus: remote: nas01 - host: lab01 pool: data sandbox: default_ttl_minutes: 240 max_ttl_minutes: 1440 - default_network_kind: bridge + default_network_kind: ovn images_file: images/catalog.yaml ``` +OVN requires the fleet-managed central, chassis TLS configuration, and physical uplink. To use the bridge fallback, set `default_network_kind: bridge` and configure `incus.host`. + Pass its path with `--config` or `AGENTCOMPUTE_CONFIG`. YAML and TOML are strict: unknown keys fail startup. Catalog and certificate paths resolve relative to the configuration file. Run the local STDIO transport: @@ -92,9 +93,13 @@ def main(): return result ``` -Instances use the sandbox's persisted member. An explicit different `host` is rejected. Bridges have opaque Incus names; capabilities expose only logical names such as `default` and `lan`. Each member's bridge is a separate L2 domain, not a cross-member network. +OVN instances may use an explicit online `host`. Without one, placement prefers the most free RAM, then the lowest one-minute load, then member name; automatic placement excludes manual/group-only schedulers. Bridge instances remain on the sandbox's persisted member and reject a different host. Bridge names are opaque; capabilities expose logical names such as `default` and `lan`. + +An OVN network with `nat=false` is isolated: it has no direct outside path and consumes no external address. Attach a router instance or use `net.peer` for reachability. Isolated networks reject external forwards. Operator-management and OOB denies are immutable, including against broader user allow rules. -Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. `user` accepts a numeric UID or `root`. OVN and macOS are not available in this slice. +Restoring a snapshot stages a copy before deleting the current instance, then recreates it under the same agent-visible name and ownership. The Incus identity and NIC MAC can change, so the DHCP lease can change too; the original instance's snapshots are consumed. Low-level Incus access remains blocked. + +Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. `user` accepts a numeric UID or `root`. macOS is not available. Only `main()`'s final converted value is returned. Intermediate capability results remain inside the worker and do not enter the model's context. @@ -190,6 +195,13 @@ AGENTCOMPUTE_TEST_REMOTE=nas01 AGENTCOMPUTE_TEST_HOST=lab01 \ go test -tags integration ./internal/cli -run TestClusterLifecycle -count=1 -v ``` +Run the cross-member OVN acceptance lane against the durable fleet fabric: + +```sh +AGENTCOMPUTE_TEST_REMOTE=nas01 AGENTCOMPUTE_TEST_MEMBERS=lab01,lab03 \ + go test -tags integration ./internal/cli -run '^TestOVNAcceptance$' -count=1 -v -timeout 15m +``` + `root:smoke` and release jobs use offline artifact startup checks, without cluster credentials. Go tests exercise the real CodeMode worker and both MCP transports. Run `.github/scripts/mcp_smoke.py -- bin/agentcompute stdio --config agentcompute.yaml` for read-only live discovery and execution. The combined cluster acceptance program and results are retained under `spikes/`. CI runs the same aggregate check with: diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 2d8f1ab..a458100 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -49,17 +49,19 @@ images_file: images/catalog.yaml | `incus.url` | Explicit daemon URL instead of a named remote. | | `incus.client_cert`, `incus.client_key` | PEM file paths for the explicit-URL client identity. | | `incus.server_cert` | Optional PEM file path for a pinned server certificate. Otherwise normal CA verification applies. | -| `incus.host` | Required member for newly created bridge-backed sandboxes. Placement is persisted in project metadata. | +| `incus.host` | Required when the default network kind is `bridge`; that member is persisted for the sandbox. OVN supports explicit per-instance hosts or automatic placement. | | `incus.pool` | Required storage pool for instance root disks. | | `sandbox.default_ttl_minutes` | Positive creation default, 240 minutes if omitted. | | `sandbox.max_ttl_minutes` | Positive upper bound, 1440 minutes if omitted; must be at least the default. | -| `sandbox.default_network_kind` | Only `bridge` is available. | +| `sandbox.default_network_kind` | `bridge` (default) or `ovn`. OVN requires an existing central, configured chassis, and physical uplink. | | `images_file` | Schema-version-1 catalog path; defaults to `images/catalog.yaml`. | -| `screenshots.dir`, `screenshots.base_url` | Accepted schema fields reserved for the desktop slice; no screenshot service in slice 1. | +| `screenshots.dir`, `screenshots.base_url` | Accepted schema fields reserved for desktop integration; the screenshot service is not implemented. | `sandbox.extend` replaces the expiry with **now + TTL**, rather than adding time to the old expiry. Explicit deletion first expires the project so a partial failure is retried by the reaper. The reaper scans at startup and every 30 seconds. -The default bridge enables IPv4 DHCP and NAT. `net.create` without L3 options creates a bare bridge. Physical network names are opaque `ac` plus eight lowercase hex characters; `user.agentcompute.sandbox`, `.name`, and `.version` metadata resolve their logical names. Pending creations also reserve the physical name on the project for cleanup. +In OVN mode, sandbox projects own their logical networks and NICs use managed networks only. Networks span members. A `nat=false` network is isolated, has no external allocation or direct outside path, and requires peering or a dual-NIC router for reachability. External forwards require NAT-enabled networks. Automatic instance placement prefers free RAM, then one-minute load, then member name; explicit online hosts take precedence. + +In bridge mode, the sandbox's default bridge enables IPv4 DHCP and NAT. Additional bridge networks require `kind="bridge"`. Physical network names are opaque `ac` plus eight lowercase hex characters; `user.agentcompute.sandbox`, `.name`, and `.version` metadata resolve their logical names. Pending creations also reserve the physical name on the project for cleanup. All members receive bridge definitions before activation, but each member has a separate L2/dnsmasq/NAT instance. All guests in a bridge-backed sandbox must use its persisted member. These bridges are not OVN networks. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index bdcba96..b3b70e6 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -24,6 +24,8 @@ incus: images_file: images/catalog.yaml ``` +This example uses the member-local bridge fallback. For cross-member networking, set `sandbox.default_network_kind` to `ovn`; `incus.host` can then be omitted. OVN requires a provisioned central, chassis configuration, and physical uplink. + The identity must manage sandbox projects and bridges in the default project. The image-build-only CI certificate is insufficient. See [Configuration](configuration.md) for explicit-URL credentials and TTL settings. ## Connect over STDIO @@ -41,7 +43,7 @@ Configure an MCP client with absolute paths: } ``` -Startup reconciles the image catalog. The client sees exactly `search_api`, `describe_api`, and `execute`; the 13 compute capabilities live behind those tools. STDIO sends JSON-RPC to stdout and diagnostics to stderr. +Startup reconciles the image catalog. The client sees exactly `search_api`, `describe_api`, and `execute`; compute capabilities live behind those tools. STDIO sends JSON-RPC to stdout and diagnostics to stderr. ## Discover the capabilities @@ -72,7 +74,7 @@ def main(): The result contains the command's exit code, stdout, stderr, timeout flag, and per-stream truncation flags. Only the final value returned by `main()` enters the successful MCP result. -An omitted sandbox name is generated. Its default bridge provides DHCP and NAT on the configured member. All its guests stay on that persisted member. If execution fails before explicit deletion, use `sandbox.list` to find the sandbox; its persisted TTL also survives a server restart and is enforced by the reaper. +An omitted sandbox name is generated. With this bridge configuration, its default network provides DHCP and NAT on the configured member, and all guests stay on that member. If execution fails before explicit deletion, use `sandbox.list` to find the sandbox; its persisted TTL survives a server restart and is enforced by the reaper. ## Use HTTP diff --git a/docs/docs/index.md b/docs/docs/index.md index 0b9e035..17b0265 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -8,7 +8,7 @@ description: A CodeMode-native Model Context Protocol server. `agentcompute` is a [CodeMode](https://github.com/meigma/codemode) [Model Context Protocol](https://modelcontextprotocol.io) server. You register typed Go capabilities; an agent uses the fixed `search_api`, `describe_api`, and `execute` MCP tools to discover and compose them in bounded Starlark programs. -Slice 1 manages time-limited Incus container sandboxes: 13 capabilities cover sandbox lifecycle, curated images, instances and bounded exec, and bridge networks. State survives process restarts in Incus project metadata. +Agentcompute manages time-limited Incus sandboxes with cross-member OVN networking or member-local bridges. Capabilities cover instance lifecycle, bounded exec, files, snapshots, image publishing, peering, ACLs, forwards, and link impairment. State survives process restarts in Incus project metadata. ## Documentation diff --git a/images/catalog.yaml b/images/catalog.yaml index 40e0b9e..82d2d9a 100644 --- a/images/catalog.yaml +++ b/images/catalog.yaml @@ -10,10 +10,20 @@ images: kinds: - container kind: container - reference: ghcr.io/gilmanlab/agentcompute/router@sha256:7d0877799aafc04c8a3ce321bfd586969f97c65e91665941455eeb160555df39 + reference: ghcr.io/gilmanlab/agentcompute/router@sha256:47cc9f058575d398736f45cd696f5613a24af73188df0a154c6284cc3e091960 cpus: 1 memory_mb: 512 disk_gb: 2 +- name: ubuntu/24.04 + os: ubuntu + version: '24.04' + kinds: + - container + kind: container + reference: images:ubuntu/24.04 + cpus: 1 + memory_mb: 1024 + disk_gb: 4 - name: runner os: ubuntu version: '24.04' diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 5c8d24c..126f775 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -41,7 +41,7 @@ func TestClusterLifecycle(t *testing.T) { require.NoError(t, err, "%s", output) config := filepath.Join(dir, "config.yaml") text := fmt.Sprintf( - "incus:\n remote: %q\n host: %q\n pool: data\nimages_file: %q\n", + "incus:\n remote: %q\n host: %q\n pool: data\nsandbox:\n default_network_kind: bridge\nimages_file: %q\n", remote, host, filepath.Join(root, "images", "catalog.yaml"), @@ -58,6 +58,7 @@ func TestClusterLifecycle(t *testing.T) { `) t.Logf("MCP sandbox creation: %s", time.Since(createStarted)) name := sandbox["name"].(string) + require.Equal(t, "bridge", sandbox["network"].(map[string]any)["kind"]) expires := sandbox["expires_at"].(string) t.Cleanup(func() { cleanup, stop := context.WithTimeout(context.Background(), time.Minute) @@ -78,6 +79,14 @@ func TestClusterLifecycle(t *testing.T) { observedNICs := created["observed"].(map[string]any)["nics"].([]any) require.Len(t, observedNICs, 1) assert.Equal(t, "default", observedNICs[0].(map[string]any)["network"]) + mixed, err := first.CallTool(ctx, &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": fmt.Sprintf(`def main(): + return net.create(sandbox=%q, name="ovn-mixed", kind="ovn") +`, name)}, + }) + require.NoError(t, err) + require.True(t, mixed.IsError, "OVN networks must not mix into a bridge sandbox") duplicate, err := first.CallTool(ctx, &mcp.CallToolParams{ Name: "execute", Arguments: map[string]any{"source": fmt.Sprintf(`def main(): @@ -156,15 +165,36 @@ func TestClusterLifecycle(t *testing.T) { } } -func startClusterClient(ctx context.Context, t *testing.T, binary, config string) (*mcp.ClientSession, *exec.Cmd) { +func startClusterClient( + ctx context.Context, + t *testing.T, + binary, config string, + names ...string, +) (*mcp.ClientSession, *exec.Cmd) { t.Helper() + if len(names) == 0 { + names = []string{ + "sandbox.create", + "sandbox.list", + "instance.create", + "instance.get", + "instance.exec", + "net.attach", + } + } command := exec.CommandContext(ctx, binary, "stdio", "--config", config) command.Stderr = os.Stderr client := mcp.NewClient(&mcp.Implementation{Name: "integration", Version: "1"}, nil) session, err := client.Connect(ctx, &mcp.CommandTransport{Command: command, TerminateDuration: time.Second}, nil) require.NoError(t, err) t.Cleanup(func() { _ = session.Close() }) - for _, name := range []string{"sandbox.create", "sandbox.list", "instance.create", "instance.get", "instance.exec", "net.attach"} { + discoverCapabilities(ctx, t, session, names) + return session, command +} + +func discoverCapabilities(ctx context.Context, t *testing.T, session *mcp.ClientSession, names []string) { + t.Helper() + for _, name := range names { for _, tool := range []struct{ name, key string }{{"search_api", "query"}, {"describe_api", "name"}} { result, err := session.CallTool( ctx, @@ -174,7 +204,6 @@ func startClusterClient(ctx context.Context, t *testing.T, binary, config string require.False(t, result.IsError, "%v", result.Content) } } - return session, command } func integrationExecute(ctx context.Context, t *testing.T, session *mcp.ClientSession, source string) map[string]any { @@ -193,3 +222,86 @@ func integrationExecute(ctx context.Context, t *testing.T, session *mcp.ClientSe require.NoError(t, json.Unmarshal(data, &envelope)) return envelope.Result } + +func integrationExecuteError(ctx context.Context, t *testing.T, session *mcp.ClientSession, source string) { + t.Helper() + result, err := session.CallTool( + ctx, + &mcp.CallToolParams{Name: "execute", Arguments: map[string]any{"source": source}}, + ) + require.NoError(t, err) + require.True(t, result.IsError, "expected an agent-facing error, content: %v", result.Content) +} + +func requireTestRemote(t *testing.T) string { + t.Helper() + remote := os.Getenv("AGENTCOMPUTE_TEST_REMOTE") + if remote == "" { + t.Skip("set AGENTCOMPUTE_TEST_REMOTE to opt into disposable cluster resources") + } + return remote +} + +func integrationRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + return root +} + +func buildAgentcompute(ctx context.Context, t *testing.T, root, dir string) string { + t.Helper() + binary := filepath.Join(dir, "agentcompute") + build := exec.CommandContext(ctx, "go", "build", "-o", binary, "./cmd/agentcompute") + build.Dir = root + output, err := build.CombinedOutput() + require.NoError(t, err, "%s", output) + return binary +} + +func asMap(t *testing.T, value any) map[string]any { + t.Helper() + out, ok := value.(map[string]any) + require.True(t, ok, "expected object, got %T", value) + return out +} + +func asSlice(t *testing.T, value any) []any { + t.Helper() + out, ok := value.([]any) + require.True(t, ok, "expected list, got %T", value) + return out +} + +func asString(t *testing.T, value any) string { + t.Helper() + out, ok := value.(string) + require.True(t, ok, "expected string, got %T", value) + return out +} + +func jsonInt(t *testing.T, value any) int64 { + t.Helper() + switch n := value.(type) { + case int64: + return n + case float64: + return int64(n) + case json.Number: + parsed, err := n.Int64() + require.NoError(t, err) + return parsed + case int: + return int64(n) + default: + t.Fatalf("expected integer, got %T", value) + return 0 + } +} + +func jsonBool(t *testing.T, value any) bool { + t.Helper() + out, ok := value.(bool) + require.True(t, ok, "expected bool, got %T", value) + return out +} diff --git a/internal/cli/ovn_integration_test.go b/internal/cli/ovn_integration_test.go new file mode 100644 index 0000000..38b6055 --- /dev/null +++ b/internal/cli/ovn_integration_test.go @@ -0,0 +1,731 @@ +//go:build integration + +package cli + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/lxc/incus/v7/shared/api" + "github.com/meigma/codemode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/GilmanLab/agentcompute/internal/compute" + "github.com/GilmanLab/agentcompute/internal/incus" +) + +var phase5Capabilities = []string{ + "sandbox.create", "sandbox.list", "sandbox.get", "sandbox.extend", "sandbox.delete", + "image.list", + "instance.create", "instance.list", "instance.get", "instance.delete", "instance.exec", + "instance.start", "instance.stop", "instance.restart", "instance.wait", + "instance.file.read", "instance.file.write", + "instance.snapshot.create", "instance.snapshot.restore", "instance.snapshot.delete", "instance.snapshot.list", + "instance.publish", + "net.create", "net.list", "net.get", "net.delete", "net.attach", "net.detach", + "net.peer", "net.acl.add", "net.acl.remove", "net.forward", "net.impair", +} + +type ovnFixture struct { + Remote string + Members []string + MgmtProbe string + OOBProbe string + Uplink string + Ranges string + RouterNAT string + Northbound string +} + +// TestOVNAcceptance exercises the production binary against a live OVN sandbox. +// +// It does not start, stop, or reconfigure OVN central. Central outage proof is +// a separate fleet orchestration. Required infrastructure: Incus 7.4 cluster +// with chassis, TLS NB/SB, physical uplink fast40-uplink, VLAN 40 OVN range +// 10.10.40.64-10.10.40.127, catalog image "router", and operator reachability +// to VLAN 40 forwards. Never recreate the deleted default/soak01 macvlan fixture. +func TestOVNAcceptance(t *testing.T) { + fx := loadOVNFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Minute) + t.Cleanup(cancel) + root := integrationRoot(t) + dir := t.TempDir() + binary := buildAgentcompute(ctx, t, root, dir) + config := writeOVNConfig(t, dir, root, fx) + backend, err := incus.New(ctx, incus.Options{ + Remote: fx.Remote, + Pool: "data", + OVNUplink: fx.Uplink, + OVNRanges: fx.Ranges, + }) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + + session, _ := startClusterClient(ctx, t, binary, config, phase5Capabilities...) + run := func(source string) map[string]any { + return integrationExecute(ctx, t, session, source) + } + fail := func(source string) { + integrationExecuteError(ctx, t, session, source) + } + + created := run(`def main(): + return sandbox.create(ttl_minutes=30) +`) + name := asString(t, created["name"]) + require.Equal(t, "ovn", asMap(t, created["network"])["kind"]) + t.Cleanup(func() { + cleanup, stop := context.WithTimeout(context.Background(), 2*time.Minute) + defer stop() + if _, getErr := backend.GetSandbox(cleanup, name); getErr == nil { + assert.NoError(t, backend.DeleteSandbox(cleanup, name)) + } + }) + + listed := run(fmt.Sprintf(`def main(): + return {"listed": net.list(sandbox=%q), "default": net.get(sandbox=%q, name="default")} +`, name, name)) + require.NotEmpty(t, asSlice(t, asMap(t, listed["listed"])["items"])) + require.Equal(t, "ovn", asMap(t, listed["default"])["kind"]) + require.Equal(t, "default", asMap(t, listed["default"])["name"]) + + require.GreaterOrEqual(t, len(fx.Members), 2) + memberA, memberB := fx.Members[0], fx.Members[1] + guests := run(fmt.Sprintf(`def main(): + a = instance.create(sandbox=%q, name="a", image="router", host=%q) + b = instance.create(sandbox=%q, name="b", image="router", host=%q) + instance.wait(sandbox=%q, name="a", until="agent", timeout_seconds=120) + instance.wait(sandbox=%q, name="b", until="agent", timeout_seconds=120) + instance.wait(sandbox=%q, name="a", until="network", timeout_seconds=120) + instance.wait(sandbox=%q, name="b", until="network", timeout_seconds=120) + return {"a": instance.get(sandbox=%q, name="a"), "b": instance.get(sandbox=%q, name="b"), "host_a": a["host"], "host_b": b["host"]} +`, name, memberA, name, memberB, name, name, name, name, name, name)) + gotA := asMap(t, guests["a"]) + gotB := asMap(t, guests["b"]) + require.Equal(t, memberA, asString(t, guests["host_a"])) + require.Equal(t, memberB, asString(t, guests["host_b"])) + require.NotEqual(t, asString(t, guests["host_a"]), asString(t, guests["host_b"])) + require.Len(t, asSlice(t, gotA["nics"]), 1) + ipB := firstGlobalIPv4(t, gotB) + ping := run(fmt.Sprintf(`def main(): + return instance.exec(sandbox=%q, name="a", command="ping -c 3 -W 2 %s") +`, name, ipB)) + require.Zero(t, jsonInt(t, ping["exit_code"])) + require.False(t, jsonBool(t, ping["timed_out"])) + + mgmtHost, mgmtPort := splitProbe(t, fx.MgmtProbe) + oobHost, oobPort := splitProbe(t, fx.OOBProbe) + probes := run(fmt.Sprintf(`def main(): + which = instance.exec(sandbox=%q, name="a", command="command -v nc") + mgmt = instance.exec(sandbox=%q, name="a", command="nc -z -w 3 %s %s", timeout_seconds=10) + oob = instance.exec(sandbox=%q, name="a", command="nc -z -w 3 %s %s", timeout_seconds=10) + return {"which": which, "mgmt": mgmt, "oob": oob} +`, name, name, mgmtHost, mgmtPort, name, oobHost, oobPort)) + require.Zero( + t, + jsonInt(t, asMap(t, probes["which"])["exit_code"]), + "router image has no nc; cannot probe baseline ACL", + ) + require.False(t, execSucceeded(t, asMap(t, probes["mgmt"])), "management probe must fail closed") + require.False(t, execSucceeded(t, asMap(t, probes["oob"])), "OOB probe must fail closed") + + for _, rule := range []string{ + "baseline-egress-mgmt", + "baseline-egress-oob", + } { + fail(fmt.Sprintf(`def main(): + return net.acl.remove(sandbox=%q, network="default", rule=%q) +`, name, rule)) + } + fail(fmt.Sprintf(`def main(): + return net.acl.add(sandbox=%q, network="default", direction="egress", action="allow", dst="10.10.10.0/24") +`, name)) + fail(fmt.Sprintf(`def main(): + return net.acl.add(sandbox=%q, network="default", direction="egress", action="allow", dst="10.10.70.0/24") +`, name)) + still := run(fmt.Sprintf(`def main(): + mgmt = instance.exec(sandbox=%q, name="a", command="nc -z -w 3 %s %s", timeout_seconds=10) + oob = instance.exec(sandbox=%q, name="a", command="nc -z -w 3 %s %s", timeout_seconds=10) + return {"mgmt": mgmt, "oob": oob} +`, name, mgmtHost, mgmtPort, name, oobHost, oobPort)) + require.False(t, execSucceeded(t, asMap(t, still["mgmt"])), "allow must not bypass management baseline") + require.False(t, execSucceeded(t, asMap(t, still["oob"])), "allow must not bypass OOB baseline") + + files := run(fmt.Sprintf(`def main(): + written = instance.file.write(sandbox=%q, name="a", path="/tmp/agentcompute-mode", content="hello-mode", mode="0640") + mode = instance.exec(sandbox=%q, name="a", command="stat -c %%a /tmp/agentcompute-mode") + instance.exec(sandbox=%q, name="a", command="head -c 200000 /dev/zero | tr '\\000' a > /tmp/agentcompute-big") + default_read = instance.file.read(sandbox=%q, name="a", path="/tmp/agentcompute-big") + capped = instance.file.read(sandbox=%q, name="a", path="/tmp/agentcompute-big", max_bytes=1000) + return {"written": written, "mode": mode, "default_read": default_read, "capped": capped} +`, name, name, name, name, name)) + require.Equal(t, int64(len("hello-mode")), jsonInt(t, asMap(t, files["written"])["bytes"])) + require.Zero(t, jsonInt(t, asMap(t, files["mode"])["exit_code"])) + require.Equal(t, "640", strings.TrimSpace(asString(t, asMap(t, files["mode"])["stdout"]))) + require.True(t, jsonBool(t, asMap(t, files["default_read"])["truncated"])) + require.Len(t, asString(t, asMap(t, files["default_read"])["content"]), 64*1024) + require.True(t, jsonBool(t, asMap(t, files["capped"])["truncated"])) + require.Len(t, asString(t, asMap(t, files["capped"])["content"]), 1000) + fail(fmt.Sprintf(`def main(): + return instance.file.write(sandbox=%q, name="a", path="/tmp/agentcompute-too-big", content="a"*65537) +`, name)) + + snaps := run(fmt.Sprintf(`def main(): + instance.file.write(sandbox=%q, name="a", path="/tmp/agentcompute-undo", content="before") + instance.snapshot.create(sandbox=%q, name="a", snapshot="undo") + listed = instance.snapshot.list(sandbox=%q, name="a") + instance.file.write(sandbox=%q, name="a", path="/tmp/agentcompute-undo", content="after") + after = instance.file.read(sandbox=%q, name="a", path="/tmp/agentcompute-undo") + instance.snapshot.restore(sandbox=%q, name="a", snapshot="undo") + restored = instance.file.read(sandbox=%q, name="a", path="/tmp/agentcompute-undo") + instance.snapshot.create(sandbox=%q, name="a", snapshot="fresh") + instance.snapshot.delete(sandbox=%q, name="a", snapshot="fresh") + remaining = instance.snapshot.list(sandbox=%q, name="a") + return {"after": after, "restored": restored, "listed": listed, "remaining": remaining} +`, name, name, name, name, name, name, name, name, name, name)) + require.Equal(t, "after", asString(t, asMap(t, snaps["after"])["content"])) + require.Equal(t, "before", asString(t, asMap(t, snaps["restored"])["content"])) + items := asSlice(t, asMap(t, snaps["listed"])["items"]) + require.NotEmpty(t, items) + foundUndo := false + for _, raw := range items { + item := asMap(t, raw) + if asString(t, item["name"]) == "undo" { + foundUndo = true + _, parseErr := time.Parse(time.RFC3339, asString(t, item["created_at"])) + require.NoError(t, parseErr) + } + } + require.True(t, foundUndo) + require.Empty(t, asSlice(t, asMap(t, snaps["remaining"])["items"])) + + power := run(fmt.Sprintf(`def main(): + stopped = instance.stop(sandbox=%q, name="a") + waited = instance.wait(sandbox=%q, name="a", until="stopped", timeout_seconds=120) + started = instance.start(sandbox=%q, name="a") + running = instance.wait(sandbox=%q, name="a", until="running", timeout_seconds=120) + restarted = instance.restart(sandbox=%q, name="a") + agent = instance.wait(sandbox=%q, name="a", until="agent", timeout_seconds=120) + return {"stopped": stopped, "waited": waited, "started": started, "running": running, "restarted": restarted, "agent": agent} +`, name, name, name, name, name, name)) + require.NotEmpty(t, asString(t, asMap(t, power["waited"])["status"])) + require.NotEmpty(t, asString(t, asMap(t, power["running"])["status"])) + require.GreaterOrEqual(t, jsonInt(t, asMap(t, power["waited"])["elapsed_seconds"]), int64(0)) + fail(fmt.Sprintf(`def main(): + return instance.wait(sandbox=%q, name="a", until="desktop", timeout_seconds=5) +`, name)) + + attached := run(fmt.Sprintf(`def main(): + spare = net.create(sandbox=%q, name="spare", cidr="192.168.82.0/24", nat=False) + nic = net.attach(sandbox=%q, instance="a", network="spare") + return {"spare": spare, "nic": nic, "a": instance.get(sandbox=%q, name="a")} +`, name, name, name)) + spareNIC := asString(t, asMap(t, attached["nic"])["nic"]) + require.NotEmpty(t, spareNIC) + require.True(t, hasNIC(t, asMap(t, attached["a"]), spareNIC)) + require.Contains(t, nicNetworks(t, asMap(t, attached["a"])), "spare") + require.Contains(t, nicNetworks(t, asMap(t, attached["a"])), "default") + isolated, _, err := backend.Scoped(ctx, "ac-"+name, "").GetNetwork("spare") + require.NoError(t, err) + require.Empty( + t, + isolated.Config["volatile.network.ipv4.address"], + "isolated networks must not consume uplink addresses", + ) + fail(fmt.Sprintf(`def main(): + return net.forward(sandbox=%q, network="spare", instance="a", port=8080) +`, name)) + detached := run(fmt.Sprintf(`def main(): + net.detach(sandbox=%q, instance="a", nic=%q) + return {"a": instance.get(sandbox=%q, name="a")} +`, name, spareNIC, name)) + require.False(t, hasNIC(t, asMap(t, detached["a"]), spareNIC)) + require.NotContains(t, nicNetworks(t, asMap(t, detached["a"])), "spare") + require.Equal(t, []string{"default"}, nicNetworks(t, asMap(t, detached["a"]))) + require.Len(t, asSlice(t, asMap(t, detached["a"])["nics"]), 1) + + peer := run(fmt.Sprintf(`def main(): + east = net.create(sandbox=%q, name="east", cidr="192.168.80.0/24", nat=False) + west = net.create(sandbox=%q, name="west", cidr="192.168.81.0/24", nat=False) + net.peer(sandbox=%q, network="east", peer="west") + peera = instance.create(sandbox=%q, name="peera", image="router", network="east", host=%q) + peerb = instance.create(sandbox=%q, name="peerb", image="router", network="west", host=%q) + instance.wait(sandbox=%q, name="peera", until="agent", timeout_seconds=120) + instance.wait(sandbox=%q, name="peerb", until="agent", timeout_seconds=120) + instance.wait(sandbox=%q, name="peera", until="network", timeout_seconds=120) + instance.wait(sandbox=%q, name="peerb", until="network", timeout_seconds=120) + return {"east": east, "west": west, "host_a": peera["host"], "host_b": peerb["host"], "peera": instance.get(sandbox=%q, name="peera"), "peerb": instance.get(sandbox=%q, name="peerb")} +`, name, name, name, name, memberA, name, memberB, name, name, name, name, name, name)) + require.Equal(t, "ovn", asMap(t, peer["east"])["kind"]) + require.Equal(t, "ovn", asMap(t, peer["west"])["kind"]) + require.Equal(t, memberA, asString(t, peer["host_a"])) + require.Equal(t, memberB, asString(t, peer["host_b"])) + require.NotEqual(t, asString(t, peer["host_a"]), asString(t, peer["host_b"])) + gotPeerA := asMap(t, peer["peera"]) + gotPeerB := asMap(t, peer["peerb"]) + require.Equal(t, []string{"east"}, nicNetworks(t, gotPeerA)) + require.Equal(t, []string{"west"}, nicNetworks(t, gotPeerB)) + require.Len(t, asSlice(t, gotPeerA["nics"]), 1) + require.Len(t, asSlice(t, gotPeerB["nics"]), 1) + peerANIC := asString(t, asMap(t, asSlice(t, gotPeerA["nics"])[0])["name"]) + peerBNIC := asString(t, asMap(t, asSlice(t, gotPeerB["nics"])[0])["name"]) + peerBIP := nicIPv4(t, gotPeerB, peerBNIC) + peerPing := run(fmt.Sprintf(`def main(): + return instance.exec(sandbox=%q, name="peera", command="ping -c 3 -W 2 %s") +`, name, peerBIP)) + require.True(t, execSucceeded(t, peerPing), "east/west peer ping from peera to %s: %#v", peerBIP, peerPing) + isolation := run(fmt.Sprintf(`def main(): + return instance.exec(sandbox=%q, name="peera", command="ping -c 2 -W 2 10.10.40.1", timeout_seconds=10) +`, name)) + require.False(t, execSucceeded(t, isolation), "isolated peered networks must not reach the lab uplink") + + acl := run(fmt.Sprintf(`def main(): + drop = net.acl.add(sandbox=%q, network="east", direction="egress", action="drop", protocol="icmp") + blocked = instance.exec(sandbox=%q, name="peera", command="ping -c 3 -W 2 %s", timeout_seconds=15) + still = instance.exec(sandbox=%q, name="a", command="ping -c 3 -W 2 %s") + return {"rule": drop["rule"], "blocked": blocked, "still": still} +`, name, name, peerBIP, name, ipB)) + require.NotEmpty(t, asString(t, acl["rule"])) + require.False(t, execSucceeded(t, asMap(t, acl["blocked"])), "east ICMP drop must block peera->peerb") + require.True(t, execSucceeded(t, asMap(t, acl["still"])), "default a->b must stay reachable") + recovered := run(fmt.Sprintf(`def main(): + net.acl.remove(sandbox=%q, network="east", rule=%q) + return instance.exec(sandbox=%q, name="peera", command="ping -c 3 -W 2 %s") +`, name, asString(t, acl["rule"]), name, peerBIP)) + require.True(t, execSucceeded(t, recovered), "peer ping must recover after east ICMP drop is removed") + + baselinePing := run(fmt.Sprintf(`def main(): + return instance.exec(sandbox=%q, name="peera", command="ping -c 5 -W 2 %s") +`, name, peerBIP)) + require.True(t, execSucceeded(t, baselinePing), "unimpaired peer ping: %#v", baselinePing) + baselineRTT := pingAvgMS(t, asString(t, baselinePing["stdout"])) + require.Less( + t, + baselineRTT, + 80.0, + "unimpaired overlay ping must stay well under 200ms netem, rtt=%.3f", + baselineRTT, + ) + delayed := run(fmt.Sprintf(`def main(): + net.impair(sandbox=%q, instance="peera", nic=%q, latency_ms=200) + pinged = instance.exec(sandbox=%q, name="peera", command="ping -c 5 -W 2 %s", timeout_seconds=30) + qdisc = instance.exec(sandbox=%q, name="peera", command="tc qdisc show dev %s") + return {"ping": pinged, "qdisc": qdisc} +`, name, peerANIC, name, peerBIP, name, peerANIC)) + require.True(t, execSucceeded(t, asMap(t, delayed["ping"])), "impaired ping: %#v", delayed["ping"]) + delayedRTT := pingAvgMS(t, asString(t, asMap(t, delayed["ping"])["stdout"])) + require.Greater( + t, + delayedRTT, + baselineRTT+100, + "latency_ms=200 must raise ping RTT (baseline=%.3f delayed=%.3f)", + baselineRTT, + delayedRTT, + ) + require.Zero(t, jsonInt(t, asMap(t, delayed["qdisc"])["exit_code"])) + require.Contains(t, asString(t, asMap(t, delayed["qdisc"])["stdout"]), "qdisc netem 1:") + rated := run(fmt.Sprintf(`def main(): + net.impair(sandbox=%q, instance="peera", nic=%q, rate_mbit=10) + qdisc = instance.exec(sandbox=%q, name="peera", command="tc qdisc show dev %s") + return qdisc +`, name, peerANIC, name, peerANIC)) + require.Zero(t, jsonInt(t, rated["exit_code"])) + rateOut := strings.ToLower(asString(t, rated["stdout"])) + require.Contains(t, rateOut, "qdisc netem 1:") + require.Contains(t, rateOut, "rate") + require.Contains(t, rateOut, "10mbit") + cleared := run(fmt.Sprintf(`def main(): + net.impair(sandbox=%q, instance="peera", nic=%q, clear=True) + qdisc = instance.exec(sandbox=%q, name="peera", command="tc qdisc show dev %s") + pinged = instance.exec(sandbox=%q, name="peera", command="ping -c 5 -W 2 %s", timeout_seconds=30) + return {"qdisc": qdisc, "ping": pinged} +`, name, peerANIC, name, peerANIC, name, peerBIP)) + require.Zero(t, jsonInt(t, asMap(t, cleared["qdisc"])["exit_code"])) + require.NotContains(t, asString(t, asMap(t, cleared["qdisc"])["stdout"]), "qdisc netem 1:") + require.True(t, execSucceeded(t, asMap(t, cleared["ping"])), "cleared ping: %#v", cleared["ping"]) + clearedRTT := pingAvgMS(t, asString(t, asMap(t, cleared["ping"])["stdout"])) + require.Less( + t, + clearedRTT, + baselineRTT+50, + "clear must remove netem latency (baseline=%.3f cleared=%.3f)", + baselineRTT, + clearedRTT, + ) + + topo := run(fmt.Sprintf(`def main(): + lan = net.create(sandbox=%q, name="lan", cidr="192.168.50.0/24", nat=False) + wan = net.create(sandbox=%q, name="wan", cidr="10.99.0.0/24", nat=True) + rtr = instance.create(sandbox=%q, name="rtr", image="router", network="lan") + wan_nic = net.attach(sandbox=%q, instance="rtr", network="wan") + client = instance.create(sandbox=%q, name="client", image="ubuntu/24.04", network="lan") + instance.wait(sandbox=%q, name="rtr", until="agent", timeout_seconds=120) + instance.wait(sandbox=%q, name="rtr", until="network", timeout_seconds=120) + nat_path = instance.exec(sandbox=%q, name="rtr", command="test -e %s") + return {"lan": lan, "wan": wan, "rtr": instance.get(sandbox=%q, name="rtr"), "client": instance.get(sandbox=%q, name="client"), "wan_nic": wan_nic, "nat_path": nat_path} +`, name, name, name, name, name, name, name, name, fx.RouterNAT, name, name)) + require.Zero(t, jsonInt(t, asMap(t, topo["nat_path"])["exit_code"]), "router image must ship %s", fx.RouterNAT) + nat := run(fmt.Sprintf(`def main(): + return instance.exec(sandbox=%q, name="rtr", command="%s --mode port-restricted --inside eth0 --outside eth1") +`, name, fx.RouterNAT)) + require.Zero(t, jsonInt(t, nat["exit_code"])) + rtrLAN := nicIPv4(t, asMap(t, topo["rtr"]), "eth0") + routed := run(fmt.Sprintf(`def main(): + instance.wait(sandbox=%q, name="client", until="network", timeout_seconds=120) + return instance.exec(sandbox=%q, name="client", command="ip route replace 10.99.0.0/24 via %s && ping -c 3 -W 2 10.99.0.1") +`, name, name, rtrLAN)) + require.Zero(t, jsonInt(t, routed["exit_code"]), "client must reach WAN through the router instance: %#v", routed) + clientNICs := asSlice(t, asMap(t, topo["client"])["nics"]) + require.Len(t, clientNICs, 1) + require.Equal(t, "lan", asMap(t, clientNICs[0])["network"]) + rtrNetworks := nicNetworks(t, asMap(t, topo["rtr"])) + require.Contains(t, rtrNetworks, "lan") + require.Contains(t, rtrNetworks, "wan") + require.NotContains(t, rtrNetworks, "default") + script := "#!/bin/sh\nwhile true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: 10\\r\\nConnection: close\\r\\n\\r\\nforward-ok' | nc -l -p 8080; done\n" + listener := run(fmt.Sprintf(`def main(): + written = instance.file.write(sandbox=%q, name="a", path="/usr/local/bin/forward-ok", content=%q, mode="0755") + started = instance.exec(sandbox=%q, name="a", command="start-stop-daemon -S -b -m -p /run/forward-ok.pid -x /usr/local/bin/forward-ok") + return {"written": written, "started": started} +`, name, script, name)) + require.Zero(t, jsonInt(t, asMap(t, listener["started"])["exit_code"])) + time.Sleep(time.Second) + fwd := run(fmt.Sprintf(`def main(): + return net.forward(sandbox=%q, network="default", instance="a", port=8080) +`, name)) + address := asString(t, fwd["address"]) + port := jsonInt(t, fwd["port"]) + require.NotEmpty(t, address) + require.Equal(t, int64(8080), port) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get("http://" + net.JoinHostPort(address, fmt.Sprintf("%d", port)) + "/") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "forward-ok", string(body)) + + fail(fmt.Sprintf(`def main(): + return instance.publish(sandbox=%q, name="a", image="published") +`, name)) + published := run(fmt.Sprintf(`def main(): + instance.file.write(sandbox=%q, name="a", path="/tmp/agentcompute-published", content="from-sandbox-image") + instance.stop(sandbox=%q, name="a") + instance.wait(sandbox=%q, name="a", until="stopped", timeout_seconds=120) + image = instance.publish(sandbox=%q, name="a", image="published") + clone = instance.create(sandbox=%q, name="clone", image="published") + marker = instance.file.read(sandbox=%q, name="clone", path="/tmp/agentcompute-published") + return {"image": image, "clone": clone, "marker": marker} +`, name, name, name, name, name, name)) + require.Equal(t, "published", asString(t, asMap(t, published["image"])["image"])) + require.Equal(t, "from-sandbox-image", asString(t, asMap(t, published["marker"])["content"])) + alias, _, aliasErr := backend.Scoped(ctx, "ac-"+name, "").GetImageAlias("published") + require.NoError(t, aliasErr) + require.NotNil(t, alias) + require.NotEmpty(t, alias.Target) + + owned, err := backend.ListNetworks(ctx, name) + require.NoError(t, err) + var physical []string + for _, network := range owned { + if network.Kind == "bridge" && network.PhysicalName != "" { + physical = append(physical, network.PhysicalName) + } + } + + run(fmt.Sprintf(`def main(): + sandbox.delete(name=%q) + return {"deleted": True} +`, name)) + assertNoSandboxResidue(t, ctx, backend, name, physical, fx.Members, address) +} + +func TestOVNNetworkCreateRejectsIsolationChange(t *testing.T) { + fx := loadOVNFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + backend, err := incus.New(ctx, incus.Options{Remote: fx.Remote, Pool: "data"}) + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, backend.Close()) }) + name := fmt.Sprintf("isolation-%x", time.Now().UnixNano()) + t.Cleanup(func() { + cleanup, done := context.WithTimeout(context.Background(), time.Minute) + defer done() + assert.NoError(t, backend.DeleteSandbox(cleanup, name)) + }) + require.NoError(t, backend.CreateSandbox(ctx, compute.Sandbox{ + Name: name, NetworkKind: "ovn", Subject: "local", + CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), + })) + original, err := backend.GetNetwork(ctx, name, "default") + require.NoError(t, err) + require.True(t, original.NAT) + isolated := original + isolated.NAT = false + _, err = backend.CreateNetwork(ctx, name, isolated) + var conflict *codemode.AgentError + require.ErrorAs(t, err, &conflict) + unchanged, err := backend.GetNetwork(ctx, name, "default") + require.NoError(t, err) + require.True(t, unchanged.NAT, "create must not convert an existing network") + reused, err := backend.CreateNetwork(ctx, name, original) + require.NoError(t, err) + require.Equal(t, original.CIDR, reused.CIDR) +} + +func loadOVNFixture(t *testing.T) ovnFixture { + t.Helper() + fx := ovnFixture{ + Remote: requireTestRemote(t), + Members: csvEnv("AGENTCOMPUTE_TEST_MEMBERS", "lab01,lab03"), + MgmtProbe: envOr("AGENTCOMPUTE_TEST_MGMT_PROBE", "10.10.10.14:8443"), + OOBProbe: envOr("AGENTCOMPUTE_TEST_OOB_PROBE", "10.10.70.20:443"), + Uplink: os.Getenv("AGENTCOMPUTE_TEST_OVN_UPLINK"), + Ranges: os.Getenv("AGENTCOMPUTE_TEST_OVN_RANGES"), + RouterNAT: envOr("AGENTCOMPUTE_TEST_ROUTER_NAT", "/opt/router/nat"), + Northbound: os.Getenv("AGENTCOMPUTE_TEST_OVN_NORTHBOUND"), + } + t.Logf( + "OVN fixture remote=%s members=%v mgmt=%s oob=%s uplink=%q ranges=%q router_nat=%s northbound=%q (inspect-only; tests do not mutate central)", + fx.Remote, + fx.Members, + fx.MgmtProbe, + fx.OOBProbe, + fx.Uplink, + fx.Ranges, + fx.RouterNAT, + fx.Northbound, + ) + return fx +} + +func writeOVNConfig(t *testing.T, dir, root string, fx ovnFixture) string { + t.Helper() + var b strings.Builder + fmt.Fprintf(&b, "incus:\n remote: %q\n pool: data\n", fx.Remote) + if fx.Uplink != "" { + fmt.Fprintf(&b, " ovn_uplink: %q\n", fx.Uplink) + } + if fx.Ranges != "" { + fmt.Fprintf(&b, " ovn_ranges: %q\n", fx.Ranges) + } + b.WriteString("sandbox:\n default_network_kind: ovn\n default_ttl_minutes: 30\n") + fmt.Fprintf(&b, "images_file: %q\n", filepath.Join(root, "images", "catalog.yaml")) + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(b.String()), 0o600)) + return path +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func csvEnv(key, fallback string) []string { + var out []string + for _, part := range strings.Split(envOr(key, fallback), ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func splitProbe(t *testing.T, value string) (string, string) { + t.Helper() + host, port, err := net.SplitHostPort(value) + if err != nil { + if ip := net.ParseIP(value); ip != nil { + return value, "22" + } + require.NoError(t, err) + } + return host, port +} + +func execSucceeded(t *testing.T, result map[string]any) bool { + t.Helper() + return jsonInt(t, result["exit_code"]) == 0 && !jsonBool(t, result["timed_out"]) +} + +func firstGlobalIPv4(t *testing.T, instance map[string]any) string { + t.Helper() + for _, raw := range asSlice(t, instance["nics"]) { + nic := asMap(t, raw) + addrs, _ := nic["addresses"].([]any) + for _, addr := range addrs { + s, _ := addr.(string) + if ip := globalIPv4(s); ip != "" { + return ip + } + } + } + t.Fatalf("no global IPv4 on instance: %#v", instance) + return "" +} + +func nicIPv4(t *testing.T, instance map[string]any, nicName string) string { + t.Helper() + for _, raw := range asSlice(t, instance["nics"]) { + nic := asMap(t, raw) + if asString(t, nic["name"]) != nicName { + continue + } + addrs, _ := nic["addresses"].([]any) + for _, addr := range addrs { + s, _ := addr.(string) + if ip := globalIPv4(s); ip != "" { + return ip + } + } + } + t.Fatalf("no global IPv4 on nic %s: %#v", nicName, instance) + return "" +} + +func nicNetworks(t *testing.T, instance map[string]any) []string { + t.Helper() + var out []string + for _, raw := range asSlice(t, instance["nics"]) { + out = append(out, asString(t, asMap(t, raw)["network"])) + } + return out +} + +func hasNIC(t *testing.T, instance map[string]any, nicName string) bool { + t.Helper() + for _, raw := range asSlice(t, instance["nics"]) { + if asString(t, asMap(t, raw)["name"]) == nicName { + return true + } + } + return false +} + +func globalIPv4(raw string) string { + host, _, ok := strings.Cut(raw, "/") + if ok { + raw = host + } + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return "" + } + return ip.To4().String() +} + +func pingAvgMS(t *testing.T, stdout string) float64 { + t.Helper() + summary := regexp.MustCompile(`(?m)(?:rtt|round-trip) min/avg/max(?:/mdev)? = [0-9.]+/([0-9.]+)/`) + if match := summary.FindStringSubmatch(stdout); len(match) == 2 { + avg, err := strconv.ParseFloat(match[1], 64) + require.NoError(t, err, "parse ping avg from %q", stdout) + return avg + } + times := regexp.MustCompile(`time[=<]([0-9.]+) ms`).FindAllStringSubmatch(stdout, -1) + require.NotEmpty(t, times, "ping RTT not found in %q", stdout) + var sum float64 + for _, match := range times { + value, err := strconv.ParseFloat(match[1], 64) + require.NoError(t, err, "parse ping time from %q", stdout) + sum += value + } + return sum / float64(len(times)) +} + +func assertNoSandboxResidue( + t *testing.T, + ctx context.Context, + backend *incus.Client, + sandbox string, + physical, members []string, + forwardAddress string, +) { + t.Helper() + _, err := backend.GetSandbox(ctx, sandbox) + require.Error(t, err) + listed, err := backend.ListSandboxes(ctx) + require.NoError(t, err) + for _, item := range listed { + assert.NotEqual(t, sandbox, item.Name) + } + + project := "ac-" + sandbox + root := backend.Scoped(ctx, api.ProjectDefaultName, "") + _, _, err = root.GetProject(project) + require.Error(t, err) + projects, err := root.GetProjectNames() + require.NoError(t, err) + assert.NotContains(t, projects, project) + + instances, err := root.GetInstancesAllProjects(api.InstanceTypeAny) + require.NoError(t, err) + for _, instance := range instances { + assert.NotEqual(t, project, instance.Project, "instance %s remained", instance.Name) + } + + images, err := root.GetImagesAllProjects() + require.NoError(t, err) + for _, image := range images { + assert.NotEqual(t, project, image.Project, "image remained in deleted project") + } + + networks, err := root.GetNetworksAllProjects() + require.NoError(t, err) + for _, network := range networks { + assert.NotEqual(t, project, network.Project, "network %s remained", network.Name) + if network.Config["user.agentcompute.sandbox"] == sandbox { + t.Errorf("network %s still tagged for sandbox %s", network.Name, sandbox) + } + for _, name := range physical { + assert.NotEqual(t, name, network.Name, "physical network remained") + } + if forwardAddress != "" && network.Project == api.ProjectDefaultName { + forwards, fwdErr := root.GetNetworkForwards(network.Name) + if fwdErr != nil { + continue + } + for _, forward := range forwards { + assert.NotEqual(t, forwardAddress, forward.ListenAddress) + } + } + } + + acls, err := root.GetNetworkACLsAllProjects() + require.NoError(t, err) + for _, acl := range acls { + assert.NotEqual(t, project, acl.Project, "ACL %s remained", acl.Name) + } + + profiles, err := root.GetProfilesAllProjects() + require.NoError(t, err) + for _, profile := range profiles { + assert.NotEqual(t, project, profile.Project, "profile %s remained", profile.Name) + } + + targets := append([]string{""}, members...) + for _, member := range targets { + nets, netErr := backend.Scoped(ctx, api.ProjectDefaultName, member).GetNetworks() + require.NoError(t, netErr) + for _, network := range nets { + for _, name := range physical { + assert.NotEqual(t, name, network.Name, "bridge remained on %s", member) + } + } + } +} diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index 3f94415..0ddb04c 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -51,6 +51,8 @@ type incusConfig struct { ServerCert string `yaml:"server_cert" toml:"server_cert"` Host string `yaml:"host" toml:"host"` Pool string `yaml:"pool" toml:"pool"` + OVNUplink string `yaml:"ovn_uplink" toml:"ovn_uplink"` + OVNRanges string `yaml:"ovn_ranges" toml:"ovn_ranges"` } type sandboxConfig struct { @@ -69,61 +71,89 @@ func loadRuntimeConfig(path string) (runtimeConfig, error) { Sandbox: sandboxConfig{ DefaultTTLMinutes: defaultTTLMinutes, MaxTTLMinutes: maxTTLMinutes, - DefaultNetworkKind: "bridge", + DefaultNetworkKind: "ovn", }, ImagesFile: "images/catalog.yaml", } if path == "" { return cfg, errors.New("configuration is required: use --config or AGENTCOMPUTE_CONFIG") } + if err := decodeRuntimeConfig(path, &cfg); err != nil { + return cfg, err + } + if err := validateRuntimeConfig(cfg); err != nil { + return cfg, err + } + resolveRuntimePaths(path, &cfg) + return cfg, nil +} + +func decodeRuntimeConfig(path string, cfg *runtimeConfig) error { file, err := os.Open(path) if err != nil { - return cfg, fmt.Errorf("open configuration: %w", err) + return fmt.Errorf("open configuration: %w", err) } defer file.Close() switch strings.ToLower(filepath.Ext(path)) { case ".toml": - err = toml.NewDecoder(file).DisallowUnknownFields().Decode(&cfg) + err = toml.NewDecoder(file).DisallowUnknownFields().Decode(cfg) case yamlExtension, ".yml": - decoder := yaml.NewDecoder(file) - decoder.KnownFields(true) - err = decoder.Decode(&cfg) - if err == nil { - var extra any - if next := decoder.Decode(&extra); !errors.Is(next, io.EOF) { - err = errors.New("configuration must contain exactly one YAML document") - } - } + err = decodeYAMLConfig(file, cfg) default: err = errors.New("configuration must be a .yaml, .yml, or .toml file") } if err != nil { - return cfg, fmt.Errorf("decode configuration: %w", err) + return fmt.Errorf("decode configuration: %w", err) + } + return nil +} + +func decodeYAMLConfig(r io.Reader, cfg *runtimeConfig) error { + decoder := yaml.NewDecoder(r) + decoder.KnownFields(true) + if err := decoder.Decode(cfg); err != nil { + return err + } + var extra any + if next := decoder.Decode(&extra); !errors.Is(next, io.EOF) { + return errors.New("configuration must contain exactly one YAML document") } - if cfg.Incus.Host == "" || cfg.Incus.Pool == "" { - return cfg, errors.New("incus.host and incus.pool are required") + return nil +} + +func validateRuntimeConfig(cfg runtimeConfig) error { + if cfg.Incus.Pool == "" { + return errors.New("incus.pool is required") } if (cfg.Incus.Remote == "") == (cfg.Incus.URL == "") { - return cfg, errors.New("configure exactly one of incus.remote and incus.url") + return errors.New("configure exactly one of incus.remote and incus.url") } - if cfg.Sandbox.DefaultNetworkKind != "bridge" { - return cfg, errors.New("sandbox.default_network_kind must be bridge; OVN is not available yet") + if cfg.Sandbox.DefaultNetworkKind != "bridge" && cfg.Sandbox.DefaultNetworkKind != "ovn" { + return errors.New("sandbox.default_network_kind must be bridge or ovn") + } + if cfg.Sandbox.DefaultNetworkKind == "bridge" && cfg.Incus.Host == "" { + return errors.New("incus.host is required for bridge sandboxes") } const maxDurationMinutes = int64((1<<63 - 1) / time.Minute) if cfg.Sandbox.DefaultTTLMinutes <= 0 || cfg.Sandbox.MaxTTLMinutes < cfg.Sandbox.DefaultTTLMinutes || cfg.Sandbox.MaxTTLMinutes > maxDurationMinutes { - return cfg, errors.New("sandbox TTL minutes must be positive, representable durations with default <= maximum") + return errors.New("sandbox TTL minutes must be positive, representable durations with default <= maximum") } if cfg.ImagesFile == "" { - return cfg, errors.New("images_file must not be empty") + return errors.New("images_file must not be empty") } + return nil +} + +func resolveRuntimePaths(path string, cfg *runtimeConfig) { base := filepath.Dir(path) - for _, value := range []*string{&cfg.ImagesFile, &cfg.Incus.ClientCert, &cfg.Incus.ClientKey, &cfg.Incus.ServerCert, &cfg.Screenshots.Dir} { + for _, value := range []*string{ + &cfg.ImagesFile, &cfg.Incus.ClientCert, &cfg.Incus.ClientKey, &cfg.Incus.ServerCert, &cfg.Screenshots.Dir, + } { if *value != "" && !filepath.IsAbs(*value) { *value = filepath.Join(base, *value) } } - return cfg, nil } func newRuntime(ctx context.Context, path string, logger *slog.Logger) (*runtime, error) { @@ -139,6 +169,7 @@ func newRuntime(ctx context.Context, path string, logger *slog.Logger) (*runtime Remote: cfg.Incus.Remote, URL: cfg.Incus.URL, ClientCert: cfg.Incus.ClientCert, ClientKey: cfg.Incus.ClientKey, ServerCert: cfg.Incus.ServerCert, Host: cfg.Incus.Host, Pool: cfg.Incus.Pool, + OVNUplink: cfg.Incus.OVNUplink, OVNRanges: cfg.Incus.OVNRanges, }) if err != nil { return nil, err @@ -152,10 +183,11 @@ func newRuntime(ctx context.Context, path string, logger *slog.Logger) (*runtime return nil, errors.Join(err, client.Close()) } service, err := compute.New(client, catalog, compute.Options{ - Host: cfg.Incus.Host, - DefaultTTL: time.Duration(cfg.Sandbox.DefaultTTLMinutes) * time.Minute, - MaxTTL: time.Duration(cfg.Sandbox.MaxTTLMinutes) * time.Minute, - Logger: logger, + Host: cfg.Incus.Host, + DefaultNetworkKind: cfg.Sandbox.DefaultNetworkKind, + DefaultTTL: time.Duration(cfg.Sandbox.DefaultTTLMinutes) * time.Minute, + MaxTTL: time.Duration(cfg.Sandbox.MaxTTLMinutes) * time.Minute, + Logger: logger, }) if err != nil { return nil, errors.Join(err, client.Close()) diff --git a/internal/compute/helpers_test.go b/internal/compute/helpers_test.go index ff01c03..81af0e1 100644 --- a/internal/compute/helpers_test.go +++ b/internal/compute/helpers_test.go @@ -54,10 +54,10 @@ func liveSandbox(name string) compute.Sandbox { } } -func expiredSandbox(name string) compute.Sandbox { +func expiredSandbox() compute.Sandbox { now := time.Now() return compute.Sandbox{ - Name: name, + Name: "demo", Platform: "incus", Host: "lab01", CreatedAt: now.Add(-2 * time.Hour), diff --git a/internal/compute/lifecycle.go b/internal/compute/lifecycle.go new file mode 100644 index 0000000..a9fcda5 --- /dev/null +++ b/internal/compute/lifecycle.go @@ -0,0 +1,402 @@ +package compute + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/meigma/codemode" +) + +const ( + // WaitUntilRunning means the guest has reached Running or Ready. + WaitUntilRunning = "running" + // WaitUntilAgent means the Incus agent can serve exec and files. + WaitUntilAgent = "agent" + // WaitUntilNetwork means at least one NIC has a non-link address. + WaitUntilNetwork = "network" + // WaitUntilDesktop is reserved for Phase 6. + WaitUntilDesktop = "desktop" + // WaitUntilStopped means the guest has reached Stopped. + WaitUntilStopped = "stopped" + + fileReadDefault = 64 * 1024 + fileReadMax = 1024 * 1024 + fileWriteMax = 64 * 1024 + fileModeBits = 32 +) + +// WaitRequest is a bounded readiness poll. +type WaitRequest struct { + // Ref identifies the guest. + Ref Ref + // Until is running, agent, network, desktop, or stopped. + Until string + // Timeout is the wait-only budget; request cancellation still wins. + Timeout time.Duration +} + +// WaitResult is the observed state after a wait. +type WaitResult struct { + // Status is the observed Incus state. + Status string + // Elapsed is time spent waiting. + Elapsed time.Duration +} + +// FileReadRequest is a bounded guest file read. +type FileReadRequest struct { + // Ref identifies the guest. + Ref Ref + // Path is an absolute guest path. + Path string + // MaxBytes is the read cap; zero selects 64 KiB. + MaxBytes int64 +} + +// FileReadResult is truncated text content. +type FileReadResult struct { + // Content contains at most MaxBytes of file data. + Content string + // Truncated reports discarded trailing bytes. + Truncated bool +} + +// FileWriteRequest is a bounded guest file write. +type FileWriteRequest struct { + // Ref identifies the guest. + Ref Ref + // Path is an absolute guest path. + Path string + // Content is the exact text to write. + Content string + // Mode is an optional octal mode such as 0644. + Mode string +} + +// FileWriteResult reports how many bytes were written. +type FileWriteResult struct { + // Bytes is the number of content bytes written. + Bytes int64 +} + +// Snapshot is a named instance snapshot. +type Snapshot struct { + // Name is the agent-facing snapshot name. + Name string + // CreatedAt is the snapshot creation time. + CreatedAt time.Time +} + +// StartInstance starts a guest and blocks until it is running. +func (s *Service) StartInstance(ctx context.Context, ref Ref, force bool) (Instance, error) { + return s.changeInstance(ctx, ref, func() (Instance, error) { + return s.backend.StartInstance(ctx, ref, force) + }, "start instance") +} + +// StopInstance stops a guest and blocks until it is stopped. +func (s *Service) StopInstance(ctx context.Context, ref Ref, force bool) (Instance, error) { + return s.changeInstance(ctx, ref, func() (Instance, error) { + return s.backend.StopInstance(ctx, ref, force) + }, "stop instance") +} + +// RestartInstance restarts a guest and blocks until it is running. +func (s *Service) RestartInstance(ctx context.Context, ref Ref, force bool) (Instance, error) { + return s.changeInstance(ctx, ref, func() (Instance, error) { + return s.backend.RestartInstance(ctx, ref, force) + }, "restart instance") +} + +// WaitInstance polls until a readiness stage or the wait budget expires. +func (s *Service) WaitInstance(ctx context.Context, req WaitRequest) (WaitResult, error) { + if err := validateRef(req.Ref); err != nil { + return WaitResult{}, err + } + if err := validateWaitUntil(req.Until); err != nil { + return WaitResult{}, err + } + waitCtx, cancel := execContext(ctx, req.Timeout) + defer cancel() + started := time.Now() + result, err := s.backend.WaitInstance(waitCtx, req) + result.Elapsed = time.Since(started) + if err == nil { + return result, nil + } + if errors.Is(waitCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil { + return result, agentErrorf( + "instance %q in sandbox %q did not become %s", + req.Ref.Name, + req.Ref.Sandbox, + req.Until, + ) + } + if errors.Is(err, ErrNotFound) { + return result, instanceNotFound(req.Ref) + } + if ctx.Err() != nil { + return result, ctx.Err() + } + return result, s.mapBackend(ctx, "wait instance", err) +} + +// ReadFile returns a bounded guest file. +func (s *Service) ReadFile(ctx context.Context, req FileReadRequest) (FileReadResult, error) { + if err := validateRef(req.Ref); err != nil { + return FileReadResult{}, err + } + if err := validateFilePath(req.Path); err != nil { + return FileReadResult{}, err + } + req.MaxBytes = clampReadLimit(req.MaxBytes) + result, err := s.backend.ReadFile(ctx, req) + if err != nil { + if errors.Is(err, ErrNotFound) { + return FileReadResult{}, instanceNotFound(req.Ref) + } + return FileReadResult{}, s.mapBackend(ctx, "read file", err) + } + if int64(len(result.Content)) > req.MaxBytes { + result.Content = result.Content[:req.MaxBytes] + result.Truncated = true + } + return result, nil +} + +// WriteFile writes a bounded guest file. +func (s *Service) WriteFile(ctx context.Context, req FileWriteRequest) (FileWriteResult, error) { + if err := validateRef(req.Ref); err != nil { + return FileWriteResult{}, err + } + if err := validateFilePath(req.Path); err != nil { + return FileWriteResult{}, err + } + if err := validateFileMode(req.Mode); err != nil { + return FileWriteResult{}, err + } + if len(req.Content) > fileWriteMax { + return FileWriteResult{}, agentErrorf( + "file content exceeds %d bytes; use instance.exec to transfer larger files", + fileWriteMax, + ) + } + result, err := s.backend.WriteFile(ctx, req) + if err != nil { + if errors.Is(err, ErrNotFound) { + return FileWriteResult{}, instanceNotFound(req.Ref) + } + return FileWriteResult{}, s.mapBackend(ctx, "write file", err) + } + return result, nil +} + +// CreateSnapshot creates a named instance snapshot. +func (s *Service) CreateSnapshot(ctx context.Context, ref Ref, snapshot string) error { + if err := validateRef(ref); err != nil { + return err + } + if err := validateName(snapshot); err != nil { + return err + } + return s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + if err := s.backend.CreateSnapshot(ctx, ref, snapshot); err != nil { + if errors.Is(err, ErrNotFound) { + return instanceNotFound(ref) + } + return s.mapBackend(ctx, "create snapshot", err) + } + return nil + }) +} + +// RestoreSnapshot restores a guest from a snapshot. +func (s *Service) RestoreSnapshot(ctx context.Context, ref Ref, snapshot string) error { + if err := validateRef(ref); err != nil { + return err + } + if err := validateName(snapshot); err != nil { + return err + } + return s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + if err := s.backend.RestoreSnapshot(ctx, ref, snapshot); err != nil { + if errors.Is(err, ErrNotFound) { + return instanceNotFound(ref) + } + return s.mapBackend(ctx, "restore snapshot", err) + } + return nil + }) +} + +// DeleteSnapshot deletes a named instance snapshot. +func (s *Service) DeleteSnapshot(ctx context.Context, ref Ref, snapshot string) error { + if err := validateRef(ref); err != nil { + return err + } + if err := validateName(snapshot); err != nil { + return err + } + return s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + if err := s.backend.DeleteSnapshot(ctx, ref, snapshot); err != nil { + if errors.Is(err, ErrNotFound) { + return instanceNotFound(ref) + } + return s.mapBackend(ctx, "delete snapshot", err) + } + return nil + }) +} + +// ListSnapshots returns snapshots for one guest. +func (s *Service) ListSnapshots(ctx context.Context, ref Ref) ([]Snapshot, error) { + if err := validateRef(ref); err != nil { + return nil, err + } + if _, err := s.GetInstance(ctx, ref); err != nil { + return nil, err + } + snapshots, err := s.backend.ListSnapshots(ctx, ref) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, instanceNotFound(ref) + } + return nil, s.mapBackend(ctx, "list snapshots", err) + } + return nonNil(snapshots), nil +} + +// PublishInstance publishes a sandbox-scoped image from a guest. +func (s *Service) PublishInstance(ctx context.Context, ref Ref, image string) (string, error) { + if err := validateRef(ref); err != nil { + return "", err + } + if err := validateName(image); err != nil { + return "", err + } + if _, ok := s.catalog.Lookup(image); ok { + return "", agentErrorf("image %q is reserved by the catalog", image) + } + var name string + err := s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + published, pubErr := s.backend.PublishInstance(ctx, ref, image) + if pubErr != nil { + if errors.Is(pubErr, ErrNotFound) { + return instanceNotFound(ref) + } + return s.mapBackend(ctx, "publish instance", pubErr) + } + name = published + return nil + }) + if err != nil { + return "", err + } + return name, nil +} + +// ResolveImage returns a curated catalog entry or a sandbox-published image. +func (s *Service) ResolveImage(ctx context.Context, sandbox, name string) (CatalogImage, error) { + if name == "" { + return CatalogImage{}, imageNotFound(name) + } + if image, ok := s.catalog.Lookup(name); ok { + return image, nil + } + if err := validateName(sandbox); err != nil { + return CatalogImage{}, err + } + if _, err := s.backend.GetSandbox(ctx, sandbox); err != nil { + if errors.Is(err, ErrNotFound) { + return CatalogImage{}, sandboxNotFound(sandbox) + } + return CatalogImage{}, s.mapBackend(ctx, "get sandbox", err) + } + image, err := s.backend.GetSandboxImage(ctx, sandbox, name) + if err != nil { + if errors.Is(err, ErrNotFound) { + return CatalogImage{}, imageNotFound(name) + } + return CatalogImage{}, s.mapBackend(ctx, "get sandbox image", err) + } + return image, nil +} + +func (s *Service) changeInstance( + ctx context.Context, + ref Ref, + fn func() (Instance, error), + op string, +) (Instance, error) { + if err := validateRef(ref); err != nil { + return Instance{}, err + } + var inst Instance + err := s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + var changeErr error + inst, changeErr = fn() + if changeErr != nil { + if errors.Is(changeErr, ErrNotFound) { + return instanceNotFound(ref) + } + return s.mapBackend(ctx, op, changeErr) + } + return nil + }) + if err != nil { + return Instance{}, err + } + return inst, nil +} + +func (s *Service) mapBackend(ctx context.Context, op string, err error) error { + if err == nil { + return nil + } + var agent *codemode.AgentError + if errors.As(err, &agent) { + return err + } + return s.backendError(ctx, op, err) +} + +func validateWaitUntil(until string) error { + switch until { + case WaitUntilRunning, WaitUntilAgent, WaitUntilNetwork, WaitUntilStopped: + return nil + case WaitUntilDesktop: + return agentErrorf("until %q is not available yet", until) + default: + return agentErrorf("until %q is not available yet", until) + } +} + +func validateFilePath(path string) error { + if path == "" || !strings.HasPrefix(path, "/") { + return agentError("path must be an absolute path") + } + return nil +} + +func validateFileMode(mode string) error { + if mode == "" { + return nil + } + if _, err := strconv.ParseUint(mode, 8, fileModeBits); err != nil { + return agentErrorf("mode %q is not an octal file mode", mode) + } + return nil +} + +func clampReadLimit(maxBytes int64) int64 { + if maxBytes <= 0 { + return fileReadDefault + } + if maxBytes > fileReadMax { + return fileReadMax + } + return maxBytes +} diff --git a/internal/compute/lifecycle_test.go b/internal/compute/lifecycle_test.go new file mode 100644 index 0000000..e739ef6 --- /dev/null +++ b/internal/compute/lifecycle_test.go @@ -0,0 +1,183 @@ +package compute_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/meigma/codemode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +func TestWaitInstanceRejectsDesktopAndUnknownUntil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + until string + }{ + {name: "desktop is reserved", until: compute.WaitUntilDesktop}, + {name: "unknown stage", until: "booted"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tc := newTestContext(t) + _, err := tc.service.WaitInstance(t.Context(), compute.WaitRequest{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, + Until: tt.until, + }) + requireAgentContains(t, err, "not available yet") + }) + } +} + +func TestWriteFileRejectsOversizedContentAndRelativePath(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + ref := compute.Ref{Sandbox: "demo", Name: "web"} + + _, err := tc.service.WriteFile(t.Context(), compute.FileWriteRequest{ + Ref: ref, + Path: "relative.txt", + Content: "hi", + }) + requireAgentContains(t, err, "absolute path") + + _, err = tc.service.WriteFile(t.Context(), compute.FileWriteRequest{ + Ref: ref, + Path: "/tmp/out", + Content: strings.Repeat("a", 64*1024+1), + }) + requireAgentContains(t, err, "exceeds") +} + +func TestWriteFileRejectsInvalidMode(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + _, err := tc.service.WriteFile(t.Context(), compute.FileWriteRequest{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, + Path: "/tmp/out", + Content: "hi", + Mode: "rwx", + }) + requireAgentContains(t, err, "octal") +} + +func TestPublishInstanceRejectsCatalogName(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + _, err := tc.service.PublishInstance(t.Context(), compute.Ref{Sandbox: "demo", Name: "web"}, "router") + requireAgentContains(t, err, "reserved") +} + +func TestStartInstanceRejectsExpiredSandbox(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(expiredSandbox(), nil) + _, err := tc.service.StartInstance(t.Context(), compute.Ref{Sandbox: "demo", Name: "web"}, false) + requireAgentContains(t, err, "expired") +} + +func TestReadFileTruncatesBackendOverflow(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + ref := compute.Ref{Sandbox: "demo", Name: "web"} + tc.backend.EXPECT().ReadFile(mock.Anything, mock.Anything).Return(compute.FileReadResult{ + Content: strings.Repeat("x", 100), + Truncated: false, + }, nil) + + result, err := tc.service.ReadFile(t.Context(), compute.FileReadRequest{ + Ref: ref, + Path: "/etc/hostname", + MaxBytes: 8, + }) + require.NoError(t, err) + assert.Equal(t, "xxxxxxxx", result.Content) + assert.True(t, result.Truncated) +} + +func TestWaitInstanceTimesOutWithActionableError(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + ref := compute.Ref{Sandbox: "demo", Name: "web"} + tc.backend.EXPECT().WaitInstance(mock.Anything, mock.Anything). + RunAndReturn(func(ctx context.Context, _ compute.WaitRequest) (compute.WaitResult, error) { + <-ctx.Done() + return compute.WaitResult{Status: "Stopped"}, ctx.Err() + }) + + _, err := tc.service.WaitInstance(t.Context(), compute.WaitRequest{ + Ref: ref, + Until: compute.WaitUntilRunning, + Timeout: 20 * time.Millisecond, + }) + requireAgentContains(t, err, "did not become") +} + +func TestWaitInstanceDoesNotHoldGate(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + ref := compute.Ref{Sandbox: "demo", Name: "web"} + waitStarted := make(chan struct{}) + releaseWait := make(chan struct{}) + live := liveSandbox("demo") + + tc.backend.EXPECT().WaitInstance(mock.Anything, mock.Anything). + RunAndReturn(func(context.Context, compute.WaitRequest) (compute.WaitResult, error) { + close(waitStarted) + <-releaseWait + return compute.WaitResult{Status: "Running"}, nil + }) + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(live, nil) + tc.backend.EXPECT().ExtendSandbox(mock.Anything, "demo", mock.AnythingOfType("time.Time")). + Return(live, nil) + + errCh := make(chan error, 1) + waitCtx, cancelWait := context.WithCancel(t.Context()) + defer cancelWait() + defer func() { + close(releaseWait) + assert.NoError(t, <-errCh) + }() + go func() { + _, err := tc.service.WaitInstance(waitCtx, compute.WaitRequest{ + Ref: ref, + Until: compute.WaitUntilRunning, + }) + errCh <- err + }() + + select { + case <-waitStarted: + case <-time.After(2 * time.Second): + t.Fatal("wait did not start; gate likely held") + } + + extendCtx, cancelExtend := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelExtend() + _, err := tc.service.ExtendSandbox(extendCtx, "demo", time.Hour) + require.NoError(t, err) +} + +func requireAgentContains(t *testing.T, err error, substr string) { + t.Helper() + require.Error(t, err) + var agent *codemode.AgentError + require.ErrorAs(t, err, &agent) + assert.Contains(t, agent.Message, substr) +} diff --git a/internal/compute/mocks/mock_backend.go b/internal/compute/mocks/mock_backend.go index f35d71c..04fbcf7 100644 --- a/internal/compute/mocks/mock_backend.go +++ b/internal/compute/mocks/mock_backend.go @@ -41,6 +41,84 @@ func (_m *MockBackend) EXPECT() *MockBackend_Expecter { return &MockBackend_Expecter{mock: &_m.Mock} } +// AddACLRule provides a mock function for the type MockBackend +func (_mock *MockBackend) AddACLRule(context1 context.Context, s string, s1 string, aCLRule compute.ACLRule) (compute.ACLRule, error) { + ret := _mock.Called(context1, s, s1, aCLRule) + + if len(ret) == 0 { + panic("no return value specified for AddACLRule") + } + + var r0 compute.ACLRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.ACLRule) (compute.ACLRule, error)); ok { + return returnFunc(context1, s, s1, aCLRule) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.ACLRule) compute.ACLRule); ok { + r0 = returnFunc(context1, s, s1, aCLRule) + } else { + r0 = ret.Get(0).(compute.ACLRule) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, compute.ACLRule) error); ok { + r1 = returnFunc(context1, s, s1, aCLRule) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_AddACLRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddACLRule' +type MockBackend_AddACLRule_Call struct { + *mock.Call +} + +// AddACLRule is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +// - aCLRule compute.ACLRule +func (_e *MockBackend_Expecter) AddACLRule(context1 interface{}, s interface{}, s1 interface{}, aCLRule interface{}) *MockBackend_AddACLRule_Call { + return &MockBackend_AddACLRule_Call{Call: _e.mock.On("AddACLRule", context1, s, s1, aCLRule)} +} + +func (_c *MockBackend_AddACLRule_Call) Run(run func(context1 context.Context, s string, s1 string, aCLRule compute.ACLRule)) *MockBackend_AddACLRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 compute.ACLRule + if args[3] != nil { + arg3 = args[3].(compute.ACLRule) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockBackend_AddACLRule_Call) Return(aCLRule1 compute.ACLRule, err error) *MockBackend_AddACLRule_Call { + _c.Call.Return(aCLRule1, err) + return _c +} + +func (_c *MockBackend_AddACLRule_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string, aCLRule compute.ACLRule) (compute.ACLRule, error)) *MockBackend_AddACLRule_Call { + _c.Call.Return(run) + return _c +} + // AttachNIC provides a mock function for the type MockBackend func (_mock *MockBackend) AttachNIC(context1 context.Context, ref compute.Ref, s string, s1 string, s2 string, s3 string) (compute.NIC, error) { ret := _mock.Called(context1, ref, s, s1, s2, s3) @@ -79,7 +157,7 @@ type MockBackend_AttachNIC_Call struct { // - s1 string // - s2 string // - s3 string -func (_e *MockBackend_Expecter) AttachNIC(context1 any, ref any, s any, s1 any, s2 any, s3 any) *MockBackend_AttachNIC_Call { +func (_e *MockBackend_Expecter) AttachNIC(context1 interface{}, ref interface{}, s interface{}, s1 interface{}, s2 interface{}, s3 interface{}) *MockBackend_AttachNIC_Call { return &MockBackend_AttachNIC_Call{Call: _e.mock.On("AttachNIC", context1, ref, s, s1, s2, s3)} } @@ -167,7 +245,7 @@ type MockBackend_BeginCreateInstance_Call struct { // BeginCreateInstance is a helper method to define mock.On call // - context1 context.Context // - createInstance compute.CreateInstance -func (_e *MockBackend_Expecter) BeginCreateInstance(context1 any, createInstance any) *MockBackend_BeginCreateInstance_Call { +func (_e *MockBackend_Expecter) BeginCreateInstance(context1 interface{}, createInstance interface{}) *MockBackend_BeginCreateInstance_Call { return &MockBackend_BeginCreateInstance_Call{Call: _e.mock.On("BeginCreateInstance", context1, createInstance)} } @@ -199,6 +277,102 @@ func (_c *MockBackend_BeginCreateInstance_Call) RunAndReturn(run func(context1 c return _c } +// CreateForward provides a mock function for the type MockBackend +func (_mock *MockBackend) CreateForward(context1 context.Context, s string, s1 string, ref compute.Ref, n int64, n1 int64, s2 string) (compute.Forward, error) { + ret := _mock.Called(context1, s, s1, ref, n, n1, s2) + + if len(ret) == 0 { + panic("no return value specified for CreateForward") + } + + var r0 compute.Forward + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.Ref, int64, int64, string) (compute.Forward, error)); ok { + return returnFunc(context1, s, s1, ref, n, n1, s2) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.Ref, int64, int64, string) compute.Forward); ok { + r0 = returnFunc(context1, s, s1, ref, n, n1, s2) + } else { + r0 = ret.Get(0).(compute.Forward) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, compute.Ref, int64, int64, string) error); ok { + r1 = returnFunc(context1, s, s1, ref, n, n1, s2) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_CreateForward_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateForward' +type MockBackend_CreateForward_Call struct { + *mock.Call +} + +// CreateForward is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +// - ref compute.Ref +// - n int64 +// - n1 int64 +// - s2 string +func (_e *MockBackend_Expecter) CreateForward(context1 interface{}, s interface{}, s1 interface{}, ref interface{}, n interface{}, n1 interface{}, s2 interface{}) *MockBackend_CreateForward_Call { + return &MockBackend_CreateForward_Call{Call: _e.mock.On("CreateForward", context1, s, s1, ref, n, n1, s2)} +} + +func (_c *MockBackend_CreateForward_Call) Run(run func(context1 context.Context, s string, s1 string, ref compute.Ref, n int64, n1 int64, s2 string)) *MockBackend_CreateForward_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 compute.Ref + if args[3] != nil { + arg3 = args[3].(compute.Ref) + } + var arg4 int64 + if args[4] != nil { + arg4 = args[4].(int64) + } + var arg5 int64 + if args[5] != nil { + arg5 = args[5].(int64) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + ) + }) + return _c +} + +func (_c *MockBackend_CreateForward_Call) Return(forward compute.Forward, err error) *MockBackend_CreateForward_Call { + _c.Call.Return(forward, err) + return _c +} + +func (_c *MockBackend_CreateForward_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string, ref compute.Ref, n int64, n1 int64, s2 string) (compute.Forward, error)) *MockBackend_CreateForward_Call { + _c.Call.Return(run) + return _c +} + // CreateNetwork provides a mock function for the type MockBackend func (_mock *MockBackend) CreateNetwork(context1 context.Context, s string, network compute.Network) (compute.Network, error) { ret := _mock.Called(context1, s, network) @@ -234,7 +408,7 @@ type MockBackend_CreateNetwork_Call struct { // - context1 context.Context // - s string // - network compute.Network -func (_e *MockBackend_Expecter) CreateNetwork(context1 any, s any, network any) *MockBackend_CreateNetwork_Call { +func (_e *MockBackend_Expecter) CreateNetwork(context1 interface{}, s interface{}, network interface{}) *MockBackend_CreateNetwork_Call { return &MockBackend_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", context1, s, network)} } @@ -296,7 +470,7 @@ type MockBackend_CreateSandbox_Call struct { // CreateSandbox is a helper method to define mock.On call // - context1 context.Context // - sandbox compute.Sandbox -func (_e *MockBackend_Expecter) CreateSandbox(context1 any, sandbox any) *MockBackend_CreateSandbox_Call { +func (_e *MockBackend_Expecter) CreateSandbox(context1 interface{}, sandbox interface{}) *MockBackend_CreateSandbox_Call { return &MockBackend_CreateSandbox_Call{Call: _e.mock.On("CreateSandbox", context1, sandbox)} } @@ -328,36 +502,37 @@ func (_c *MockBackend_CreateSandbox_Call) RunAndReturn(run func(context1 context return _c } -// DeleteInstance provides a mock function for the type MockBackend -func (_mock *MockBackend) DeleteInstance(context1 context.Context, ref compute.Ref) error { - ret := _mock.Called(context1, ref) +// CreateSnapshot provides a mock function for the type MockBackend +func (_mock *MockBackend) CreateSnapshot(context1 context.Context, ref compute.Ref, s string) error { + ret := _mock.Called(context1, ref, s) if len(ret) == 0 { - panic("no return value specified for DeleteInstance") + panic("no return value specified for CreateSnapshot") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) error); ok { - r0 = returnFunc(context1, ref) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(context1, ref, s) } else { r0 = ret.Error(0) } return r0 } -// MockBackend_DeleteInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteInstance' -type MockBackend_DeleteInstance_Call struct { +// MockBackend_CreateSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSnapshot' +type MockBackend_CreateSnapshot_Call struct { *mock.Call } -// DeleteInstance is a helper method to define mock.On call +// CreateSnapshot is a helper method to define mock.On call // - context1 context.Context // - ref compute.Ref -func (_e *MockBackend_Expecter) DeleteInstance(context1 any, ref any) *MockBackend_DeleteInstance_Call { - return &MockBackend_DeleteInstance_Call{Call: _e.mock.On("DeleteInstance", context1, ref)} +// - s string +func (_e *MockBackend_Expecter) CreateSnapshot(context1 interface{}, ref interface{}, s interface{}) *MockBackend_CreateSnapshot_Call { + return &MockBackend_CreateSnapshot_Call{Call: _e.mock.On("CreateSnapshot", context1, ref, s)} } -func (_c *MockBackend_DeleteInstance_Call) Run(run func(context1 context.Context, ref compute.Ref)) *MockBackend_DeleteInstance_Call { +func (_c *MockBackend_CreateSnapshot_Call) Run(run func(context1 context.Context, ref compute.Ref, s string)) *MockBackend_CreateSnapshot_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -367,62 +542,67 @@ func (_c *MockBackend_DeleteInstance_Call) Run(run func(context1 context.Context if args[1] != nil { arg1 = args[1].(compute.Ref) } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } run( arg0, arg1, + arg2, ) }) return _c } -func (_c *MockBackend_DeleteInstance_Call) Return(err error) *MockBackend_DeleteInstance_Call { +func (_c *MockBackend_CreateSnapshot_Call) Return(err error) *MockBackend_CreateSnapshot_Call { _c.Call.Return(err) return _c } -func (_c *MockBackend_DeleteInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref) error) *MockBackend_DeleteInstance_Call { +func (_c *MockBackend_CreateSnapshot_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, s string) error) *MockBackend_CreateSnapshot_Call { _c.Call.Return(run) return _c } -// DeleteSandbox provides a mock function for the type MockBackend -func (_mock *MockBackend) DeleteSandbox(context1 context.Context, s string) error { - ret := _mock.Called(context1, s) +// DeleteInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) DeleteInstance(context1 context.Context, ref compute.Ref) error { + ret := _mock.Called(context1, ref) if len(ret) == 0 { - panic("no return value specified for DeleteSandbox") + panic("no return value specified for DeleteInstance") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) error); ok { + r0 = returnFunc(context1, ref) } else { r0 = ret.Error(0) } return r0 } -// MockBackend_DeleteSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSandbox' -type MockBackend_DeleteSandbox_Call struct { +// MockBackend_DeleteInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteInstance' +type MockBackend_DeleteInstance_Call struct { *mock.Call } -// DeleteSandbox is a helper method to define mock.On call +// DeleteInstance is a helper method to define mock.On call // - context1 context.Context -// - s string -func (_e *MockBackend_Expecter) DeleteSandbox(context1 any, s any) *MockBackend_DeleteSandbox_Call { - return &MockBackend_DeleteSandbox_Call{Call: _e.mock.On("DeleteSandbox", context1, s)} +// - ref compute.Ref +func (_e *MockBackend_Expecter) DeleteInstance(context1 interface{}, ref interface{}) *MockBackend_DeleteInstance_Call { + return &MockBackend_DeleteInstance_Call{Call: _e.mock.On("DeleteInstance", context1, ref)} } -func (_c *MockBackend_DeleteSandbox_Call) Run(run func(context1 context.Context, s string)) *MockBackend_DeleteSandbox_Call { +func (_c *MockBackend_DeleteInstance_Call) Run(run func(context1 context.Context, ref compute.Ref)) *MockBackend_DeleteInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 compute.Ref if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(compute.Ref) } run( arg0, @@ -432,134 +612,109 @@ func (_c *MockBackend_DeleteSandbox_Call) Run(run func(context1 context.Context, return _c } -func (_c *MockBackend_DeleteSandbox_Call) Return(err error) *MockBackend_DeleteSandbox_Call { +func (_c *MockBackend_DeleteInstance_Call) Return(err error) *MockBackend_DeleteInstance_Call { _c.Call.Return(err) return _c } -func (_c *MockBackend_DeleteSandbox_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockBackend_DeleteSandbox_Call { +func (_c *MockBackend_DeleteInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref) error) *MockBackend_DeleteInstance_Call { _c.Call.Return(run) return _c } -// Exec provides a mock function for the type MockBackend -func (_mock *MockBackend) Exec(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer) (int64, error) { - ret := _mock.Called(context1, execRequest, writer, writer1) +// DeleteNetwork provides a mock function for the type MockBackend +func (_mock *MockBackend) DeleteNetwork(context1 context.Context, s string, s1 string) error { + ret := _mock.Called(context1, s, s1) if len(ret) == 0 { - panic("no return value specified for Exec") + panic("no return value specified for DeleteNetwork") } - var r0 int64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) (int64, error)); ok { - return returnFunc(context1, execRequest, writer, writer1) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) int64); ok { - r0 = returnFunc(context1, execRequest, writer, writer1) - } else { - r0 = ret.Get(0).(int64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) error); ok { - r1 = returnFunc(context1, execRequest, writer, writer1) + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(context1, s, s1) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// MockBackend_Exec_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Exec' -type MockBackend_Exec_Call struct { +// MockBackend_DeleteNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteNetwork' +type MockBackend_DeleteNetwork_Call struct { *mock.Call } -// Exec is a helper method to define mock.On call +// DeleteNetwork is a helper method to define mock.On call // - context1 context.Context -// - execRequest compute.ExecRequest -// - writer io.Writer -// - writer1 io.Writer -func (_e *MockBackend_Expecter) Exec(context1 any, execRequest any, writer any, writer1 any) *MockBackend_Exec_Call { - return &MockBackend_Exec_Call{Call: _e.mock.On("Exec", context1, execRequest, writer, writer1)} +// - s string +// - s1 string +func (_e *MockBackend_Expecter) DeleteNetwork(context1 interface{}, s interface{}, s1 interface{}) *MockBackend_DeleteNetwork_Call { + return &MockBackend_DeleteNetwork_Call{Call: _e.mock.On("DeleteNetwork", context1, s, s1)} } -func (_c *MockBackend_Exec_Call) Run(run func(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer)) *MockBackend_Exec_Call { +func (_c *MockBackend_DeleteNetwork_Call) Run(run func(context1 context.Context, s string, s1 string)) *MockBackend_DeleteNetwork_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 compute.ExecRequest + var arg1 string if args[1] != nil { - arg1 = args[1].(compute.ExecRequest) + arg1 = args[1].(string) } - var arg2 io.Writer + var arg2 string if args[2] != nil { - arg2 = args[2].(io.Writer) - } - var arg3 io.Writer - if args[3] != nil { - arg3 = args[3].(io.Writer) + arg2 = args[2].(string) } run( arg0, arg1, arg2, - arg3, ) }) return _c } -func (_c *MockBackend_Exec_Call) Return(n int64, err error) *MockBackend_Exec_Call { - _c.Call.Return(n, err) +func (_c *MockBackend_DeleteNetwork_Call) Return(err error) *MockBackend_DeleteNetwork_Call { + _c.Call.Return(err) return _c } -func (_c *MockBackend_Exec_Call) RunAndReturn(run func(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer) (int64, error)) *MockBackend_Exec_Call { +func (_c *MockBackend_DeleteNetwork_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string) error) *MockBackend_DeleteNetwork_Call { _c.Call.Return(run) return _c } -// ExtendSandbox provides a mock function for the type MockBackend -func (_mock *MockBackend) ExtendSandbox(context1 context.Context, s string, time1 time.Time) (compute.Sandbox, error) { - ret := _mock.Called(context1, s, time1) +// DeleteSandbox provides a mock function for the type MockBackend +func (_mock *MockBackend) DeleteSandbox(context1 context.Context, s string) error { + ret := _mock.Called(context1, s) if len(ret) == 0 { - panic("no return value specified for ExtendSandbox") + panic("no return value specified for DeleteSandbox") } - var r0 compute.Sandbox - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (compute.Sandbox, error)); ok { - return returnFunc(context1, s, time1) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) compute.Sandbox); ok { - r0 = returnFunc(context1, s, time1) - } else { - r0 = ret.Get(0).(compute.Sandbox) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { - r1 = returnFunc(context1, s, time1) + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(context1, s) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// MockBackend_ExtendSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendSandbox' -type MockBackend_ExtendSandbox_Call struct { +// MockBackend_DeleteSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSandbox' +type MockBackend_DeleteSandbox_Call struct { *mock.Call } -// ExtendSandbox is a helper method to define mock.On call +// DeleteSandbox is a helper method to define mock.On call // - context1 context.Context // - s string -// - time1 time.Time -func (_e *MockBackend_Expecter) ExtendSandbox(context1 any, s any, time1 any) *MockBackend_ExtendSandbox_Call { - return &MockBackend_ExtendSandbox_Call{Call: _e.mock.On("ExtendSandbox", context1, s, time1)} +func (_e *MockBackend_Expecter) DeleteSandbox(context1 interface{}, s interface{}) *MockBackend_DeleteSandbox_Call { + return &MockBackend_DeleteSandbox_Call{Call: _e.mock.On("DeleteSandbox", context1, s)} } -func (_c *MockBackend_ExtendSandbox_Call) Run(run func(context1 context.Context, s string, time1 time.Time)) *MockBackend_ExtendSandbox_Call { +func (_c *MockBackend_DeleteSandbox_Call) Run(run func(context1 context.Context, s string)) *MockBackend_DeleteSandbox_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -569,8 +724,279 @@ func (_c *MockBackend_ExtendSandbox_Call) Run(run func(context1 context.Context, if args[1] != nil { arg1 = args[1].(string) } - var arg2 time.Time - if args[2] != nil { + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_DeleteSandbox_Call) Return(err error) *MockBackend_DeleteSandbox_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_DeleteSandbox_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockBackend_DeleteSandbox_Call { + _c.Call.Return(run) + return _c +} + +// DeleteSnapshot provides a mock function for the type MockBackend +func (_mock *MockBackend) DeleteSnapshot(context1 context.Context, ref compute.Ref, s string) error { + ret := _mock.Called(context1, ref, s) + + if len(ret) == 0 { + panic("no return value specified for DeleteSnapshot") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(context1, ref, s) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot' +type MockBackend_DeleteSnapshot_Call struct { + *mock.Call +} + +// DeleteSnapshot is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +// - s string +func (_e *MockBackend_Expecter) DeleteSnapshot(context1 interface{}, ref interface{}, s interface{}) *MockBackend_DeleteSnapshot_Call { + return &MockBackend_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", context1, ref, s)} +} + +func (_c *MockBackend_DeleteSnapshot_Call) Run(run func(context1 context.Context, ref compute.Ref, s string)) *MockBackend_DeleteSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_DeleteSnapshot_Call) Return(err error) *MockBackend_DeleteSnapshot_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_DeleteSnapshot_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, s string) error) *MockBackend_DeleteSnapshot_Call { + _c.Call.Return(run) + return _c +} + +// DetachNIC provides a mock function for the type MockBackend +func (_mock *MockBackend) DetachNIC(context1 context.Context, ref compute.Ref, s string) error { + ret := _mock.Called(context1, ref, s) + + if len(ret) == 0 { + panic("no return value specified for DetachNIC") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(context1, ref, s) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_DetachNIC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetachNIC' +type MockBackend_DetachNIC_Call struct { + *mock.Call +} + +// DetachNIC is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +// - s string +func (_e *MockBackend_Expecter) DetachNIC(context1 interface{}, ref interface{}, s interface{}) *MockBackend_DetachNIC_Call { + return &MockBackend_DetachNIC_Call{Call: _e.mock.On("DetachNIC", context1, ref, s)} +} + +func (_c *MockBackend_DetachNIC_Call) Run(run func(context1 context.Context, ref compute.Ref, s string)) *MockBackend_DetachNIC_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_DetachNIC_Call) Return(err error) *MockBackend_DetachNIC_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_DetachNIC_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, s string) error) *MockBackend_DetachNIC_Call { + _c.Call.Return(run) + return _c +} + +// Exec provides a mock function for the type MockBackend +func (_mock *MockBackend) Exec(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer) (int64, error) { + ret := _mock.Called(context1, execRequest, writer, writer1) + + if len(ret) == 0 { + panic("no return value specified for Exec") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) (int64, error)); ok { + return returnFunc(context1, execRequest, writer, writer1) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) int64); ok { + r0 = returnFunc(context1, execRequest, writer, writer1) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.ExecRequest, io.Writer, io.Writer) error); ok { + r1 = returnFunc(context1, execRequest, writer, writer1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_Exec_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Exec' +type MockBackend_Exec_Call struct { + *mock.Call +} + +// Exec is a helper method to define mock.On call +// - context1 context.Context +// - execRequest compute.ExecRequest +// - writer io.Writer +// - writer1 io.Writer +func (_e *MockBackend_Expecter) Exec(context1 interface{}, execRequest interface{}, writer interface{}, writer1 interface{}) *MockBackend_Exec_Call { + return &MockBackend_Exec_Call{Call: _e.mock.On("Exec", context1, execRequest, writer, writer1)} +} + +func (_c *MockBackend_Exec_Call) Run(run func(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer)) *MockBackend_Exec_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.ExecRequest + if args[1] != nil { + arg1 = args[1].(compute.ExecRequest) + } + var arg2 io.Writer + if args[2] != nil { + arg2 = args[2].(io.Writer) + } + var arg3 io.Writer + if args[3] != nil { + arg3 = args[3].(io.Writer) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockBackend_Exec_Call) Return(n int64, err error) *MockBackend_Exec_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockBackend_Exec_Call) RunAndReturn(run func(context1 context.Context, execRequest compute.ExecRequest, writer io.Writer, writer1 io.Writer) (int64, error)) *MockBackend_Exec_Call { + _c.Call.Return(run) + return _c +} + +// ExtendSandbox provides a mock function for the type MockBackend +func (_mock *MockBackend) ExtendSandbox(context1 context.Context, s string, time1 time.Time) (compute.Sandbox, error) { + ret := _mock.Called(context1, s, time1) + + if len(ret) == 0 { + panic("no return value specified for ExtendSandbox") + } + + var r0 compute.Sandbox + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (compute.Sandbox, error)); ok { + return returnFunc(context1, s, time1) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) compute.Sandbox); ok { + r0 = returnFunc(context1, s, time1) + } else { + r0 = ret.Get(0).(compute.Sandbox) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { + r1 = returnFunc(context1, s, time1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ExtendSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendSandbox' +type MockBackend_ExtendSandbox_Call struct { + *mock.Call +} + +// ExtendSandbox is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - time1 time.Time +func (_e *MockBackend_Expecter) ExtendSandbox(context1 interface{}, s interface{}, time1 interface{}) *MockBackend_ExtendSandbox_Call { + return &MockBackend_ExtendSandbox_Call{Call: _e.mock.On("ExtendSandbox", context1, s, time1)} +} + +func (_c *MockBackend_ExtendSandbox_Call) Run(run func(context1 context.Context, s string, time1 time.Time)) *MockBackend_ExtendSandbox_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 time.Time + if args[2] != nil { arg2 = args[2].(time.Time) } run( @@ -582,55 +1008,874 @@ func (_c *MockBackend_ExtendSandbox_Call) Run(run func(context1 context.Context, return _c } -func (_c *MockBackend_ExtendSandbox_Call) Return(sandbox compute.Sandbox, err error) *MockBackend_ExtendSandbox_Call { - _c.Call.Return(sandbox, err) +func (_c *MockBackend_ExtendSandbox_Call) Return(sandbox compute.Sandbox, err error) *MockBackend_ExtendSandbox_Call { + _c.Call.Return(sandbox, err) + return _c +} + +func (_c *MockBackend_ExtendSandbox_Call) RunAndReturn(run func(context1 context.Context, s string, time1 time.Time) (compute.Sandbox, error)) *MockBackend_ExtendSandbox_Call { + _c.Call.Return(run) + return _c +} + +// GetInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) GetInstance(context1 context.Context, ref compute.Ref) (compute.Instance, error) { + ret := _mock.Called(context1, ref) + + if len(ret) == 0 { + panic("no return value specified for GetInstance") + } + + var r0 compute.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) (compute.Instance, error)); ok { + return returnFunc(context1, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) compute.Instance); ok { + r0 = returnFunc(context1, ref) + } else { + r0 = ret.Get(0).(compute.Instance) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref) error); ok { + r1 = returnFunc(context1, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstance' +type MockBackend_GetInstance_Call struct { + *mock.Call +} + +// GetInstance is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +func (_e *MockBackend_Expecter) GetInstance(context1 interface{}, ref interface{}) *MockBackend_GetInstance_Call { + return &MockBackend_GetInstance_Call{Call: _e.mock.On("GetInstance", context1, ref)} +} + +func (_c *MockBackend_GetInstance_Call) Run(run func(context1 context.Context, ref compute.Ref)) *MockBackend_GetInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_GetInstance_Call) Return(instance compute.Instance, err error) *MockBackend_GetInstance_Call { + _c.Call.Return(instance, err) + return _c +} + +func (_c *MockBackend_GetInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref) (compute.Instance, error)) *MockBackend_GetInstance_Call { + _c.Call.Return(run) + return _c +} + +// GetNetwork provides a mock function for the type MockBackend +func (_mock *MockBackend) GetNetwork(context1 context.Context, s string, s1 string) (compute.Network, error) { + ret := _mock.Called(context1, s, s1) + + if len(ret) == 0 { + panic("no return value specified for GetNetwork") + } + + var r0 compute.Network + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (compute.Network, error)); ok { + return returnFunc(context1, s, s1) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) compute.Network); ok { + r0 = returnFunc(context1, s, s1) + } else { + r0 = ret.Get(0).(compute.Network) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(context1, s, s1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetwork' +type MockBackend_GetNetwork_Call struct { + *mock.Call +} + +// GetNetwork is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +func (_e *MockBackend_Expecter) GetNetwork(context1 interface{}, s interface{}, s1 interface{}) *MockBackend_GetNetwork_Call { + return &MockBackend_GetNetwork_Call{Call: _e.mock.On("GetNetwork", context1, s, s1)} +} + +func (_c *MockBackend_GetNetwork_Call) Run(run func(context1 context.Context, s string, s1 string)) *MockBackend_GetNetwork_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_GetNetwork_Call) Return(network compute.Network, err error) *MockBackend_GetNetwork_Call { + _c.Call.Return(network, err) + return _c +} + +func (_c *MockBackend_GetNetwork_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string) (compute.Network, error)) *MockBackend_GetNetwork_Call { + _c.Call.Return(run) + return _c +} + +// GetSandbox provides a mock function for the type MockBackend +func (_mock *MockBackend) GetSandbox(context1 context.Context, s string) (compute.Sandbox, error) { + ret := _mock.Called(context1, s) + + if len(ret) == 0 { + panic("no return value specified for GetSandbox") + } + + var r0 compute.Sandbox + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (compute.Sandbox, error)); ok { + return returnFunc(context1, s) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) compute.Sandbox); ok { + r0 = returnFunc(context1, s) + } else { + r0 = ret.Get(0).(compute.Sandbox) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(context1, s) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSandbox' +type MockBackend_GetSandbox_Call struct { + *mock.Call +} + +// GetSandbox is a helper method to define mock.On call +// - context1 context.Context +// - s string +func (_e *MockBackend_Expecter) GetSandbox(context1 interface{}, s interface{}) *MockBackend_GetSandbox_Call { + return &MockBackend_GetSandbox_Call{Call: _e.mock.On("GetSandbox", context1, s)} +} + +func (_c *MockBackend_GetSandbox_Call) Run(run func(context1 context.Context, s string)) *MockBackend_GetSandbox_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_GetSandbox_Call) Return(sandbox compute.Sandbox, err error) *MockBackend_GetSandbox_Call { + _c.Call.Return(sandbox, err) + return _c +} + +func (_c *MockBackend_GetSandbox_Call) RunAndReturn(run func(context1 context.Context, s string) (compute.Sandbox, error)) *MockBackend_GetSandbox_Call { + _c.Call.Return(run) + return _c +} + +// GetSandboxImage provides a mock function for the type MockBackend +func (_mock *MockBackend) GetSandboxImage(context1 context.Context, s string, s1 string) (compute.CatalogImage, error) { + ret := _mock.Called(context1, s, s1) + + if len(ret) == 0 { + panic("no return value specified for GetSandboxImage") + } + + var r0 compute.CatalogImage + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (compute.CatalogImage, error)); ok { + return returnFunc(context1, s, s1) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) compute.CatalogImage); ok { + r0 = returnFunc(context1, s, s1) + } else { + r0 = ret.Get(0).(compute.CatalogImage) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(context1, s, s1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetSandboxImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSandboxImage' +type MockBackend_GetSandboxImage_Call struct { + *mock.Call +} + +// GetSandboxImage is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +func (_e *MockBackend_Expecter) GetSandboxImage(context1 interface{}, s interface{}, s1 interface{}) *MockBackend_GetSandboxImage_Call { + return &MockBackend_GetSandboxImage_Call{Call: _e.mock.On("GetSandboxImage", context1, s, s1)} +} + +func (_c *MockBackend_GetSandboxImage_Call) Run(run func(context1 context.Context, s string, s1 string)) *MockBackend_GetSandboxImage_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_GetSandboxImage_Call) Return(catalogImage compute.CatalogImage, err error) *MockBackend_GetSandboxImage_Call { + _c.Call.Return(catalogImage, err) + return _c +} + +func (_c *MockBackend_GetSandboxImage_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string) (compute.CatalogImage, error)) *MockBackend_GetSandboxImage_Call { + _c.Call.Return(run) + return _c +} + +// ListInstances provides a mock function for the type MockBackend +func (_mock *MockBackend) ListInstances(context1 context.Context, s string) ([]compute.Instance, error) { + ret := _mock.Called(context1, s) + + if len(ret) == 0 { + panic("no return value specified for ListInstances") + } + + var r0 []compute.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]compute.Instance, error)); ok { + return returnFunc(context1, s) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []compute.Instance); ok { + r0 = returnFunc(context1, s) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Instance) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(context1, s) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ListInstances_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListInstances' +type MockBackend_ListInstances_Call struct { + *mock.Call +} + +// ListInstances is a helper method to define mock.On call +// - context1 context.Context +// - s string +func (_e *MockBackend_Expecter) ListInstances(context1 interface{}, s interface{}) *MockBackend_ListInstances_Call { + return &MockBackend_ListInstances_Call{Call: _e.mock.On("ListInstances", context1, s)} +} + +func (_c *MockBackend_ListInstances_Call) Run(run func(context1 context.Context, s string)) *MockBackend_ListInstances_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_ListInstances_Call) Return(instances []compute.Instance, err error) *MockBackend_ListInstances_Call { + _c.Call.Return(instances, err) + return _c +} + +func (_c *MockBackend_ListInstances_Call) RunAndReturn(run func(context1 context.Context, s string) ([]compute.Instance, error)) *MockBackend_ListInstances_Call { + _c.Call.Return(run) + return _c +} + +// ListNetworks provides a mock function for the type MockBackend +func (_mock *MockBackend) ListNetworks(context1 context.Context, s string) ([]compute.Network, error) { + ret := _mock.Called(context1, s) + + if len(ret) == 0 { + panic("no return value specified for ListNetworks") + } + + var r0 []compute.Network + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]compute.Network, error)); ok { + return returnFunc(context1, s) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []compute.Network); ok { + r0 = returnFunc(context1, s) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Network) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(context1, s) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks' +type MockBackend_ListNetworks_Call struct { + *mock.Call +} + +// ListNetworks is a helper method to define mock.On call +// - context1 context.Context +// - s string +func (_e *MockBackend_Expecter) ListNetworks(context1 interface{}, s interface{}) *MockBackend_ListNetworks_Call { + return &MockBackend_ListNetworks_Call{Call: _e.mock.On("ListNetworks", context1, s)} +} + +func (_c *MockBackend_ListNetworks_Call) Run(run func(context1 context.Context, s string)) *MockBackend_ListNetworks_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_ListNetworks_Call) Return(networks []compute.Network, err error) *MockBackend_ListNetworks_Call { + _c.Call.Return(networks, err) + return _c +} + +func (_c *MockBackend_ListNetworks_Call) RunAndReturn(run func(context1 context.Context, s string) ([]compute.Network, error)) *MockBackend_ListNetworks_Call { + _c.Call.Return(run) + return _c +} + +// ListSandboxes provides a mock function for the type MockBackend +func (_mock *MockBackend) ListSandboxes(context1 context.Context) ([]compute.Sandbox, error) { + ret := _mock.Called(context1) + + if len(ret) == 0 { + panic("no return value specified for ListSandboxes") + } + + var r0 []compute.Sandbox + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]compute.Sandbox, error)); ok { + return returnFunc(context1) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []compute.Sandbox); ok { + r0 = returnFunc(context1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Sandbox) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(context1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ListSandboxes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSandboxes' +type MockBackend_ListSandboxes_Call struct { + *mock.Call +} + +// ListSandboxes is a helper method to define mock.On call +// - context1 context.Context +func (_e *MockBackend_Expecter) ListSandboxes(context1 interface{}) *MockBackend_ListSandboxes_Call { + return &MockBackend_ListSandboxes_Call{Call: _e.mock.On("ListSandboxes", context1)} +} + +func (_c *MockBackend_ListSandboxes_Call) Run(run func(context1 context.Context)) *MockBackend_ListSandboxes_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockBackend_ListSandboxes_Call) Return(sandboxs []compute.Sandbox, err error) *MockBackend_ListSandboxes_Call { + _c.Call.Return(sandboxs, err) + return _c +} + +func (_c *MockBackend_ListSandboxes_Call) RunAndReturn(run func(context1 context.Context) ([]compute.Sandbox, error)) *MockBackend_ListSandboxes_Call { + _c.Call.Return(run) + return _c +} + +// ListSnapshots provides a mock function for the type MockBackend +func (_mock *MockBackend) ListSnapshots(context1 context.Context, ref compute.Ref) ([]compute.Snapshot, error) { + ret := _mock.Called(context1, ref) + + if len(ret) == 0 { + panic("no return value specified for ListSnapshots") + } + + var r0 []compute.Snapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) ([]compute.Snapshot, error)); ok { + return returnFunc(context1, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) []compute.Snapshot); ok { + r0 = returnFunc(context1, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Snapshot) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref) error); ok { + r1 = returnFunc(context1, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ListSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSnapshots' +type MockBackend_ListSnapshots_Call struct { + *mock.Call +} + +// ListSnapshots is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +func (_e *MockBackend_Expecter) ListSnapshots(context1 interface{}, ref interface{}) *MockBackend_ListSnapshots_Call { + return &MockBackend_ListSnapshots_Call{Call: _e.mock.On("ListSnapshots", context1, ref)} +} + +func (_c *MockBackend_ListSnapshots_Call) Run(run func(context1 context.Context, ref compute.Ref)) *MockBackend_ListSnapshots_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_ListSnapshots_Call) Return(snapshots []compute.Snapshot, err error) *MockBackend_ListSnapshots_Call { + _c.Call.Return(snapshots, err) + return _c +} + +func (_c *MockBackend_ListSnapshots_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref) ([]compute.Snapshot, error)) *MockBackend_ListSnapshots_Call { + _c.Call.Return(run) + return _c +} + +// PeerNetworks provides a mock function for the type MockBackend +func (_mock *MockBackend) PeerNetworks(context1 context.Context, s string, s1 string, s2 string) error { + ret := _mock.Called(context1, s, s1, s2) + + if len(ret) == 0 { + panic("no return value specified for PeerNetworks") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(context1, s, s1, s2) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_PeerNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PeerNetworks' +type MockBackend_PeerNetworks_Call struct { + *mock.Call +} + +// PeerNetworks is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +// - s2 string +func (_e *MockBackend_Expecter) PeerNetworks(context1 interface{}, s interface{}, s1 interface{}, s2 interface{}) *MockBackend_PeerNetworks_Call { + return &MockBackend_PeerNetworks_Call{Call: _e.mock.On("PeerNetworks", context1, s, s1, s2)} +} + +func (_c *MockBackend_PeerNetworks_Call) Run(run func(context1 context.Context, s string, s1 string, s2 string)) *MockBackend_PeerNetworks_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockBackend_PeerNetworks_Call) Return(err error) *MockBackend_PeerNetworks_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_PeerNetworks_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string, s2 string) error) *MockBackend_PeerNetworks_Call { + _c.Call.Return(run) + return _c +} + +// PublishInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) PublishInstance(context1 context.Context, ref compute.Ref, s string) (string, error) { + ret := _mock.Called(context1, ref, s) + + if len(ret) == 0 { + panic("no return value specified for PublishInstance") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) (string, error)); ok { + return returnFunc(context1, ref, s) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) string); ok { + r0 = returnFunc(context1, ref, s) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, string) error); ok { + r1 = returnFunc(context1, ref, s) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_PublishInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PublishInstance' +type MockBackend_PublishInstance_Call struct { + *mock.Call +} + +// PublishInstance is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +// - s string +func (_e *MockBackend_Expecter) PublishInstance(context1 interface{}, ref interface{}, s interface{}) *MockBackend_PublishInstance_Call { + return &MockBackend_PublishInstance_Call{Call: _e.mock.On("PublishInstance", context1, ref, s)} +} + +func (_c *MockBackend_PublishInstance_Call) Run(run func(context1 context.Context, ref compute.Ref, s string)) *MockBackend_PublishInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_PublishInstance_Call) Return(s1 string, err error) *MockBackend_PublishInstance_Call { + _c.Call.Return(s1, err) + return _c +} + +func (_c *MockBackend_PublishInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, s string) (string, error)) *MockBackend_PublishInstance_Call { + _c.Call.Return(run) + return _c +} + +// ReadFile provides a mock function for the type MockBackend +func (_mock *MockBackend) ReadFile(context1 context.Context, fileReadRequest compute.FileReadRequest) (compute.FileReadResult, error) { + ret := _mock.Called(context1, fileReadRequest) + + if len(ret) == 0 { + panic("no return value specified for ReadFile") + } + + var r0 compute.FileReadResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileReadRequest) (compute.FileReadResult, error)); ok { + return returnFunc(context1, fileReadRequest) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileReadRequest) compute.FileReadResult); ok { + r0 = returnFunc(context1, fileReadRequest) + } else { + r0 = ret.Get(0).(compute.FileReadResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.FileReadRequest) error); ok { + r1 = returnFunc(context1, fileReadRequest) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_ReadFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadFile' +type MockBackend_ReadFile_Call struct { + *mock.Call +} + +// ReadFile is a helper method to define mock.On call +// - context1 context.Context +// - fileReadRequest compute.FileReadRequest +func (_e *MockBackend_Expecter) ReadFile(context1 interface{}, fileReadRequest interface{}) *MockBackend_ReadFile_Call { + return &MockBackend_ReadFile_Call{Call: _e.mock.On("ReadFile", context1, fileReadRequest)} +} + +func (_c *MockBackend_ReadFile_Call) Run(run func(context1 context.Context, fileReadRequest compute.FileReadRequest)) *MockBackend_ReadFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.FileReadRequest + if args[1] != nil { + arg1 = args[1].(compute.FileReadRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockBackend_ReadFile_Call) Return(fileReadResult compute.FileReadResult, err error) *MockBackend_ReadFile_Call { + _c.Call.Return(fileReadResult, err) + return _c +} + +func (_c *MockBackend_ReadFile_Call) RunAndReturn(run func(context1 context.Context, fileReadRequest compute.FileReadRequest) (compute.FileReadResult, error)) *MockBackend_ReadFile_Call { + _c.Call.Return(run) + return _c +} + +// RemoveACLRule provides a mock function for the type MockBackend +func (_mock *MockBackend) RemoveACLRule(context1 context.Context, s string, s1 string, s2 string) error { + ret := _mock.Called(context1, s, s1, s2) + + if len(ret) == 0 { + panic("no return value specified for RemoveACLRule") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(context1, s, s1, s2) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_RemoveACLRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveACLRule' +type MockBackend_RemoveACLRule_Call struct { + *mock.Call +} + +// RemoveACLRule is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +// - s2 string +func (_e *MockBackend_Expecter) RemoveACLRule(context1 interface{}, s interface{}, s1 interface{}, s2 interface{}) *MockBackend_RemoveACLRule_Call { + return &MockBackend_RemoveACLRule_Call{Call: _e.mock.On("RemoveACLRule", context1, s, s1, s2)} +} + +func (_c *MockBackend_RemoveACLRule_Call) Run(run func(context1 context.Context, s string, s1 string, s2 string)) *MockBackend_RemoveACLRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockBackend_RemoveACLRule_Call) Return(err error) *MockBackend_RemoveACLRule_Call { + _c.Call.Return(err) return _c } -func (_c *MockBackend_ExtendSandbox_Call) RunAndReturn(run func(context1 context.Context, s string, time1 time.Time) (compute.Sandbox, error)) *MockBackend_ExtendSandbox_Call { +func (_c *MockBackend_RemoveACLRule_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string, s2 string) error) *MockBackend_RemoveACLRule_Call { _c.Call.Return(run) return _c } -// GetInstance provides a mock function for the type MockBackend -func (_mock *MockBackend) GetInstance(context1 context.Context, ref compute.Ref) (compute.Instance, error) { - ret := _mock.Called(context1, ref) +// RestartInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) RestartInstance(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error) { + ret := _mock.Called(context1, ref, b) if len(ret) == 0 { - panic("no return value specified for GetInstance") + panic("no return value specified for RestartInstance") } var r0 compute.Instance var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) (compute.Instance, error)); ok { - return returnFunc(context1, ref) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(context1, ref, b) } - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) compute.Instance); ok { - r0 = returnFunc(context1, ref) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(context1, ref, b) } else { r0 = ret.Get(0).(compute.Instance) } - if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref) error); ok { - r1 = returnFunc(context1, ref) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(context1, ref, b) } else { r1 = ret.Error(1) } return r0, r1 } -// MockBackend_GetInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstance' -type MockBackend_GetInstance_Call struct { +// MockBackend_RestartInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartInstance' +type MockBackend_RestartInstance_Call struct { *mock.Call } -// GetInstance is a helper method to define mock.On call +// RestartInstance is a helper method to define mock.On call // - context1 context.Context // - ref compute.Ref -func (_e *MockBackend_Expecter) GetInstance(context1 any, ref any) *MockBackend_GetInstance_Call { - return &MockBackend_GetInstance_Call{Call: _e.mock.On("GetInstance", context1, ref)} +// - b bool +func (_e *MockBackend_Expecter) RestartInstance(context1 interface{}, ref interface{}, b interface{}) *MockBackend_RestartInstance_Call { + return &MockBackend_RestartInstance_Call{Call: _e.mock.On("RestartInstance", context1, ref, b)} } -func (_c *MockBackend_GetInstance_Call) Run(run func(context1 context.Context, ref compute.Ref)) *MockBackend_GetInstance_Call { +func (_c *MockBackend_RestartInstance_Call) Run(run func(context1 context.Context, ref compute.Ref, b bool)) *MockBackend_RestartInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -640,207 +1885,283 @@ func (_c *MockBackend_GetInstance_Call) Run(run func(context1 context.Context, r if args[1] != nil { arg1 = args[1].(compute.Ref) } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } run( arg0, arg1, + arg2, ) }) return _c } -func (_c *MockBackend_GetInstance_Call) Return(instance compute.Instance, err error) *MockBackend_GetInstance_Call { +func (_c *MockBackend_RestartInstance_Call) Return(instance compute.Instance, err error) *MockBackend_RestartInstance_Call { _c.Call.Return(instance, err) return _c } -func (_c *MockBackend_GetInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref) (compute.Instance, error)) *MockBackend_GetInstance_Call { +func (_c *MockBackend_RestartInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error)) *MockBackend_RestartInstance_Call { _c.Call.Return(run) return _c } -// GetSandbox provides a mock function for the type MockBackend -func (_mock *MockBackend) GetSandbox(context1 context.Context, s string) (compute.Sandbox, error) { - ret := _mock.Called(context1, s) +// RestoreSnapshot provides a mock function for the type MockBackend +func (_mock *MockBackend) RestoreSnapshot(context1 context.Context, ref compute.Ref, s string) error { + ret := _mock.Called(context1, ref, s) if len(ret) == 0 { - panic("no return value specified for GetSandbox") + panic("no return value specified for RestoreSnapshot") } - var r0 compute.Sandbox + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(context1, ref, s) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_RestoreSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreSnapshot' +type MockBackend_RestoreSnapshot_Call struct { + *mock.Call +} + +// RestoreSnapshot is a helper method to define mock.On call +// - context1 context.Context +// - ref compute.Ref +// - s string +func (_e *MockBackend_Expecter) RestoreSnapshot(context1 interface{}, ref interface{}, s interface{}) *MockBackend_RestoreSnapshot_Call { + return &MockBackend_RestoreSnapshot_Call{Call: _e.mock.On("RestoreSnapshot", context1, ref, s)} +} + +func (_c *MockBackend_RestoreSnapshot_Call) Run(run func(context1 context.Context, ref compute.Ref, s string)) *MockBackend_RestoreSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockBackend_RestoreSnapshot_Call) Return(err error) *MockBackend_RestoreSnapshot_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_RestoreSnapshot_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, s string) error) *MockBackend_RestoreSnapshot_Call { + _c.Call.Return(run) + return _c +} + +// StartInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) StartInstance(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error) { + ret := _mock.Called(context1, ref, b) + + if len(ret) == 0 { + panic("no return value specified for StartInstance") + } + + var r0 compute.Instance var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) (compute.Sandbox, error)); ok { - return returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(context1, ref, b) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) compute.Sandbox); ok { - r0 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(context1, ref, b) } else { - r0 = ret.Get(0).(compute.Sandbox) + r0 = ret.Get(0).(compute.Instance) } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(context1, ref, b) } else { r1 = ret.Error(1) } return r0, r1 } -// MockBackend_GetSandbox_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSandbox' -type MockBackend_GetSandbox_Call struct { +// MockBackend_StartInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartInstance' +type MockBackend_StartInstance_Call struct { *mock.Call } -// GetSandbox is a helper method to define mock.On call +// StartInstance is a helper method to define mock.On call // - context1 context.Context -// - s string -func (_e *MockBackend_Expecter) GetSandbox(context1 any, s any) *MockBackend_GetSandbox_Call { - return &MockBackend_GetSandbox_Call{Call: _e.mock.On("GetSandbox", context1, s)} +// - ref compute.Ref +// - b bool +func (_e *MockBackend_Expecter) StartInstance(context1 interface{}, ref interface{}, b interface{}) *MockBackend_StartInstance_Call { + return &MockBackend_StartInstance_Call{Call: _e.mock.On("StartInstance", context1, ref, b)} } -func (_c *MockBackend_GetSandbox_Call) Run(run func(context1 context.Context, s string)) *MockBackend_GetSandbox_Call { +func (_c *MockBackend_StartInstance_Call) Run(run func(context1 context.Context, ref compute.Ref, b bool)) *MockBackend_StartInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 compute.Ref if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(compute.Ref) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) } run( arg0, arg1, + arg2, ) }) return _c } -func (_c *MockBackend_GetSandbox_Call) Return(sandbox compute.Sandbox, err error) *MockBackend_GetSandbox_Call { - _c.Call.Return(sandbox, err) +func (_c *MockBackend_StartInstance_Call) Return(instance compute.Instance, err error) *MockBackend_StartInstance_Call { + _c.Call.Return(instance, err) return _c } -func (_c *MockBackend_GetSandbox_Call) RunAndReturn(run func(context1 context.Context, s string) (compute.Sandbox, error)) *MockBackend_GetSandbox_Call { +func (_c *MockBackend_StartInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error)) *MockBackend_StartInstance_Call { _c.Call.Return(run) return _c } -// ListInstances provides a mock function for the type MockBackend -func (_mock *MockBackend) ListInstances(context1 context.Context, s string) ([]compute.Instance, error) { - ret := _mock.Called(context1, s) +// StopInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) StopInstance(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error) { + ret := _mock.Called(context1, ref, b) if len(ret) == 0 { - panic("no return value specified for ListInstances") + panic("no return value specified for StopInstance") } - var r0 []compute.Instance + var r0 compute.Instance var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]compute.Instance, error)); ok { - return returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(context1, ref, b) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) []compute.Instance); ok { - r0 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(context1, ref, b) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]compute.Instance) - } + r0 = ret.Get(0).(compute.Instance) } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(context1, ref, b) } else { r1 = ret.Error(1) } return r0, r1 } -// MockBackend_ListInstances_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListInstances' -type MockBackend_ListInstances_Call struct { +// MockBackend_StopInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopInstance' +type MockBackend_StopInstance_Call struct { *mock.Call } -// ListInstances is a helper method to define mock.On call +// StopInstance is a helper method to define mock.On call // - context1 context.Context -// - s string -func (_e *MockBackend_Expecter) ListInstances(context1 any, s any) *MockBackend_ListInstances_Call { - return &MockBackend_ListInstances_Call{Call: _e.mock.On("ListInstances", context1, s)} +// - ref compute.Ref +// - b bool +func (_e *MockBackend_Expecter) StopInstance(context1 interface{}, ref interface{}, b interface{}) *MockBackend_StopInstance_Call { + return &MockBackend_StopInstance_Call{Call: _e.mock.On("StopInstance", context1, ref, b)} } -func (_c *MockBackend_ListInstances_Call) Run(run func(context1 context.Context, s string)) *MockBackend_ListInstances_Call { +func (_c *MockBackend_StopInstance_Call) Run(run func(context1 context.Context, ref compute.Ref, b bool)) *MockBackend_StopInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 compute.Ref if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(compute.Ref) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) } run( arg0, arg1, + arg2, ) }) return _c } -func (_c *MockBackend_ListInstances_Call) Return(instances []compute.Instance, err error) *MockBackend_ListInstances_Call { - _c.Call.Return(instances, err) +func (_c *MockBackend_StopInstance_Call) Return(instance compute.Instance, err error) *MockBackend_StopInstance_Call { + _c.Call.Return(instance, err) return _c } -func (_c *MockBackend_ListInstances_Call) RunAndReturn(run func(context1 context.Context, s string) ([]compute.Instance, error)) *MockBackend_ListInstances_Call { +func (_c *MockBackend_StopInstance_Call) RunAndReturn(run func(context1 context.Context, ref compute.Ref, b bool) (compute.Instance, error)) *MockBackend_StopInstance_Call { _c.Call.Return(run) return _c } -// ListNetworks provides a mock function for the type MockBackend -func (_mock *MockBackend) ListNetworks(context1 context.Context, s string) ([]compute.Network, error) { - ret := _mock.Called(context1, s) +// WaitInstance provides a mock function for the type MockBackend +func (_mock *MockBackend) WaitInstance(context1 context.Context, waitRequest compute.WaitRequest) (compute.WaitResult, error) { + ret := _mock.Called(context1, waitRequest) if len(ret) == 0 { - panic("no return value specified for ListNetworks") + panic("no return value specified for WaitInstance") } - var r0 []compute.Network + var r0 compute.WaitResult var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]compute.Network, error)); ok { - return returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.WaitRequest) (compute.WaitResult, error)); ok { + return returnFunc(context1, waitRequest) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) []compute.Network); ok { - r0 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.WaitRequest) compute.WaitResult); ok { + r0 = returnFunc(context1, waitRequest) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]compute.Network) - } + r0 = ret.Get(0).(compute.WaitResult) } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(context1, s) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.WaitRequest) error); ok { + r1 = returnFunc(context1, waitRequest) } else { r1 = ret.Error(1) } return r0, r1 } -// MockBackend_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks' -type MockBackend_ListNetworks_Call struct { +// MockBackend_WaitInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitInstance' +type MockBackend_WaitInstance_Call struct { *mock.Call } -// ListNetworks is a helper method to define mock.On call +// WaitInstance is a helper method to define mock.On call // - context1 context.Context -// - s string -func (_e *MockBackend_Expecter) ListNetworks(context1 any, s any) *MockBackend_ListNetworks_Call { - return &MockBackend_ListNetworks_Call{Call: _e.mock.On("ListNetworks", context1, s)} +// - waitRequest compute.WaitRequest +func (_e *MockBackend_Expecter) WaitInstance(context1 interface{}, waitRequest interface{}) *MockBackend_WaitInstance_Call { + return &MockBackend_WaitInstance_Call{Call: _e.mock.On("WaitInstance", context1, waitRequest)} } -func (_c *MockBackend_ListNetworks_Call) Run(run func(context1 context.Context, s string)) *MockBackend_ListNetworks_Call { +func (_c *MockBackend_WaitInstance_Call) Run(run func(context1 context.Context, waitRequest compute.WaitRequest)) *MockBackend_WaitInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 compute.WaitRequest if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(compute.WaitRequest) } run( arg0, @@ -850,74 +2171,78 @@ func (_c *MockBackend_ListNetworks_Call) Run(run func(context1 context.Context, return _c } -func (_c *MockBackend_ListNetworks_Call) Return(networks []compute.Network, err error) *MockBackend_ListNetworks_Call { - _c.Call.Return(networks, err) +func (_c *MockBackend_WaitInstance_Call) Return(waitResult compute.WaitResult, err error) *MockBackend_WaitInstance_Call { + _c.Call.Return(waitResult, err) return _c } -func (_c *MockBackend_ListNetworks_Call) RunAndReturn(run func(context1 context.Context, s string) ([]compute.Network, error)) *MockBackend_ListNetworks_Call { +func (_c *MockBackend_WaitInstance_Call) RunAndReturn(run func(context1 context.Context, waitRequest compute.WaitRequest) (compute.WaitResult, error)) *MockBackend_WaitInstance_Call { _c.Call.Return(run) return _c } -// ListSandboxes provides a mock function for the type MockBackend -func (_mock *MockBackend) ListSandboxes(context1 context.Context) ([]compute.Sandbox, error) { - ret := _mock.Called(context1) +// WriteFile provides a mock function for the type MockBackend +func (_mock *MockBackend) WriteFile(context1 context.Context, fileWriteRequest compute.FileWriteRequest) (compute.FileWriteResult, error) { + ret := _mock.Called(context1, fileWriteRequest) if len(ret) == 0 { - panic("no return value specified for ListSandboxes") + panic("no return value specified for WriteFile") } - var r0 []compute.Sandbox + var r0 compute.FileWriteResult var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) ([]compute.Sandbox, error)); ok { - return returnFunc(context1) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileWriteRequest) (compute.FileWriteResult, error)); ok { + return returnFunc(context1, fileWriteRequest) } - if returnFunc, ok := ret.Get(0).(func(context.Context) []compute.Sandbox); ok { - r0 = returnFunc(context1) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileWriteRequest) compute.FileWriteResult); ok { + r0 = returnFunc(context1, fileWriteRequest) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]compute.Sandbox) - } + r0 = ret.Get(0).(compute.FileWriteResult) } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(context1) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.FileWriteRequest) error); ok { + r1 = returnFunc(context1, fileWriteRequest) } else { r1 = ret.Error(1) } return r0, r1 } -// MockBackend_ListSandboxes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSandboxes' -type MockBackend_ListSandboxes_Call struct { +// MockBackend_WriteFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteFile' +type MockBackend_WriteFile_Call struct { *mock.Call } -// ListSandboxes is a helper method to define mock.On call +// WriteFile is a helper method to define mock.On call // - context1 context.Context -func (_e *MockBackend_Expecter) ListSandboxes(context1 any) *MockBackend_ListSandboxes_Call { - return &MockBackend_ListSandboxes_Call{Call: _e.mock.On("ListSandboxes", context1)} +// - fileWriteRequest compute.FileWriteRequest +func (_e *MockBackend_Expecter) WriteFile(context1 interface{}, fileWriteRequest interface{}) *MockBackend_WriteFile_Call { + return &MockBackend_WriteFile_Call{Call: _e.mock.On("WriteFile", context1, fileWriteRequest)} } -func (_c *MockBackend_ListSandboxes_Call) Run(run func(context1 context.Context)) *MockBackend_ListSandboxes_Call { +func (_c *MockBackend_WriteFile_Call) Run(run func(context1 context.Context, fileWriteRequest compute.FileWriteRequest)) *MockBackend_WriteFile_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } + var arg1 compute.FileWriteRequest + if args[1] != nil { + arg1 = args[1].(compute.FileWriteRequest) + } run( arg0, + arg1, ) }) return _c } -func (_c *MockBackend_ListSandboxes_Call) Return(sandboxs []compute.Sandbox, err error) *MockBackend_ListSandboxes_Call { - _c.Call.Return(sandboxs, err) +func (_c *MockBackend_WriteFile_Call) Return(fileWriteResult compute.FileWriteResult, err error) *MockBackend_WriteFile_Call { + _c.Call.Return(fileWriteResult, err) return _c } -func (_c *MockBackend_ListSandboxes_Call) RunAndReturn(run func(context1 context.Context) ([]compute.Sandbox, error)) *MockBackend_ListSandboxes_Call { +func (_c *MockBackend_WriteFile_Call) RunAndReturn(run func(context1 context.Context, fileWriteRequest compute.FileWriteRequest) (compute.FileWriteResult, error)) *MockBackend_WriteFile_Call { _c.Call.Return(run) return _c } diff --git a/internal/compute/mocks/mock_pending_instance.go b/internal/compute/mocks/mock_pending_instance.go index 548bf5d..b787889 100644 --- a/internal/compute/mocks/mock_pending_instance.go +++ b/internal/compute/mocks/mock_pending_instance.go @@ -72,7 +72,7 @@ type MockPendingInstance_Wait_Call struct { // Wait is a helper method to define mock.On call // - context1 context.Context -func (_e *MockPendingInstance_Expecter) Wait(context1 any) *MockPendingInstance_Wait_Call { +func (_e *MockPendingInstance_Expecter) Wait(context1 interface{}) *MockPendingInstance_Wait_Call { return &MockPendingInstance_Wait_Call{Call: _e.mock.On("Wait", context1)} } diff --git a/internal/compute/network.go b/internal/compute/network.go new file mode 100644 index 0000000..5eee933 --- /dev/null +++ b/internal/compute/network.go @@ -0,0 +1,594 @@ +package compute + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" +) + +const ( + aclDirectionIngress = "ingress" + aclDirectionEgress = "egress" + aclActionAllow = "allow" + aclActionDrop = "drop" + aclActionReject = "reject" + aclProtocolTCP = "tcp" + aclProtocolUDP = "udp" + aclProtocolICMP = "icmp" + + // BaselineEgressMgmt is the immutable egress drop toward the management VLAN. + BaselineEgressMgmt = "baseline-egress-mgmt" + // BaselineEgressOOB is the immutable egress drop toward the OOB VLAN. + BaselineEgressOOB = "baseline-egress-oob" + + // mappedIPv4PrefixBits is the bit offset of the embedded IPv4 address in an IPv4-mapped IPv6 prefix. + mappedIPv4PrefixBits = 96 + protectedIPv4PrefixBits = 24 + maxLossPercent = 100 +) + +// ACLRule is one agent-facing network ACL entry. +type ACLRule struct { + // ID is the agent-facing rule identifier returned by AddACLRule. + ID string + // Direction is ingress or egress. + Direction string + // Action is allow, drop, or reject. + Action string + // Protocol is tcp, udp, icmp, or empty for any. + Protocol string + // Src is an optional CIDR or address. + Src string + // Dst is an optional CIDR or address. + Dst string + // Port is an optional destination port or range. + Port string +} + +// Forward is a listen address allocated from the uplink OVN range. +type Forward struct { + // Address is the listen address on the uplink range. + Address string + // Port is the listen port. + Port int64 + // Protocol is tcp or udp. + Protocol string + // Network is the agent-facing network name. + Network string + // Instance is the target instance name. + Instance string +} + +// Impairment is in-guest tc netem configuration for one NIC. +type Impairment struct { + // LatencyMS is added delay in milliseconds. + LatencyMS int64 + // JitterMS is delay variation and requires LatencyMS. + JitterMS int64 + // LossPercent is packet loss percent. + LossPercent float64 + // RateMbit is a rate limit in Mbit/s. + RateMbit int64 + // Clear removes existing impairment on the NIC. + Clear bool +} + +func sandboxNetworkKind(box Sandbox) string { + if box.NetworkKind == "" || box.NetworkKind == kindBridge { + return kindBridge + } + return box.NetworkKind +} + +func isBridgeSandbox(box Sandbox) bool { + return sandboxNetworkKind(box) == kindBridge +} + +func isBaselineRule(id string) bool { + return id == BaselineEgressMgmt || id == BaselineEgressOOB +} + +// ListNetworks returns agent-facing networks in a sandbox. +func (s *Service) ListNetworks(ctx context.Context, sandbox string) ([]Network, error) { + if err := validateName(sandbox); err != nil { + return nil, err + } + if _, err := s.backend.GetSandbox(ctx, sandbox); err != nil { + if errors.Is(err, ErrNotFound) { + return nil, sandboxNotFound(sandbox) + } + return nil, s.backendError(ctx, "get sandbox", err) + } + networks, err := s.backend.ListNetworks(ctx, sandbox) + if err != nil { + return nil, s.backendError(ctx, "list networks", err) + } + return nonNil(networks), nil +} + +// GetNetwork returns one agent-facing network. +func (s *Service) GetNetwork(ctx context.Context, sandbox, name string) (Network, error) { + if err := validateName(sandbox); err != nil { + return Network{}, err + } + if name != reservedDefault { + if err := validateName(name); err != nil { + return Network{}, err + } + } + if _, err := s.backend.GetSandbox(ctx, sandbox); err != nil { + if errors.Is(err, ErrNotFound) { + return Network{}, sandboxNotFound(sandbox) + } + return Network{}, s.backendError(ctx, "get sandbox", err) + } + network, err := s.backend.GetNetwork(ctx, sandbox, name) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Network{}, networkNotFound(name, sandbox) + } + return Network{}, s.backendError(ctx, "get network", err) + } + return network, nil +} + +// DeleteNetwork deletes a network that has no attached NICs. +func (s *Service) DeleteNetwork(ctx context.Context, sandbox, name string) error { + if name != reservedDefault { + if err := validateName(name); err != nil { + return err + } + } else { + return agentErrorf("name %q is reserved", name) + } + return s.withLiveSandbox(ctx, sandbox, func(Sandbox) error { + instances, err := s.backend.ListInstances(ctx, sandbox) + if err != nil { + return s.backendError(ctx, "list instances", err) + } + for _, instance := range instances { + for _, nic := range instance.NICs { + if nic.Network == name { + return agentErrorf("network %q still has attached NICs in sandbox %q", name, sandbox) + } + } + } + if err := s.backend.DeleteNetwork(ctx, sandbox, name); err != nil { + if errors.Is(err, ErrNotFound) { + return networkNotFound(name, sandbox) + } + return s.backendError(ctx, "delete network", err) + } + return nil + }) +} + +// DetachNIC removes a NIC under the sandbox gate. +func (s *Service) DetachNIC(ctx context.Context, ref Ref, nic string) error { + if err := validateRef(ref); err != nil { + return err + } + if err := validateName(nic); err != nil { + return err + } + return s.withLiveSandbox(ctx, ref.Sandbox, func(Sandbox) error { + if err := s.backend.DetachNIC(ctx, ref, nic); err != nil { + if errors.Is(err, ErrNotFound) { + return agentErrorf("nic %q not found on instance %q in sandbox %q", nic, ref.Name, ref.Sandbox) + } + return s.backendError(ctx, "detach nic", err) + } + return nil + }) +} + +// PeerNetworks routes between two OVN networks in a sandbox. +func (s *Service) PeerNetworks(ctx context.Context, sandbox, network, peer string) error { + if err := validateNetworkName(network); err != nil { + return err + } + if err := validateNetworkName(peer); err != nil { + return err + } + if network == peer { + return agentError("peer network must be different") + } + return s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { + if isBridgeSandbox(box) { + return agentError("network peering requires OVN networks") + } + left, err := s.backend.GetNetwork(ctx, sandbox, network) + if err != nil { + if errors.Is(err, ErrNotFound) { + return networkNotFound(network, sandbox) + } + return s.backendError(ctx, "get network", err) + } + right, err := s.backend.GetNetwork(ctx, sandbox, peer) + if err != nil { + if errors.Is(err, ErrNotFound) { + return networkNotFound(peer, sandbox) + } + return s.backendError(ctx, "get network", err) + } + if left.Kind != kindOVN || right.Kind != kindOVN { + return agentError("network peering requires OVN networks") + } + if err := s.backend.PeerNetworks(ctx, sandbox, network, peer); err != nil { + return s.backendError(ctx, "peer networks", err) + } + return nil + }) +} + +// AddACLRule appends a network-scoped rule without allowing baseline overrides. +func (s *Service) AddACLRule(ctx context.Context, sandbox, network string, rule ACLRule) (ACLRule, error) { + if err := validateNetworkName(network); err != nil { + return ACLRule{}, err + } + if err := validateACLRule(rule); err != nil { + return ACLRule{}, err + } + var created ACLRule + err := s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { + if isBridgeSandbox(box) { + return agentError("network ACLs require OVN networks") + } + if _, err := s.backend.GetNetwork(ctx, sandbox, network); err != nil { + if errors.Is(err, ErrNotFound) { + return networkNotFound(network, sandbox) + } + return s.backendError(ctx, "get network", err) + } + var addErr error + created, addErr = s.backend.AddACLRule(ctx, sandbox, network, rule) + if addErr != nil { + if errors.Is(addErr, ErrNotFound) { + return networkNotFound(network, sandbox) + } + return s.backendError(ctx, "add acl", addErr) + } + return nil + }) + if err != nil { + return ACLRule{}, err + } + return created, nil +} + +// RemoveACLRule deletes an agent ACL rule; baseline IDs are not removable. +func (s *Service) RemoveACLRule(ctx context.Context, sandbox, network, rule string) error { + if err := validateNetworkName(network); err != nil { + return err + } + if rule == "" { + return agentError("rule is required") + } + if isBaselineRule(rule) { + return agentErrorf("rule %q is a baseline ACL and cannot be removed", rule) + } + return s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { + if isBridgeSandbox(box) { + return agentError("network ACLs require OVN networks") + } + if err := s.backend.RemoveACLRule(ctx, sandbox, network, rule); err != nil { + if errors.Is(err, ErrNotFound) { + return agentErrorf("rule %q not found on network %q in sandbox %q", rule, network, sandbox) + } + return s.backendError(ctx, "remove acl", err) + } + return nil + }) +} + +// CreateForward exposes an instance port on a reused or newly allocated uplink address. +func (s *Service) CreateForward( + ctx context.Context, + sandbox, network string, + ref Ref, + port, listenPort int64, + protocol string, +) (Forward, error) { + ref, listenPort, protocol, err := prepareForward(sandbox, network, ref, port, listenPort, protocol) + if err != nil { + return Forward{}, err + } + var created Forward + err = s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { + var createErr error + created, createErr = s.createPreparedForward(ctx, box, sandbox, network, ref, port, listenPort, protocol) + return createErr + }) + if err != nil { + return Forward{}, err + } + return created, nil +} + +// ImpairNIC applies Linux-only tc netem settings inside a guest. It is not gated. +func (s *Service) ImpairNIC(ctx context.Context, ref Ref, nic string, impairment Impairment) error { + if err := validateRef(ref); err != nil { + return err + } + if err := validateName(nic); err != nil { + return err + } + if err := validateImpairment(impairment); err != nil { + return err + } + inst, err := s.GetInstance(ctx, ref) + if err != nil { + return err + } + if inst.Status != statusRunning { + return agentErrorf("instance %q in sandbox %q is not running", ref.Name, ref.Sandbox) + } + if !nicExists(inst, nic) { + return agentErrorf("nic %q not found on instance %q in sandbox %q", nic, ref.Name, ref.Sandbox) + } + if err := s.requireLinuxGuest(ctx, inst); err != nil { + return err + } + script := impairCommand(nic, impairment) + stdout := newDrainingWriter(execOutputLimit) + stderr := newDrainingWriter(execOutputLimit) + code, execErr := s.backend.Exec(ctx, ExecRequest{ + Ref: ref, + Argv: []string{"sh", "-c", script}, + }, stdout, stderr) + if execErr != nil { + if errors.Is(execErr, context.Canceled) || errors.Is(execErr, context.DeadlineExceeded) { + return execErr + } + return s.backendError(ctx, "impair", execErr) + } + if code != 0 { + return agentErrorf("impair failed on nic %q of instance %q", nic, ref.Name) + } + return nil +} + +func prepareForward( + sandbox, network string, + ref Ref, + port, listenPort int64, + protocol string, +) (Ref, int64, string, error) { + if err := validateNetworkName(network); err != nil { + return Ref{}, 0, "", err + } + if ref.Sandbox == "" { + ref.Sandbox = sandbox + } + if ref.Sandbox != sandbox { + return Ref{}, 0, "", agentError("instance sandbox must match network sandbox") + } + if err := validateRef(ref); err != nil { + return Ref{}, 0, "", err + } + if port < 1 || port > 65535 { + return Ref{}, 0, "", agentError("port must be between 1 and 65535") + } + if listenPort == 0 { + listenPort = port + } + if listenPort < 1 || listenPort > 65535 { + return Ref{}, 0, "", agentError("listen_port must be between 1 and 65535") + } + if protocol == "" { + protocol = aclProtocolTCP + } + if protocol != aclProtocolTCP && protocol != aclProtocolUDP { + return Ref{}, 0, "", agentErrorf("protocol %q is not supported", protocol) + } + return ref, listenPort, protocol, nil +} + +func (s *Service) createPreparedForward( + ctx context.Context, + box Sandbox, + sandbox, network string, + ref Ref, + port, listenPort int64, + protocol string, +) (Forward, error) { + if isBridgeSandbox(box) { + return Forward{}, agentError("port forwards require an OVN network") + } + netw, err := s.backend.GetNetwork(ctx, sandbox, network) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Forward{}, networkNotFound(network, sandbox) + } + return Forward{}, s.backendError(ctx, "get network", err) + } + if netw.Kind != kindOVN { + return Forward{}, agentError("port forwards require an OVN network") + } + if !netw.NAT { + return Forward{}, agentError("net.forward requires a NAT-enabled network; nat=false networks have no uplink") + } + instance, err := s.backend.GetInstance(ctx, ref) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Forward{}, instanceNotFound(ref) + } + return Forward{}, s.backendError(ctx, "get instance", err) + } + if !instanceHasNetwork(instance, network) { + return Forward{}, agentErrorf("instance %q has no NIC on network %q", ref.Name, network) + } + created, err := s.backend.CreateForward(ctx, sandbox, network, ref, port, listenPort, protocol) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Forward{}, networkNotFound(network, sandbox) + } + return Forward{}, s.backendError(ctx, "create forward", err) + } + return created, nil +} + +func validateNetworkName(name string) error { + if name == reservedDefault { + return nil + } + return validateName(name) +} + +func validateACLRule(rule ACLRule) error { + switch rule.Direction { + case aclDirectionIngress, aclDirectionEgress: + default: + return agentErrorf("direction %q is not supported", rule.Direction) + } + switch rule.Action { + case aclActionAllow, aclActionDrop, aclActionReject: + default: + return agentErrorf("action %q is not supported", rule.Action) + } + if rule.Protocol != "" && rule.Protocol != aclProtocolTCP && rule.Protocol != aclProtocolUDP && + rule.Protocol != aclProtocolICMP { + return agentErrorf("protocol %q is not supported", rule.Protocol) + } + if rule.Port != "" && rule.Protocol != aclProtocolTCP && rule.Protocol != aclProtocolUDP { + return agentError("port requires tcp or udp") + } + if rule.Action == aclActionAllow && !excludesProtectedDestinations(rule.Dst) { + return agentError("allow requires an explicit destination outside management and OOB ranges") + } + return nil +} + +func excludesProtectedDestinations(destination string) bool { + prefix, err := netip.ParsePrefix(destination) + if err != nil { + address, addressErr := netip.ParseAddr(destination) + if addressErr != nil { + return false + } + address = address.Unmap() + prefix = netip.PrefixFrom(address, address.BitLen()) + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < mappedIPv4PrefixBits { + return false + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-mappedIPv4PrefixBits) + } + for _, protected := range protectedPrefixes() { + if prefix.Overlaps(protected) { + return false + } + } + return true +} + +func protectedPrefixes() [2]netip.Prefix { + return [2]netip.Prefix{ + netip.PrefixFrom(netip.AddrFrom4([4]byte{10, 10, 10, 0}), protectedIPv4PrefixBits), + netip.PrefixFrom(netip.AddrFrom4([4]byte{10, 10, 70, 0}), protectedIPv4PrefixBits), + } +} + +func validateCIDR(cidr string) error { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return agentErrorf("invalid cidr %q", cidr) + } + return nil +} + +func validateImpairment(impairment Impairment) error { + if impairment.Clear { + return nil + } + if impairment.LatencyMS < 0 || impairment.JitterMS < 0 || impairment.RateMbit < 0 || impairment.LossPercent < 0 { + return agentError("impairment values must be non-negative") + } + if impairment.LossPercent > maxLossPercent { + return agentError("loss_percent must be between 0 and 100") + } + if impairment.JitterMS > 0 && impairment.LatencyMS == 0 { + return agentError("jitter_ms requires latency_ms") + } + if impairment.LatencyMS == 0 && impairment.JitterMS == 0 && impairment.LossPercent == 0 && + impairment.RateMbit == 0 { + return agentError("no impairment specified") + } + return nil +} + +func instanceHasNetwork(instance Instance, network string) bool { + for _, nic := range instance.NICs { + if nic.Network == network { + return true + } + } + return false +} + +func nicExists(instance Instance, nic string) bool { + for _, attached := range instance.NICs { + if attached.Name == nic { + return true + } + } + return false +} + +func (s *Service) requireLinuxGuest(ctx context.Context, inst Instance) error { + if image, ok := s.catalog.Lookup(inst.Image); ok { + osName := strings.ToLower(image.OS) + if strings.Contains(osName, "windows") || osName == "darwin" || osName == "macos" { + return agentErrorf("net.impair is not supported on %s guests", image.OS) + } + } + stdout := newDrainingWriter(execOutputLimit) + stderr := newDrainingWriter(execOutputLimit) + code, err := s.backend.Exec(ctx, ExecRequest{ + Ref: inst.Ref, + Argv: []string{"uname", "-s"}, + }, stdout, stderr) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return agentErrorf("net.impair is not supported on non-Linux guests") + } + if code != 0 || !strings.EqualFold(strings.TrimSpace(stdout.String()), "Linux") { + return agentErrorf("net.impair is not supported on non-Linux guests") + } + return nil +} + +func impairCommand(nic string, impairment Impairment) string { + if impairment.Clear { + return fmt.Sprintf( + "qdisc=$(tc qdisc show dev %s) || exit $?\n"+ + "case \"$qdisc\" in *\"qdisc netem 1: root\"*) tc qdisc del dev %s root ;; esac\n", + nic, nic, + ) + } + return fmt.Sprintf("tc qdisc replace dev %s root handle 1: netem %s\n", nic, netemArgs(impairment)) +} + +func netemArgs(impairment Impairment) string { + var parts []string + if impairment.LatencyMS > 0 { + if impairment.JitterMS > 0 { + parts = append(parts, fmt.Sprintf("delay %dms %dms", impairment.LatencyMS, impairment.JitterMS)) + } else { + parts = append(parts, fmt.Sprintf("delay %dms", impairment.LatencyMS)) + } + } + if impairment.LossPercent > 0 { + parts = append(parts, fmt.Sprintf("loss %s%%", strconv.FormatFloat(impairment.LossPercent, 'f', -1, 64))) + } + if impairment.RateMbit > 0 { + parts = append(parts, fmt.Sprintf("rate %dmbit", impairment.RateMbit)) + } + return strings.Join(parts, " ") +} diff --git a/internal/compute/reaper_test.go b/internal/compute/reaper_test.go index 55b4b07..cd60a70 100644 --- a/internal/compute/reaper_test.go +++ b/internal/compute/reaper_test.go @@ -14,7 +14,7 @@ func TestReapRetriesPartialDelete(t *testing.T) { t.Parallel() tc := newTestContext(t) - expired := expiredSandbox("demo") + expired := expiredSandbox() live := liveSandbox("keep") tc.backend.EXPECT().ListSandboxes(mock.Anything).Return([]compute.Sandbox{expired, live}, nil).Times(2) @@ -32,7 +32,7 @@ func TestReapSkipsUnexpiredAfterReread(t *testing.T) { t.Parallel() tc := newTestContext(t) - listed := expiredSandbox("demo") + listed := expiredSandbox() tc.backend.EXPECT().ListSandboxes(mock.Anything).Return([]compute.Sandbox{listed}, nil) tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(liveSandbox("demo"), nil) diff --git a/internal/compute/service.go b/internal/compute/service.go index b65e464..cdfbd37 100644 --- a/internal/compute/service.go +++ b/internal/compute/service.go @@ -26,35 +26,35 @@ const ( // Options configures a compute Service. type Options struct { - // Host is the slice-1 member every new sandbox is pinned to. + // Host is the member used to pin bridge-backed sandboxes. Host string // DefaultTTL is used when create or extend omits a TTL. Zero selects 240 minutes. DefaultTTL time.Duration // MaxTTL is the upper bound for create and extend. Zero selects 1440 minutes. MaxTTL time.Duration + // DefaultNetworkKind selects ovn or bridge for new sandboxes. Empty selects ovn. + DefaultNetworkKind string // Logger receives operational logs. Nil selects a no-op logger. Logger *slog.Logger } // Service orchestrates sandboxes against a Backend and an immutable catalog. type Service struct { - backend Backend - catalog *Catalog - gate *gate - log *slog.Logger - host string - defaultTTL time.Duration - maxTTL time.Duration + backend Backend + catalog *Catalog + gate *gate + log *slog.Logger + host string + defaultTTL time.Duration + maxTTL time.Duration + defaultNetworkKind string } -// New constructs a Service. Host is required; zero TTLs select the documented defaults. +// New constructs a Service. Bridge defaults require Host; zero TTLs select the documented defaults. func New(backend Backend, catalog *Catalog, opts Options) (*Service, error) { if backend == nil { return nil, errors.New("backend is required") } - if opts.Host == "" { - return nil, errors.New("host is required") - } if catalog == nil { empty, err := NewCatalog(nil) if err != nil { @@ -73,14 +73,25 @@ func New(backend Backend, catalog *Catalog, opts Options) (*Service, error) { if resolvedDefault > resolvedMax { return nil, errors.New("default TTL exceeds maximum TTL") } + kind := opts.DefaultNetworkKind + if kind == "" { + kind = kindOVN + } + if kind != kindBridge && kind != kindOVN { + return nil, fmt.Errorf("unsupported default network kind %q", kind) + } + if kind == kindBridge && opts.Host == "" { + return nil, errors.New("host is required for bridge sandboxes") + } return &Service{ - backend: backend, - catalog: catalog, - gate: newGate(), - log: loggerOrDiscard(opts.Logger), - host: opts.Host, - defaultTTL: resolvedDefault, - maxTTL: resolvedMax, + backend: backend, + catalog: catalog, + gate: newGate(), + log: loggerOrDiscard(opts.Logger), + host: opts.Host, + defaultTTL: resolvedDefault, + maxTTL: resolvedMax, + defaultNetworkKind: kind, }, nil } @@ -343,32 +354,15 @@ func (s *Service) Exec(ctx context.Context, req ExecRequest) (ExecResult, error) // CreateNetwork creates an additional agent-facing network in a live sandbox. func (s *Service) CreateNetwork(ctx context.Context, sandbox string, network Network) (Network, error) { - if err := validateName(network.Name); err != nil { - return Network{}, err - } - if err := validateNetworkKind(network.Kind); err != nil { + network, err := prepareNetwork(network) + if err != nil { return Network{}, err } - if network.Kind == "" { - network.Kind = kindBridge - } var created Network - err := s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { - if network.Host == "" { - network.Host = box.Host - } - if network.Host != box.Host { - return agentErrorf("host %q is not sandbox member %q", network.Host, box.Host) - } + err = s.withLiveSandbox(ctx, sandbox, func(box Sandbox) error { var createErr error - created, createErr = s.backend.CreateNetwork(ctx, sandbox, network) - if createErr != nil { - if errors.Is(createErr, ErrNotFound) { - return sandboxNotFound(sandbox) - } - return s.backendError(ctx, "create network", createErr) - } - return nil + created, createErr = s.createPreparedNetwork(ctx, box, sandbox, network) + return createErr }) if err != nil { return Network{}, err @@ -426,6 +420,59 @@ func validateAttachment(ref Ref, network, nic string) error { return nil } +func prepareNetwork(network Network) (Network, error) { + if err := validateName(network.Name); err != nil { + return Network{}, err + } + if err := validateNetworkKind(network.Kind); err != nil { + return Network{}, err + } + if network.CIDR != "" { + if err := validateCIDR(network.CIDR); err != nil { + return Network{}, err + } + } + if network.Kind == "" { + network.Kind = kindOVN + } + if network.Kind == kindOVN && !network.NAT && network.Gateway != "" { + return Network{}, agentError( + "nat=false networks have no uplink gateway; attach a router instance or use net.peer", + ) + } + return network, nil +} + +func (s *Service) createPreparedNetwork( + ctx context.Context, + box Sandbox, + sandbox string, + network Network, +) (Network, error) { + fabric := sandboxNetworkKind(box) + if network.Kind != fabric { + return Network{}, agentErrorf("cannot create a %q network in a %q sandbox", network.Kind, fabric) + } + if fabric == kindBridge { + if network.Host == "" { + network.Host = box.Host + } + if network.Host != box.Host { + return Network{}, agentErrorf("host %q is not sandbox member %q", network.Host, box.Host) + } + } else { + network.Host = "" + } + created, err := s.backend.CreateNetwork(ctx, sandbox, network) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Network{}, sandboxNotFound(sandbox) + } + return Network{}, s.backendError(ctx, "create network", err) + } + return created, nil +} + func (s *Service) createGeneratedSandbox(ctx context.Context, ttl time.Duration, subject string) (Sandbox, error) { for range nameGenerateTries { name, err := generateName() @@ -456,7 +503,8 @@ func (s *Service) createNamedSandbox( } defer unlock() - if _, err := s.backend.GetSandbox(ctx, name); err == nil { + _, err = s.backend.GetSandbox(ctx, name) + if err == nil { return Sandbox{}, agentErrorf("sandbox %q already exists", name) } else if !errors.Is(err, ErrNotFound) { return Sandbox{}, s.backendError(ctx, "get sandbox", err) @@ -464,12 +512,15 @@ func (s *Service) createNamedSandbox( now := time.Now() box := Sandbox{ - Name: name, - Platform: platformIncus, - Subject: subject, - Host: s.host, - CreatedAt: now, - ExpiresAt: now.Add(ttl), + Name: name, + Platform: platformIncus, + Subject: subject, + NetworkKind: s.defaultNetworkKind, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + if s.defaultNetworkKind == kindBridge { + box.Host = s.host } if err := s.backend.CreateSandbox(ctx, box); err != nil { return Sandbox{}, s.backendError(ctx, "create sandbox", err) @@ -511,14 +562,16 @@ func (s *Service) beginInstance(ctx context.Context, req CreateInstance) (Pendin } func (s *Service) prepareCreate(box Sandbox, req CreateInstance) (CreateInstance, error) { - if box.Host == "" { - box.Host = s.host - } - if req.Host == "" { - req.Host = box.Host - } - if req.Host != box.Host { - return CreateInstance{}, agentErrorf("host %q is not sandbox member %q", req.Host, box.Host) + if isBridgeSandbox(box) { + if box.Host == "" { + box.Host = s.host + } + if req.Host == "" { + req.Host = box.Host + } + if req.Host != box.Host { + return CreateInstance{}, agentErrorf("host %q is not sandbox member %q", req.Host, box.Host) + } } if req.Kind == "" { req.Kind = req.Image.Kind @@ -635,7 +688,7 @@ func validateInstanceKind(kind string, image CatalogImage) error { } func validateNetworkKind(kind string) error { - if kind == "" || kind == kindBridge { + if kind == "" || kind == kindBridge || kind == kindOVN { return nil } return agentErrorf("kind %q is not available yet", kind) diff --git a/internal/compute/service_test.go b/internal/compute/service_test.go index 90139be..c14db7b 100644 --- a/internal/compute/service_test.go +++ b/internal/compute/service_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/meigma/codemode" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -41,7 +42,6 @@ func TestCreateSandboxZeroTTLUsesOptionsDefault(t *testing.T) { backend.EXPECT().CreateSandbox(mock.Anything, mock.AnythingOfType("compute.Sandbox")). RunAndReturn(func(_ context.Context, box compute.Sandbox) error { assert.WithinDuration(t, time.Now().Add(configured), box.ExpiresAt, time.Second) - assert.Equal(t, "lab01", box.Host) return nil }) @@ -54,11 +54,13 @@ func TestCreateSandboxGeneratedNameRetriesCollision(t *testing.T) { t.Parallel() tc := newTestContext(t) - calls := 0 + collision := "" tc.backend.EXPECT().GetSandbox(mock.Anything, mock.AnythingOfType("string")). RunAndReturn(func(_ context.Context, name string) (compute.Sandbox, error) { - calls++ - if calls == 1 { + if collision == "" { + collision = name + } + if name == collision { return compute.Sandbox{Name: name}, nil } return compute.Sandbox{}, compute.ErrNotFound @@ -67,17 +69,14 @@ func TestCreateSandboxGeneratedNameRetriesCollision(t *testing.T) { box, err := tc.service.CreateSandbox(t.Context(), "", 0, "subj") require.NoError(t, err) - assert.NotEmpty(t, box.Name) - assert.Equal(t, "lab01", box.Host) - assert.GreaterOrEqual(t, calls, 2) - assert.WithinDuration(t, time.Now().Add(240*time.Minute), box.ExpiresAt, 5*time.Second) + assert.NotEqual(t, collision, box.Name) } func TestCreateInstanceRejectsExpiredSandboxAfterGate(t *testing.T) { t.Parallel() tc := newTestContext(t) - tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(expiredSandbox("demo"), nil) + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(expiredSandbox(), nil) _, err := tc.service.CreateInstance(t.Context(), compute.CreateInstance{ Ref: compute.Ref{Sandbox: "demo", Name: "web"}, @@ -132,12 +131,24 @@ func TestCreateInstanceRejectsOtherHost(t *testing.T) { requireAgentMessage(t, err, `host "lab02" is not sandbox member "lab01"`) } -func TestCreateNetworkRejectsOVN(t *testing.T) { +func TestCreateNetworkRejectsMixedKind(t *testing.T) { t.Parallel() tc := newTestContext(t) + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(liveSandbox("demo"), nil) _, err := tc.service.CreateNetwork(t.Context(), "demo", compute.Network{Name: "lan", Kind: "ovn"}) - requireAgentMessage(t, err, `kind "ovn" is not available yet`) + requireAgentMessage(t, err, `cannot create a "ovn" network in a "bridge" sandbox`) +} + +func TestCreateNetworkRejectsBridgeInOVNSandbox(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + box := liveSandbox("demo") + box.NetworkKind = "ovn" + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(box, nil) + _, err := tc.service.CreateNetwork(t.Context(), "demo", compute.Network{Name: "lan", Kind: "bridge"}) + requireAgentMessage(t, err, `cannot create a "bridge" network in a "ovn" sandbox`) } func TestCreateInstanceRejectsMacPlatform(t *testing.T) { @@ -232,3 +243,81 @@ func TestDeleteSandboxMarksExpiryThenDeletes(t *testing.T) { require.NoError(t, tc.service.DeleteSandbox(t.Context(), "demo")) } + +func TestCreateInstanceOVNLeavesHostEmpty(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + box := liveSandbox("demo") + box.NetworkKind = "ovn" + box.Host = "" + pending := mocks.NewMockPendingInstance(t) + pending.EXPECT().Wait(mock.Anything).Return(runningInstance(), nil) + tc.backend.EXPECT().GetSandbox(mock.Anything, "demo").Return(box, nil) + tc.backend.EXPECT().GetInstance(mock.Anything, compute.Ref{Sandbox: "demo", Name: "web"}). + Return(compute.Instance{}, compute.ErrNotFound) + tc.backend.EXPECT(). + BeginCreateInstance(mock.Anything, mock.MatchedBy(func(req compute.CreateInstance) bool { + return req.Host == "" && req.Network == "default" + })). + Return(pending, nil) + + _, err := tc.service.CreateInstance(t.Context(), compute.CreateInstance{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, + Image: routerImage(), + Kind: "container", + Start: true, + }) + require.NoError(t, err) +} + +func TestRemoveACLRuleRejectsBaseline(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + for _, id := range []string{compute.BaselineEgressMgmt, compute.BaselineEgressOOB} { + err := tc.service.RemoveACLRule(t.Context(), "demo", "default", id) + var agentErr *codemode.AgentError + require.ErrorAs(t, err, &agentErr) + } +} + +func TestImpairRejectsJitterWithoutLatency(t *testing.T) { + t.Parallel() + + tc := newTestContext(t) + err := tc.service.ImpairNIC(t.Context(), compute.Ref{Sandbox: "demo", Name: "web"}, "eth0", compute.Impairment{ + JitterMS: 5, + }) + requireAgentMessage(t, err, "jitter_ms requires latency_ms") +} + +func TestACLAllowsCannotOverrideBaseline(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + direction string + dst string + }{ + {name: "wildcard", direction: "egress"}, + {name: "supernet", direction: "egress", dst: "10.0.0.0/8"}, + {name: "management subset", direction: "egress", dst: "10.10.10.128/25"}, + {name: "OOB host", direction: "egress", dst: "10.10.70.20"}, + {name: "mapped prefix", direction: "egress", dst: "::ffff:10.10.10.0/120"}, + {name: "unbounded selector", direction: "egress", dst: "@external"}, + {name: "reversed ingress", direction: "ingress"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + tc := newTestContext(t) + _, err := tc.service.AddACLRule(t.Context(), "demo", "default", compute.ACLRule{ + Direction: test.direction, + Action: "allow", + Dst: test.dst, + }) + var agentErr *codemode.AgentError + require.ErrorAs(t, err, &agentErr) + }) + } +} diff --git a/internal/compute/types.go b/internal/compute/types.go index f7e5cc7..95f37cb 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -25,6 +25,8 @@ type Sandbox struct { Subject string // Host is the member shared by this sandbox's bridge-backed instances. Host string + // NetworkKind records the default fabric; empty denotes a legacy bridge sandbox. + NetworkKind string // CreatedAt is the original creation time. CreatedAt time.Time // ExpiresAt is the persisted reaper deadline. @@ -75,6 +77,8 @@ type Network struct { Name string // PhysicalName is the opaque Incus bridge identifier, never exposed in DTOs. PhysicalName string + // Project is the Incus project containing the managed network. + Project string // Kind is bridge or ovn. Kind string // CIDR is the configured network prefix. @@ -198,4 +202,23 @@ type Backend interface { ListNetworks(context.Context, string) ([]Network, error) CreateNetwork(context.Context, string, Network) (Network, error) AttachNIC(context.Context, Ref, string, string, string, string) (NIC, error) + GetNetwork(context.Context, string, string) (Network, error) + DeleteNetwork(context.Context, string, string) error + DetachNIC(context.Context, Ref, string) error + PeerNetworks(context.Context, string, string, string) error + AddACLRule(context.Context, string, string, ACLRule) (ACLRule, error) + RemoveACLRule(context.Context, string, string, string) error + CreateForward(context.Context, string, string, Ref, int64, int64, string) (Forward, error) + StartInstance(context.Context, Ref, bool) (Instance, error) + StopInstance(context.Context, Ref, bool) (Instance, error) + RestartInstance(context.Context, Ref, bool) (Instance, error) + WaitInstance(context.Context, WaitRequest) (WaitResult, error) + ReadFile(context.Context, FileReadRequest) (FileReadResult, error) + WriteFile(context.Context, FileWriteRequest) (FileWriteResult, error) + CreateSnapshot(context.Context, Ref, string) error + RestoreSnapshot(context.Context, Ref, string) error + DeleteSnapshot(context.Context, Ref, string) error + ListSnapshots(context.Context, Ref) ([]Snapshot, error) + PublishInstance(context.Context, Ref, string) (string, error) + GetSandboxImage(context.Context, string, string) (CatalogImage, error) } diff --git a/internal/incus/client.go b/internal/incus/client.go index 32967fd..2bbd03d 100644 --- a/internal/incus/client.go +++ b/internal/incus/client.go @@ -38,6 +38,7 @@ const ( versionValue = "1" projectPrefix = "ac-" imagesRemoteName = "images" + defaultHost = "lab01" defaultLogicalNetwork = "default" defaultNICName = "eth0" rootDeviceName = "root" @@ -46,12 +47,19 @@ const ( kindVM = "vm" networkKindBridge = "bridge" configTrue = "true" + configFalse = "false" configNone = "none" configManaged = "managed" configBlock = "block" deviceTypeKey = "type" deviceTypeNIC = "nic" deviceNetworkKey = "network" + ipv4AddressKey = "ipv4.address" + ipv4NATKey = "ipv4.nat" + ipv4DHCPKey = "ipv4.dhcp" + dnsModeKey = "dns.mode" + addressAuto = "auto" + featuresNetworksKey = "features.networks" bytesPerMiB = 1024 * 1024 bytesPerGiB = 1024 * bytesPerMiB @@ -88,24 +96,38 @@ type Options struct { // Pool is the storage pool used for explicit root disks. Pool string + + // OVNUplink is the default-project physical network for sandbox OVN networks. + OVNUplink string + + // OVNRanges is the external subnet authorization on the physical uplink. + OVNRanges string } // Client is the Incus adapter used by compute.Service. type Client struct { - server incusclient.InstanceServer - cfg *cliconfig.Config - host string - pool string + server incusclient.InstanceServer + cfg *cliconfig.Config + host string + pool string + ovnUplink string + ovnRanges string } // New connects to Incus and returns a Client. func New(ctx context.Context, opts Options) (*Client, error) { if opts.Host == "" { - return nil, errors.New("incus host is required") + opts.Host = defaultHost } if opts.Pool == "" { return nil, errors.New("incus pool is required") } + if opts.OVNUplink == "" { + opts.OVNUplink = defaultOVNUplink + } + if opts.OVNRanges == "" { + opts.OVNRanges = defaultOVNRanges + } cfg, err := cliconfig.LoadConfig("") if err != nil { @@ -124,10 +146,12 @@ func New(ctx context.Context, opts Options) (*Client, error) { } return &Client{ - server: server, - cfg: cfg, - host: opts.Host, - pool: opts.Pool, + server: server, + cfg: cfg, + host: opts.Host, + pool: opts.Pool, + ovnUplink: opts.OVNUplink, + ovnRanges: opts.OVNRanges, }, nil } @@ -442,13 +466,18 @@ func parseSandbox(project api.Project) (compute.Sandbox, bool) { } created, _ := parseTime(project.Config[metaCreatedAt]) expires, _ := parseTime(project.Config[metaExpiresAt]) + networkKind := networkKindBridge + if isTrue(project.Config[featuresNetworksKey]) { + networkKind = networkKindOVN + } return compute.Sandbox{ - Name: name, - Platform: platformIncus, - Subject: project.Config[metaSubject], - Host: project.Config[metaHost], - CreatedAt: created, - ExpiresAt: expires, + Name: name, + Platform: platformIncus, + Subject: project.Config[metaSubject], + Host: project.Config[metaHost], + NetworkKind: networkKind, + CreatedAt: created, + ExpiresAt: expires, }, true } diff --git a/internal/incus/instance.go b/internal/incus/instance.go index 8b523af..0115cde 100644 --- a/internal/incus/instance.go +++ b/internal/incus/instance.go @@ -47,20 +47,12 @@ func (c *Client) BeginCreateInstance(ctx context.Context, req compute.CreateInst if req.Ref.Sandbox == "" || req.Ref.Name == "" { return nil, errors.New("instance reference is required") } - project, _, err := c.getProject(ctx, req.Ref.Sandbox) - if err != nil { + if _, _, err := c.getProject(ctx, req.Ref.Sandbox); err != nil { return nil, err } - sandbox, ok := parseSandbox(*project) - if !ok { - return nil, compute.ErrNotFound - } - host := sandbox.Host - if host == "" { - host = c.host - } - if req.Host != "" && req.Host != host { - return nil, fmt.Errorf("host %q is not the sandbox member %q", req.Host, host) + host, err := c.resolveTarget(ctx, req.Host) + if err != nil { + return nil, err } source, err := c.instanceSource(ctx, projectName(req.Ref.Sandbox), req.Image) @@ -307,7 +299,7 @@ func (c *Client) forceDeleteInstance(ctx context.Context, sandbox, name string) if instance.StatusCode != api.Stopped { op, stopErr := srv.UpdateInstanceState(name, api.InstanceStatePut{ - Action: "stop", + Action: stopAction, Timeout: -1, Force: true, }, "") @@ -329,16 +321,16 @@ func (c *Client) startInstance(ctx context.Context, project, name string) error if err != nil { return mapError(err) } - if state.StatusCode == api.Running || state.StatusCode == api.Ready { + if instanceRunningOrReady(state.StatusCode) { return nil } op, err := srv.UpdateInstanceState(name, api.InstanceStatePut{ - Action: "start", + Action: startAction, Timeout: -1, }, "") if err != nil { if state, _, stateErr := srv.GetInstanceState(name); stateErr == nil && - (state.StatusCode == api.Running || state.StatusCode == api.Ready) { + instanceRunningOrReady(state.StatusCode) { return nil } return mapError(err) @@ -356,7 +348,7 @@ func (c *Client) waitRunning(ctx context.Context, project, name string) error { if err != nil { return mapError(err) } - if state.StatusCode == api.Running || state.StatusCode == api.Ready { + if instanceRunningOrReady(state.StatusCode) { return nil } if state.StatusCode == api.Error { @@ -377,6 +369,12 @@ func (c *Client) instanceSource( project string, image compute.CatalogImage, ) (api.InstanceSource, error) { + if isSandboxImage(image) { + return api.InstanceSource{ + Type: sourceTypeImage, + Fingerprint: image.Fingerprint, + }, nil + } if isUpstreamRef(image.Reference) { remote, alias, _ := splitRemoteAlias(image.Reference) server, err := c.RemoteImage(ctx, remote) @@ -411,6 +409,10 @@ func (c *Client) instanceSource( }, nil } +func isSandboxImage(image compute.CatalogImage) bool { + return image.Fingerprint != "" && image.Reference == "" +} + func (c *Client) copyImage(ctx context.Context, project string, image compute.CatalogImage) (string, error) { fingerprint := image.Fingerprint if fingerprint == "" { @@ -509,7 +511,12 @@ func (c *Client) mapNICs(ctx context.Context, sandbox string, full *api.Instance return nil, err } for _, network := range networks { - logicalByPhysical[network.PhysicalName] = network.Name + if network.PhysicalName != "" { + logicalByPhysical[network.PhysicalName] = network.Name + } + if network.Name != "" { + logicalByPhysical[network.Name] = network.Name + } } nics := make([]compute.NIC, 0) diff --git a/internal/incus/instance_file.go b/internal/incus/instance_file.go new file mode 100644 index 0000000..9ac1840 --- /dev/null +++ b/internal/incus/instance_file.go @@ -0,0 +1,111 @@ +package incus + +import ( + "context" + "errors" + "fmt" + "io" + "strconv" + "strings" + + incusclient "github.com/lxc/incus/v7/client" + "github.com/meigma/codemode" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const defaultFileReadLimit = 64 * 1024 + +// ReadFile pulls a bounded guest file through the Incus agent. +func (c *Client) ReadFile(ctx context.Context, req compute.FileReadRequest) (compute.FileReadResult, error) { + if err := c.requireInstance(ctx, req.Ref); err != nil { + return compute.FileReadResult{}, err + } + srv := c.Scoped(ctx, projectName(req.Ref.Sandbox), "") + body, info, err := srv.GetInstanceFile(req.Ref.Name, req.Path) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return compute.FileReadResult{}, fileErrorf( + "file not found on instance %q in sandbox %q", + req.Ref.Name, + req.Ref.Sandbox, + ) + } + return compute.FileReadResult{}, mapError(err) + } + if info != nil && info.Type == "directory" { + if body != nil { + _ = body.Close() + } + return compute.FileReadResult{}, fileErrorf( + "path is a directory on instance %q in sandbox %q", + req.Ref.Name, + req.Ref.Sandbox, + ) + } + if body == nil { + return compute.FileReadResult{}, errors.New("file read returned no content") + } + defer body.Close() + + limit := req.MaxBytes + if limit <= 0 { + limit = defaultFileReadLimit + } + buf, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return compute.FileReadResult{}, fmt.Errorf("read file: %w", err) + } + truncated := int64(len(buf)) > limit + if truncated { + buf = buf[:limit] + } + return compute.FileReadResult{Content: string(buf), Truncated: truncated}, nil +} + +// WriteFile pushes a bounded guest file through the Incus agent. +func (c *Client) WriteFile(ctx context.Context, req compute.FileWriteRequest) (compute.FileWriteResult, error) { + if err := c.requireInstance(ctx, req.Ref); err != nil { + return compute.FileWriteResult{}, err + } + mode := -1 + if req.Mode != "" { + parsed, err := parseOctalMode(req.Mode) + if err != nil { + return compute.FileWriteResult{}, err + } + mode = parsed + } + err := c.Scoped(ctx, projectName(req.Ref.Sandbox), ""). + CreateInstanceFile(req.Ref.Name, req.Path, incusclient.InstanceFileArgs{ + Content: strings.NewReader(req.Content), + UID: -1, + GID: -1, + Mode: mode, + Type: "file", + WriteMode: "overwrite", + }) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return compute.FileWriteResult{}, fileErrorf( + "file not found on instance %q in sandbox %q", + req.Ref.Name, + req.Ref.Sandbox, + ) + } + return compute.FileWriteResult{}, mapError(err) + } + return compute.FileWriteResult{Bytes: int64(len(req.Content))}, nil +} + +func parseOctalMode(mode string) (int, error) { + value, err := strconv.ParseUint(mode, 8, 32) + if err != nil { + return 0, fileErrorf("mode %q is not an octal file mode", mode) + } + return int(value), nil +} + +func fileErrorf(format string, args ...any) error { + return &codemode.AgentError{Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/incus/instance_file_test.go b/internal/incus/instance_file_test.go new file mode 100644 index 0000000..d0ab43d --- /dev/null +++ b/internal/incus/instance_file_test.go @@ -0,0 +1,57 @@ +package incus + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lxc/incus/v7/shared/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +func TestReadFileReturnsAtLimitWithoutDrainingRemainder(t *testing.T) { + t.Parallel() + fixture := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/1.0": + writeIncusSync(w, api.Server{ServerUntrusted: api.ServerUntrusted{ + Auth: "trusted", APIExtensions: []string{"projects", "instances"}, + }}) + case "/1.0/projects/ac-demo": + writeIncusSync(w, api.Project{Name: "ac-demo", ProjectPut: api.ProjectPut{ + Config: map[string]string{metaVersion: versionValue}, + }}) + case "/1.0/instances/web": + writeIncusSync(w, api.Instance{Name: "web"}) + case "/1.0/instances/web/files": + w.Header().Set("X-Incus-type", "file") + _, _ = w.Write([]byte("012345678")) + w.(http.Flusher).Flush() + select { + case <-r.Context().Done(): + case <-time.After(3 * time.Second): + } + default: + writeIncusError(w, http.StatusNotFound, "not found") + } + })) + t.Cleanup(fixture.Close) + sdk := connectFixture(t, fixture.URL) + t.Cleanup(sdk.Disconnect) + client := &Client{server: sdk} + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + result, err := client.ReadFile(ctx, compute.FileReadRequest{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, Path: "/tmp/stream", MaxBytes: 8, + }) + require.NoError(t, err) + require.NoError(t, ctx.Err(), "bounded reads must not wait for the rest of the file") + assert.Equal(t, "01234567", result.Content) + assert.True(t, result.Truncated) +} diff --git a/internal/incus/instance_lifecycle.go b/internal/incus/instance_lifecycle.go new file mode 100644 index 0000000..5092513 --- /dev/null +++ b/internal/incus/instance_lifecycle.go @@ -0,0 +1,226 @@ +package incus + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/lxc/incus/v7/shared/api" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +// StartInstance starts a guest and waits until it is running. +func (c *Client) StartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + if err := c.requireInstance(ctx, ref); err != nil { + return compute.Instance{}, err + } + if err := c.changeState(ctx, ref, startAction, force); err != nil { + return compute.Instance{}, err + } + if err := c.waitRunning(ctx, projectName(ref.Sandbox), ref.Name); err != nil { + return compute.Instance{}, err + } + return c.GetInstance(ctx, ref) +} + +// StopInstance stops a guest and waits until it is stopped. +func (c *Client) StopInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + if err := c.requireInstance(ctx, ref); err != nil { + return compute.Instance{}, err + } + if err := c.changeState(ctx, ref, stopAction, force); err != nil { + return compute.Instance{}, err + } + if err := c.waitStopped(ctx, projectName(ref.Sandbox), ref.Name); err != nil { + return compute.Instance{}, err + } + return c.GetInstance(ctx, ref) +} + +// RestartInstance restarts a guest and waits until it is running. +func (c *Client) RestartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + if err := c.requireInstance(ctx, ref); err != nil { + return compute.Instance{}, err + } + action := "restart" + state, err := c.instanceState(ctx, ref) + if err != nil { + return compute.Instance{}, err + } + if state.StatusCode == api.Stopped { + action = startAction + } + if err := c.changeState(ctx, ref, action, force); err != nil { + return compute.Instance{}, err + } + if err := c.waitRunning(ctx, projectName(ref.Sandbox), ref.Name); err != nil { + return compute.Instance{}, err + } + return c.GetInstance(ctx, ref) +} + +// WaitInstance polls guest state until the requested stage is reached. +func (c *Client) WaitInstance(ctx context.Context, req compute.WaitRequest) (compute.WaitResult, error) { + if err := c.requireInstance(ctx, req.Ref); err != nil { + return compute.WaitResult{}, err + } + lastStatus := "" + for { + if err := ctx.Err(); err != nil { + return compute.WaitResult{Status: lastStatus}, err + } + ok, status, err := c.waitSatisfied(ctx, req) + lastStatus = status + if err != nil { + return compute.WaitResult{Status: status}, err + } + if ok { + return compute.WaitResult{Status: status}, nil + } + timer := time.NewTimer(runningPoll) + select { + case <-ctx.Done(): + timer.Stop() + return compute.WaitResult{Status: lastStatus}, ctx.Err() + case <-timer.C: + } + } +} + +func (c *Client) changeState(ctx context.Context, ref compute.Ref, action string, force bool) error { + srv := c.Scoped(ctx, projectName(ref.Sandbox), "") + state, _, err := srv.GetInstanceState(ref.Name) + if err != nil { + return mapError(err) + } + if stateSatisfiesAction(action, state.StatusCode) { + return nil + } + op, err := srv.UpdateInstanceState(ref.Name, api.InstanceStatePut{ + Action: action, + Timeout: -1, + Force: force, + }, "") + if err != nil { + if state, _, stateErr := srv.GetInstanceState(ref.Name); stateErr == nil && + stateSatisfiesAction(action, state.StatusCode) { + return nil + } + return mapError(err) + } + return waitOp(ctx, op) +} + +func (c *Client) waitStopped(ctx context.Context, project, name string) error { + srv := c.Scoped(ctx, project, "") + for { + if err := ctx.Err(); err != nil { + return err + } + state, _, err := srv.GetInstanceState(name) + if err != nil { + return mapError(err) + } + if state.StatusCode == api.Stopped { + return nil + } + if state.StatusCode == api.Error { + return errors.New("instance entered error state") + } + timer := time.NewTimer(runningPoll) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (c *Client) waitSatisfied(ctx context.Context, req compute.WaitRequest) (bool, string, error) { + full, _, err := c.Scoped(ctx, projectName(req.Ref.Sandbox), "").GetInstanceFull(req.Ref.Name) + if err != nil { + return false, "", mapError(err) + } + status := full.Status + if full.StatusCode == api.Error { + return false, status, errors.New("instance entered error state") + } + switch req.Until { + case compute.WaitUntilRunning: + return instanceRunningOrReady(full.StatusCode), status, nil + case compute.WaitUntilStopped: + return full.StatusCode == api.Stopped, status, nil + case compute.WaitUntilAgent: + return agentWaitReady(full), status, nil + case compute.WaitUntilNetwork: + ready, waitErr := c.networkWaitReady(ctx, req.Ref.Sandbox, full) + return ready, status, waitErr + default: + return false, status, fmt.Errorf("unsupported wait stage %q", req.Until) + } +} + +func instanceRunningOrReady(code api.StatusCode) bool { + return code == api.Running || code == api.Ready +} + +func stateSatisfiesAction(action string, code api.StatusCode) bool { + switch action { + case startAction: + return instanceRunningOrReady(code) + case stopAction: + return code == api.Stopped + default: + return false + } +} + +func agentWaitReady(full *api.InstanceFull) bool { + if !instanceRunningOrReady(full.StatusCode) { + return false + } + if full.Type != string(api.InstanceTypeVM) { + return true + } + return full.State != nil && (full.State.Processes >= 0 || full.State.OSInfo != nil) +} + +func (c *Client) networkWaitReady(ctx context.Context, sandbox string, full *api.InstanceFull) (bool, error) { + if !instanceRunningOrReady(full.StatusCode) { + return false, nil + } + nics, err := c.mapNICs(ctx, sandbox, full) + if err != nil { + return false, err + } + for _, nic := range nics { + if len(nic.Addresses) > 0 { + return true, nil + } + } + return false, nil +} + +func (c *Client) instanceState(ctx context.Context, ref compute.Ref) (*api.InstanceState, error) { + state, _, err := c.Scoped(ctx, projectName(ref.Sandbox), "").GetInstanceState(ref.Name) + if err != nil { + return nil, mapError(err) + } + return state, nil +} + +func (c *Client) requireInstance(ctx context.Context, ref compute.Ref) error { + if ref.Sandbox == "" || ref.Name == "" { + return errors.New("instance reference is required") + } + if _, _, err := c.getProject(ctx, ref.Sandbox); err != nil { + return err + } + if _, _, err := c.Scoped(ctx, projectName(ref.Sandbox), "").GetInstance(ref.Name); err != nil { + return mapError(err) + } + return nil +} diff --git a/internal/incus/instance_publish.go b/internal/incus/instance_publish.go new file mode 100644 index 0000000..bf53683 --- /dev/null +++ b/internal/incus/instance_publish.go @@ -0,0 +1,232 @@ +package incus + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + incusclient "github.com/lxc/incus/v7/client" + "github.com/lxc/incus/v7/shared/api" + "github.com/meigma/codemode" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + metaPublished = metaPrefix + "published" + metaCPUs = metaPrefix + "cpus" + metaMemoryMB = metaPrefix + "memory_mb" + metaDiskGB = metaPrefix + "disk_gb" + metaKind = metaPrefix + "kind" + sourceInstance = "instance" + defaultPublishedCPUs = 1 + defaultPublishedMemoryMB = 512 + defaultPublishedDiskGB = 2 +) + +// PublishInstance creates a project-local image alias from a guest. +// +// The image stays in the sandbox project. Catalog copies already in the +// project are left untouched and nothing is published to a remote or the +// default project. +func (c *Client) PublishInstance(ctx context.Context, ref compute.Ref, image string) (string, error) { + if err := c.requireInstance(ctx, ref); err != nil { + return "", err + } + inst, err := c.GetInstance(ctx, ref) + if err != nil { + return "", err + } + project := projectName(ref.Sandbox) + srv := c.Scoped(ctx, project, "") + source, _, err := srv.GetInstance(ref.Name) + if err != nil { + return "", mapError(err) + } + if source.StatusCode != api.Stopped { + return "", publishErrorf("instance %q must be stopped before publishing", ref.Name) + } + if err = requireMissingImageAlias(srv, ref.Sandbox, image); err != nil { + return "", err + } + op, err := srv.CreateImage(api.ImagesPost{ + ImagePut: api.ImagePut{ + Public: false, + Profiles: []string{}, + Properties: publishedImageProperties(source.Config, inst, ref.Sandbox, image), + }, + Aliases: []api.ImageAlias{{Name: image}}, + Source: &api.ImagesPostSource{ + Type: sourceInstance, + Name: ref.Name, + Project: project, + }, + }, nil) + if err != nil { + if isConflict(err) { + return "", publishErrorf("image %q already exists in sandbox %q", image, ref.Sandbox) + } + return "", mapError(err) + } + if err = waitOp(ctx, op); err != nil { + return "", err + } + fingerprint := imageFingerprint(op.Get().Metadata) + if fingerprint == "" { + return "", fmt.Errorf("publish instance %q returned no image fingerprint", ref.Name) + } + return image, ensureImageAlias(srv, image, fingerprint) +} + +// GetSandboxImage returns a published sandbox-local image as a catalog entry. +func (c *Client) GetSandboxImage(ctx context.Context, sandbox, name string) (compute.CatalogImage, error) { + if _, _, err := c.getProject(ctx, sandbox); err != nil { + return compute.CatalogImage{}, err + } + srv := c.Scoped(ctx, projectName(sandbox), "") + alias, _, err := srv.GetImageAlias(name) + if err != nil { + return compute.CatalogImage{}, mapError(err) + } + image, _, err := srv.GetImage(alias.Target) + if err != nil { + return compute.CatalogImage{}, mapError(err) + } + if image.Properties[metaPublished] != configTrue || image.Properties[metaVersion] != versionValue { + return compute.CatalogImage{}, compute.ErrNotFound + } + kind := image.Properties[metaKind] + if kind == "" { + kind = kindContainer + if image.Type == string(api.InstanceTypeVM) { + kind = kindVM + } + } + cpus := parseInt64(image.Properties[metaCPUs]) + if cpus <= 0 { + cpus = defaultPublishedCPUs + } + memory := parseInt64(image.Properties[metaMemoryMB]) + if memory <= 0 { + memory = defaultPublishedMemoryMB + } + disk := parseInt64(image.Properties[metaDiskGB]) + if disk <= 0 { + disk = defaultPublishedDiskGB + } + return compute.CatalogImage{ + Name: name, + OS: publishedOS(image.Properties), + Version: publishedVersion(image.Properties), + Platform: platformIncus, + Kind: kind, + Kinds: []string{kind}, + Desktop: isTrue(image.Properties[metaDesktop]), + Fingerprint: image.Fingerprint, + CPUs: cpus, + MemoryMB: memory, + DiskGB: disk, + }, nil +} + +func imageFingerprint(metadata map[string]any) string { + if metadata == nil { + return "" + } + raw, ok := metadata["fingerprint"].(string) + if !ok { + return "" + } + return raw +} + +func publishedOS(properties map[string]string) string { + if osName := properties["os"]; osName != "" { + return osName + } + return "unknown" +} + +func publishedVersion(properties map[string]string) string { + if version := properties["release"]; version != "" { + return version + } + if version := properties["version"]; version != "" { + return version + } + return "published" +} + +func publishErrorf(format string, args ...any) error { + return &codemode.AgentError{Message: fmt.Sprintf(format, args...)} +} + +func requireMissingImageAlias(srv incusclient.InstanceServer, sandbox, image string) error { + _, _, err := srv.GetImageAlias(image) + if err == nil { + return publishErrorf("image %q already exists in sandbox %q", image, sandbox) + } + if mapped := mapError(err); !errors.Is(mapped, compute.ErrNotFound) { + return mapped + } + return nil +} + +func ensureImageAlias(srv incusclient.InstanceServer, image, fingerprint string) error { + alias, _, err := srv.GetImageAlias(image) + if err == nil { + if alias.Target != fingerprint { + return publishErrorf("image %q already refers to another fingerprint", image) + } + return nil + } + if mapped := mapError(err); !errors.Is(mapped, compute.ErrNotFound) { + return mapped + } + if err = srv.CreateImageAlias(api.ImageAliasesPost{ + ImageAliasesEntry: api.ImageAliasesEntry{ + Name: image, + ImageAliasesEntryPut: api.ImageAliasesEntryPut{Target: fingerprint}, + }, + }); err != nil { + return mapError(err) + } + return nil +} + +func publishedImageProperties( + config map[string]string, + inst compute.Instance, + sandbox, image string, +) map[string]string { + properties := make(map[string]string) + for key, value := range config { + if suffix, ok := strings.CutPrefix(key, "image."); ok { + properties[suffix] = value + } + } + cpus := inst.CPUs + if cpus <= 0 { + cpus = defaultPublishedCPUs + } + memory := inst.MemoryMB + if memory <= 0 { + memory = defaultPublishedMemoryMB + } + disk := inst.DiskGB + if disk <= 0 { + disk = defaultPublishedDiskGB + } + properties[metaVersion] = versionValue + properties[metaSandbox] = sandbox + properties[metaImage] = image + properties[metaPublished] = configTrue + properties[metaDesktop] = strconv.FormatBool(inst.Desktop) + properties[metaKind] = inst.Kind + properties[metaCPUs] = strconv.FormatInt(cpus, 10) + properties[metaMemoryMB] = strconv.FormatInt(memory, 10) + properties[metaDiskGB] = strconv.FormatInt(disk, 10) + return properties +} diff --git a/internal/incus/instance_snapshot.go b/internal/incus/instance_snapshot.go new file mode 100644 index 0000000..fafc983 --- /dev/null +++ b/internal/incus/instance_snapshot.go @@ -0,0 +1,149 @@ +package incus + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "strings" + + "github.com/lxc/incus/v7/shared/api" + "github.com/meigma/codemode" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +// CreateSnapshot creates a stateless instance snapshot. +func (c *Client) CreateSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + if err := c.requireInstance(ctx, ref); err != nil { + return err + } + op, err := c.Scoped(ctx, projectName(ref.Sandbox), "").CreateInstanceSnapshot(ref.Name, api.InstanceSnapshotsPost{ + Name: snapshot, + Stateful: false, + }) + if err != nil { + if isConflict(err) { + return snapshotErrorf( + "snapshot %q already exists on instance %q in sandbox %q", + snapshot, + ref.Name, + ref.Sandbox, + ) + } + return mapError(err) + } + return waitOp(ctx, op) +} + +// RestoreSnapshot recreates a guest from a snapshot without relaxing project restrictions. +func (c *Client) RestoreSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + if err := c.requireInstance(ctx, ref); err != nil { + return err + } + srv := c.Scoped(ctx, projectName(ref.Sandbox), "") + instance, _, err := srv.GetInstance(ref.Name) + if err != nil { + return mapError(err) + } + source, _, err := srv.GetInstanceSnapshot(ref.Name, snapshot) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return snapshotErrorf("snapshot %q not found on instance %q in sandbox %q", snapshot, ref.Name, ref.Sandbox) + } + return mapError(err) + } + if _, stopErr := c.StopInstance(ctx, ref, true); stopErr != nil { + return stopErr + } + + // The source snapshot disappears with its parent. Stage the copy first. + staged := "restore-" + strings.ToLower(rand.Text()) + config := make(map[string]string, len(source.Config)) + for key, value := range source.Config { + if !strings.HasPrefix(key, "volatile.") { + config[key] = value + } + } + for key, value := range instance.Config { + if strings.HasPrefix(key, metaPrefix) { + config[key] = value + } + } + op, err := c.Scoped(ctx, projectName(ref.Sandbox), instance.Location).CreateInstance(api.InstancesPost{ + Name: staged, + Type: api.InstanceType(instance.Type), + Source: api.InstanceSource{ + Type: "copy", + Source: ref.Name + "/" + snapshot, + }, + InstancePut: api.InstancePut{ + Architecture: source.Architecture, + Config: config, + Devices: source.Devices, + Profiles: source.Profiles, + Ephemeral: source.Ephemeral, + }, + }) + if err != nil { + return mapError(err) + } + if err = waitOp(ctx, op); err != nil { + return fmt.Errorf("copy snapshot to %q: %w", staged, err) + } + if err = c.forceDeleteInstance(ctx, ref.Sandbox, ref.Name); err != nil { + return fmt.Errorf("remove original instance; snapshot copy retained as %q: %w", staged, err) + } + op, err = srv.RenameInstance(staged, api.InstancePost{Name: ref.Name}) + if err != nil { + return fmt.Errorf("replace instance name; snapshot copy retained as %q: %w", staged, mapError(err)) + } + if err = waitOp(ctx, op); err != nil { + return fmt.Errorf("rename snapshot copy %q to %q: %w", staged, ref.Name, err) + } + _, err = c.StartInstance(ctx, ref, false) + return err +} + +// DeleteSnapshot deletes one instance snapshot. +func (c *Client) DeleteSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + if err := c.requireInstance(ctx, ref); err != nil { + return err + } + op, err := c.Scoped(ctx, projectName(ref.Sandbox), "").DeleteInstanceSnapshot(ref.Name, snapshot) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return snapshotErrorf( + "snapshot %q not found on instance %q in sandbox %q", + snapshot, + ref.Name, + ref.Sandbox, + ) + } + return mapError(err) + } + return waitOp(ctx, op) +} + +// ListSnapshots returns snapshots for one guest. +func (c *Client) ListSnapshots(ctx context.Context, ref compute.Ref) ([]compute.Snapshot, error) { + if err := c.requireInstance(ctx, ref); err != nil { + return nil, err + } + items, err := c.Scoped(ctx, projectName(ref.Sandbox), "").GetInstanceSnapshots(ref.Name) + if err != nil { + return nil, mapError(err) + } + out := make([]compute.Snapshot, 0, len(items)) + for _, item := range items { + out = append(out, compute.Snapshot{ + Name: item.Name, + CreatedAt: item.CreatedAt, + }) + } + return out, nil +} + +func snapshotErrorf(format string, args ...any) error { + return &codemode.AgentError{Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/incus/network.go b/internal/incus/network.go index 792dbf3..254bab8 100644 --- a/internal/incus/network.go +++ b/internal/incus/network.go @@ -28,20 +28,34 @@ func (c *Client) ListNetworks(ctx context.Context, sandbox string) ([]compute.Ne return c.listNetworks(ctx, sandbox, project) } -// CreateNetwork defines an all-member opaque bridge and reserves its name. +// CreateNetwork defines a bridge in the default project or an OVN network in the sandbox project. func (c *Client) CreateNetwork(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error) { - if network.Kind != "" && network.Kind != networkKindBridge { - return compute.Network{}, fmt.Errorf("network kind %q is not available", network.Kind) + project, _, err := c.getProject(ctx, sandbox) + if err != nil { + return compute.Network{}, err + } + fabric := networkKindBridge + if featuresNetworks(project) { + fabric = networkKindOVN + } + kind := network.Kind + if kind == "" { + kind = fabric + } + if kind != fabric { + return compute.Network{}, fmt.Errorf("cannot create a %s network in a %s sandbox", kind, fabric) + } + if kind == networkKindOVN { + return c.createOVNNetwork(ctx, sandbox, network) + } + if kind != networkKindBridge { + return compute.Network{}, fmt.Errorf("network kind %q is not available", kind) } logical := network.Name if logical == "" { return compute.Network{}, errors.New("network name is required") } - project, _, err := c.getProject(ctx, sandbox) - if err != nil { - return compute.Network{}, err - } host := project.Config[metaHost] if host == "" { host = c.host @@ -104,7 +118,7 @@ func (c *Client) AttachNIC(ctx context.Context, ref compute.Ref, network, nic, i "name": nicName, } if ip != "" { - device["ipv4.address"] = ip + device[ipv4AddressKey] = ip } if mac != "" { device["hwaddr"] = mac @@ -132,11 +146,88 @@ func (c *Client) AttachNIC(ctx context.Context, ref compute.Ref, network, nic, i return compute.NIC{}, fmt.Errorf("attached NIC %q was not observed", nicName) } +// GetNetwork returns one owned agent-facing network. +func (c *Client) GetNetwork(ctx context.Context, sandbox, name string) (compute.Network, error) { + networks, err := c.ListNetworks(ctx, sandbox) + if err != nil { + return compute.Network{}, err + } + for _, network := range networks { + if network.Name == name { + return network, nil + } + } + return compute.Network{}, compute.ErrNotFound +} + +// DeleteNetwork removes an owned network after dependents are gone. +func (c *Client) DeleteNetwork(ctx context.Context, sandbox, name string) error { + network, err := c.GetNetwork(ctx, sandbox, name) + if err != nil { + return err + } + if network.Kind == networkKindOVN { + return c.deleteOVNNetwork(ctx, sandbox, network.PhysicalName) + } + if err := c.checkBridgeOwnership(ctx, sandbox, network.PhysicalName); err != nil { + return err + } + if errs := c.deleteForwards(ctx, network.PhysicalName); len(errs) > 0 { + return errors.Join(errs...) + } + return c.deleteBridge(ctx, network.PhysicalName) +} + +// DetachNIC removes a NIC device from an instance. +func (c *Client) DetachNIC(ctx context.Context, ref compute.Ref, nic string) error { + if ref.Sandbox == "" || ref.Name == "" || nic == "" { + return errors.New("instance reference and nic are required") + } + srv := c.Scoped(ctx, projectName(ref.Sandbox), "") + instance, etag, err := srv.GetInstance(ref.Name) + if err != nil { + return mapError(err) + } + devices := copyDevices(instance.Devices) + if _, exists := devices[nic]; !exists { + found := false + for name, device := range devices { + if device[deviceTypeKey] == deviceTypeNIC && device["name"] == nic { + delete(devices, name) + found = true + break + } + } + if !found { + return compute.ErrNotFound + } + } else { + delete(devices, nic) + } + instance.Devices = devices + op, err := srv.UpdateInstance(ref.Name, instance.Writable(), etag) + if err != nil { + return mapError(err) + } + return waitOp(ctx, op) +} + func (c *Client) listNetworks(ctx context.Context, sandbox string, project *api.Project) ([]compute.Network, error) { host := c.host - if project != nil { - if project.Config[metaHost] != "" { - host = project.Config[metaHost] + if project != nil && project.Config[metaHost] != "" { + host = project.Config[metaHost] + } + + out := make([]compute.Network, 0) + seen := map[string]struct{}{} + if featuresNetworks(project) { + ovn, err := c.ownedOVNNetworks(ctx, sandbox) + if err != nil { + return nil, err + } + for _, network := range ovn { + seen[network.Name] = struct{}{} + out = append(out, network) } } @@ -144,14 +235,15 @@ func (c *Client) listNetworks(ctx context.Context, sandbox string, project *api. if err != nil { return nil, mapError(err) } - - out := make([]compute.Network, 0) for _, network := range networks { logical := network.Config[metaName] if network.Config[metaSandbox] != sandbox || network.Config[metaVersion] != versionValue || logical == "" { continue } - out = append(out, networkFromAPI(network, logical, host)) + if _, ok := seen[logical]; ok { + continue + } + out = append(out, networkFromAPI(network, logical, host, api.ProjectDefaultName)) } return out, nil } @@ -161,16 +253,53 @@ func (c *Client) ownedBridges(ctx context.Context, sandbox string) ([]compute.Ne if err != nil && !errors.Is(err, compute.ErrNotFound) { return nil, err } - return c.listNetworks(ctx, sandbox, project) + return c.listDefaultBridges(ctx, sandbox, project) +} + +func (c *Client) listDefaultBridges( + ctx context.Context, + sandbox string, + project *api.Project, +) ([]compute.Network, error) { + host := c.host + if project != nil && project.Config[metaHost] != "" { + host = project.Config[metaHost] + } + networks, err := c.Scoped(ctx, api.ProjectDefaultName, "").GetNetworks() + if err != nil { + return nil, mapError(err) + } + out := make([]compute.Network, 0) + for _, network := range networks { + logical := network.Config[metaName] + if network.Config[metaSandbox] != sandbox || network.Config[metaVersion] != versionValue || logical == "" { + continue + } + out = append(out, networkFromAPI(network, logical, host, api.ProjectDefaultName)) + } + return out, nil } func (c *Client) resolvePhysical(ctx context.Context, sandbox, logical string) (string, error) { if logical == "" { logical = defaultLogicalNetwork } - if _, _, err := c.getProject(ctx, sandbox); err != nil { + project, _, err := c.getProject(ctx, sandbox) + if err != nil { return "", err } + if featuresNetworks(project) { + ovns, ovnErr := c.ownedOVNNetworks(ctx, sandbox) + if ovnErr != nil { + return "", ovnErr + } + for _, network := range ovns { + if network.Name == logical { + return network.PhysicalName, nil + } + } + return "", compute.ErrNotFound + } networks, err := c.Scoped(ctx, api.ProjectDefaultName, "").GetNetworks() if err != nil { return "", mapError(err) @@ -386,7 +515,7 @@ func (c *Client) observedNetwork(ctx context.Context, logical, physical, host st if err != nil { return compute.Network{}, mapError(err) } - return networkFromAPI(*network, logical, host), nil + return networkFromAPI(*network, logical, host, api.ProjectDefaultName), nil } func bridgeConfig(sandbox, logical string, network compute.Network) map[string]string { @@ -397,45 +526,49 @@ func bridgeConfig(sandbox, logical string, network compute.Network) map[string]s case network.CIDR != "": address = network.CIDR case network.DHCP || network.NAT || network.DNS: - address = "auto" + address = addressAuto } dnsMode := configNone if network.DNS { dnsMode = configManaged } return map[string]string{ - "ipv4.address": address, - "ipv4.nat": strconv.FormatBool(network.NAT), - "ipv4.dhcp": strconv.FormatBool(network.DHCP), + ipv4AddressKey: address, + ipv4NATKey: strconv.FormatBool(network.NAT), + ipv4DHCPKey: strconv.FormatBool(network.DHCP), "ipv6.address": configNone, - "dns.mode": dnsMode, + dnsModeKey: dnsMode, metaSandbox: sandbox, metaName: logical, metaVersion: versionValue, } } -func networkFromAPI(network api.Network, logical, host string) compute.Network { - cidr := network.Config["ipv4.address"] +func networkFromAPI(network api.Network, logical, host, project string) compute.Network { + cidr := network.Config[ipv4AddressKey] kind := network.Type if kind == "" { kind = networkKindBridge } + if project == "" { + project = api.ProjectDefaultName + } return compute.Network{ Name: logical, PhysicalName: network.Name, + Project: project, Kind: kind, CIDR: cidr, Gateway: gatewayIP(cidr), Host: host, - DHCP: isTrue(network.Config["ipv4.dhcp"]), - NAT: isTrue(network.Config["ipv4.nat"]), - DNS: network.Config["dns.mode"] != configNone, + DHCP: isTrue(network.Config[ipv4DHCPKey]), + NAT: isTrue(network.Config[ipv4NATKey]), + DNS: network.Config[dnsModeKey] != configNone, } } func gatewayIP(cidr string) string { - if cidr == "" || cidr == "auto" || cidr == configNone { + if cidr == "" || cidr == addressAuto || cidr == configNone { return "" } ip, _, err := net.ParseCIDR(cidr) diff --git a/internal/incus/network_test.go b/internal/incus/network_test.go index c0bd720..68430c3 100644 --- a/internal/incus/network_test.go +++ b/internal/incus/network_test.go @@ -133,3 +133,181 @@ func writeFixtureNetwork(w http.ResponseWriter, networks map[string]api.Network, } writeIncusSync(w, network) } + +func TestForwardAllocationHandlesCompetingClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode string + }{ + {name: "retries after a confirmed collision", mode: "collision"}, + {name: "does not duplicate after an uncertain commit", mode: "uncertain commit"}, + {name: "does not retry an unrelated failure", mode: "unrelated failure"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + state := newForwardClaimState(tt.mode) + client := newForwardClaimClient(t, state) + result, err := client.CreateForward( + t.Context(), + "demo", + "lan", + compute.Ref{Sandbox: "demo", Name: "web"}, + 80, + 8080, + "tcp", + ) + assertForwardClaimResult(t, tt.mode, result, err) + assertForwardClaimRetention(t, state) + }) + } +} + +type forwardClaimState struct { + mu sync.Mutex + mode string + occupied bool + attempted bool + forwards []api.NetworkForward + network api.Network +} + +func newForwardClaimState(mode string) *forwardClaimState { + return &forwardClaimState{ + mode: mode, + network: api.Network{ + Name: "lan", Type: networkKindOVN, Status: api.NetworkStatusCreated, + NetworkPut: api.NetworkPut{Config: map[string]string{ + metaSandbox: "demo", + metaName: "lan", + metaVersion: versionValue, + ipv4AddressKey: "192.168.82.1/24", + ipv4NATKey: configTrue, + }}, + }, + } +} + +func newForwardClaimClient(t *testing.T, state *forwardClaimState) *Client { + t.Helper() + fixture := httptest.NewTLSServer(state.handler(t)) + t.Cleanup(fixture.Close) + sdk := connectFixture(t, fixture.URL) + t.Cleanup(sdk.Disconnect) + return &Client{server: sdk, ovnRanges: "10.10.40.64-10.10.40.65"} +} + +func (s *forwardClaimState) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + switch r.URL.Path { + case "/1.0": + writeIncusSync(w, api.Server{ServerUntrusted: api.ServerUntrusted{ + Auth: "trusted", + APIExtensions: []string{ + "projects", + "instances", + "instance_get_full", + "network", + "networks_all_projects", + "network_forward", + "network_allocations", + }, + }}) + case "/1.0/projects/ac-demo": + writeIncusSync(w, api.Project{Name: "ac-demo", ProjectPut: api.ProjectPut{ + Config: map[string]string{metaVersion: versionValue, featuresNetworksKey: configTrue}, + }}) + case "/1.0/instances/web": + writeIncusSync(w, forwardClaimInstance()) + case "/1.0/networks/lan": + writeIncusSync(w, s.network) + case "/1.0/networks": + writeIncusSync(w, []api.Network{s.network}) + case "/1.0/network-allocations": + s.writeAllocations(w) + case "/1.0/networks/lan/forwards": + s.handleForwards(w, r) + default: + t.Errorf("unhandled fixture request: %s %s", r.Method, r.URL) + writeIncusError(w, http.StatusNotFound, "not found") + } + }) +} + +func forwardClaimInstance() api.InstanceFull { + return api.InstanceFull{ + Instance: api.Instance{Name: "web", InstancePut: api.InstancePut{ + Devices: map[string]map[string]string{ + "eth0": {deviceTypeKey: deviceTypeNIC, deviceNetworkKey: "lan"}, + }, + }}, + State: &api.InstanceState{Network: map[string]api.InstanceStateNetwork{ + "eth0": { + Addresses: []api.InstanceStateNetworkAddress{ + {Address: "192.168.82.2", Family: "inet", Scope: "global"}, + }, + }, + }}, + } +} + +func (s *forwardClaimState) writeAllocations(w http.ResponseWriter) { + var allocations []api.NetworkAllocations + if s.occupied { + allocations = append(allocations, api.NetworkAllocations{Address: "10.10.40.64/32"}) + } + writeIncusSync(w, allocations) +} + +func (s *forwardClaimState) handleForwards(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeIncusSync(w, s.forwards) + return + } + var request api.NetworkForwardsPost + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeIncusError(w, http.StatusBadRequest, err.Error()) + return + } + created := api.NetworkForward{ + ListenAddress: request.ListenAddress, NetworkForwardPut: request.NetworkForwardPut, + } + if !s.attempted { + s.attempted = true + s.occupied = s.mode != "unrelated failure" + if s.mode == "uncertain commit" { + s.forwards = append(s.forwards, created) + } + writeIncusError(w, http.StatusInternalServerError, "create failed") + return + } + s.forwards = append(s.forwards, created) + writeIncusSync(w, nil) +} + +func assertForwardClaimResult(t *testing.T, mode string, result compute.Forward, err error) { + t.Helper() + if mode == "collision" { + require.NoError(t, err) + assert.Equal(t, "10.10.40.65", result.Address) + return + } + require.Error(t, err) + assert.True(t, api.StatusErrorCheck(err, http.StatusInternalServerError)) +} + +func assertForwardClaimRetention(t *testing.T, state *forwardClaimState) { + t.Helper() + state.mu.Lock() + defer state.mu.Unlock() + if state.mode == "unrelated failure" { + assert.Empty(t, state.forwards) + return + } + require.Len(t, state.forwards, 1, "an uncertain commit must not create another forward") +} diff --git a/internal/incus/ovn.go b/internal/incus/ovn.go new file mode 100644 index 0000000..0d64752 --- /dev/null +++ b/internal/incus/ovn.go @@ -0,0 +1,303 @@ +package incus + +import ( + "context" + "errors" + "fmt" + "net" + "slices" + "strconv" + "strings" + "time" + + "github.com/lxc/incus/v7/shared/api" + "github.com/meigma/codemode" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + networkKindOVN = "ovn" + defaultOVNUplink = "fast40-uplink" + defaultOVNRanges = "10.10.40.64/26" + ovnMutationTimeout = 20 * time.Second + securityACLKey = "security.acls" + securityACLIngressKey = "security.acls.default.ingress.action" + securityACLEgressKey = "security.acls.default.egress.action" + aclActionAllow = "allow" + dnsModeManaged = "managed" +) + +func (c *Client) ovnUplinkName() string { + if c.ovnUplink != "" { + return c.ovnUplink + } + return defaultOVNUplink +} + +func (c *Client) ovnRangeSpec() string { + if c.ovnRanges != "" { + return c.ovnRanges + } + return defaultOVNRanges +} + +func (c *Client) ovnContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, ovnMutationTimeout) +} + +func ovnUnavailable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, compute.ErrUnavailable) || errors.Is(err, context.DeadlineExceeded) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "northbound") || + strings.Contains(msg, "ovn") && (strings.Contains(msg, "connect") || + strings.Contains(msg, "unavailable") || + strings.Contains(msg, "timed out") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "database")) +} + +func mapOVNError(err error) error { + if err == nil { + return nil + } + mapped := mapError(err) + if ovnUnavailable(mapped) { + return fmt.Errorf("%w", compute.ErrUnavailable) + } + return mapped +} + +func (c *Client) createOVNNetwork( + ctx context.Context, + sandbox string, + network compute.Network, +) (compute.Network, error) { + logical := network.Name + if logical == "" { + return compute.Network{}, errors.New("network name is required") + } + ctx, cancel := c.ovnContext(ctx) + defer cancel() + config, err := ovnConfig(sandbox, logical, network, c.ovnUplinkName()) + if err != nil { + return compute.Network{}, err + } + + existing, err := c.projectNetwork(ctx, sandbox, logical) + if err != nil && !errors.Is(err, compute.ErrNotFound) { + return compute.Network{}, mapOVNError(err) + } + if err == nil { + return matchingOVNNetwork(existing, sandbox, logical, config) + } + + if err = c.ensureNetworkACLs(ctx, sandbox, logical); err != nil { + return compute.Network{}, err + } + + err = c.Scoped(ctx, projectName(sandbox), "").CreateNetwork(api.NetworksPost{ + Name: logical, + Type: networkKindOVN, + NetworkPut: api.NetworkPut{Config: config}, + }) + if err != nil { + if isConflict(err) { + created, getErr := c.projectNetwork(ctx, sandbox, logical) + if getErr == nil { + return matchingOVNNetwork(created, sandbox, logical, config) + } + } + return compute.Network{}, mapOVNError(err) + } + return c.observedProjectNetwork(ctx, sandbox, logical) +} + +func matchingOVNNetwork( + existing *api.Network, + sandbox, logical string, + desired map[string]string, +) (compute.Network, error) { + if existing.Config[metaSandbox] != sandbox || existing.Config[metaVersion] != versionValue { + return compute.Network{}, &codemode.AgentError{ + Message: fmt.Sprintf("network %q is not owned by sandbox %q", logical, sandbox), + } + } + if existing.Status != api.NetworkStatusCreated { + return compute.Network{}, fmt.Errorf("%w", compute.ErrUnavailable) + } + if existing.Type != networkKindOVN { + return compute.Network{}, &codemode.AgentError{ + Message: fmt.Sprintf("network %q already exists with a different kind", logical), + } + } + for key, value := range desired { + if key == ipv4AddressKey && value == addressAuto { + continue + } + if existing.Config[key] != value { + return compute.Network{}, &codemode.AgentError{ + Message: fmt.Sprintf( + "network %q already exists with a different %s setting; delete it before recreating it", + logical, + key, + ), + } + } + } + return networkFromAPI(*existing, logical, "", projectName(sandbox)), nil +} + +func (c *Client) projectNetwork(ctx context.Context, sandbox, name string) (*api.Network, error) { + network, _, err := c.Scoped(ctx, projectName(sandbox), "").GetNetwork(name) + if err != nil { + return nil, mapError(err) + } + return network, nil +} + +func (c *Client) observedProjectNetwork(ctx context.Context, sandbox, logical string) (compute.Network, error) { + network, err := c.projectNetwork(ctx, sandbox, logical) + if err != nil { + return compute.Network{}, err + } + return networkFromAPI(*network, logical, "", projectName(sandbox)), nil +} + +func (c *Client) ownedOVNNetworks(ctx context.Context, sandbox string) ([]compute.Network, error) { + if _, _, err := c.getProject(ctx, sandbox); err != nil { + if errors.Is(err, compute.ErrNotFound) { + return nil, nil + } + return nil, err + } + networks, err := c.Scoped(ctx, projectName(sandbox), "").GetNetworks() + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return nil, nil + } + return nil, mapError(err) + } + out := make([]compute.Network, 0) + for _, network := range networks { + if network.Type != networkKindOVN { + continue + } + logical := network.Config[metaName] + if logical == "" { + logical = network.Name + } + if network.Config[metaSandbox] != sandbox || network.Config[metaVersion] != versionValue { + continue + } + out = append(out, networkFromAPI(network, logical, "", projectName(sandbox))) + } + return out, nil +} + +func (c *Client) deleteOVNNetwork(ctx context.Context, sandbox, name string) error { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + if err := c.deleteForwardsInProject(ctx, projectName(sandbox), name); err != nil { + joined := errors.Join(err...) + if joined != nil { + return joined + } + } + if err := c.deletePeers(ctx, sandbox, name); err != nil { + joined := errors.Join(err...) + if joined != nil { + return joined + } + } + err := c.Scoped(ctx, projectName(sandbox), "").DeleteNetwork(name) + if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + return mapOVNError(err) + } + srv := c.Scoped(ctx, projectName(sandbox), "") + aclName := agentACLName(name) + acl, _, err := srv.GetNetworkACL(aclName) + if errors.Is(mapError(err), compute.ErrNotFound) { + return nil + } + if err != nil { + return mapOVNError(err) + } + if acl.Config[metaSandbox] != sandbox || acl.Config[metaVersion] != versionValue || + acl.Config[metaName] != name { + return fmt.Errorf("acl %q is not owned by network %q", aclName, name) + } + return mapOVNError(srv.DeleteNetworkACL(aclName)) +} + +func ovnConfig(sandbox, logical string, network compute.Network, uplink string) (map[string]string, error) { + address := addressAuto + if network.Gateway != "" && strings.Contains(network.Gateway, "/") { + address = network.Gateway + } else if network.CIDR != "" { + converted, err := routerCIDR(network.CIDR) + if err != nil { + return nil, err + } + address = converted + } + dnsMode := configNone + if network.DNS { + dnsMode = dnsModeManaged + } + if !network.NAT { + uplink = configNone + } + return map[string]string{ + deviceNetworkKey: uplink, + ipv4AddressKey: address, + ipv4NATKey: strconv.FormatBool(network.NAT), + ipv4DHCPKey: strconv.FormatBool(network.DHCP), + "ipv6.address": configNone, + dnsModeKey: dnsMode, + securityACLKey: aclNameBaseline + "," + agentACLName(logical), + securityACLIngressKey: aclActionAllow, + securityACLEgressKey: aclActionAllow, + metaSandbox: sandbox, + metaName: logical, + metaVersion: versionValue, + }, nil +} + +func routerCIDR(cidr string) (string, error) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return "", fmt.Errorf("invalid cidr %q", cidr) + } + ones, bits := ipnet.Mask.Size() + if ones == 0 || ones >= bits { + return "", fmt.Errorf("invalid cidr %q", cidr) + } + if !ip.Equal(ipnet.IP) { + return ip.String() + "/" + strconv.Itoa(ones), nil + } + next := make(net.IP, len(ipnet.IP)) + copy(next, ipnet.IP) + for i := range slices.Backward(next) { + next[i]++ + if next[i] != 0 { + break + } + } + if !ipnet.Contains(next) { + return "", fmt.Errorf("invalid cidr %q", cidr) + } + return next.String() + "/" + strconv.Itoa(ones), nil +} + +func featuresNetworks(project *api.Project) bool { + if project == nil { + return false + } + return isTrue(project.Config[featuresNetworksKey]) +} diff --git a/internal/incus/ovn_acl.go b/internal/incus/ovn_acl.go new file mode 100644 index 0000000..ad514f8 --- /dev/null +++ b/internal/incus/ovn_acl.go @@ -0,0 +1,270 @@ +package incus + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/lxc/incus/v7/shared/api" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + aclNameBaseline = "baseline" + aclRuleIDPrefix = "agentcompute:id=" + aclDirectionIngress = "ingress" + aclDirectionEgress = "egress" +) + +func (c *Client) ensureNetworkACLs(ctx context.Context, sandbox, network string) error { + if err := c.ensureBaselineACL(ctx, sandbox); err != nil { + return err + } + return c.ensureAgentACL(ctx, sandbox, network) +} + +func agentACLName(network string) string { + sum := sha256.Sum256([]byte(network)) + return "agent-" + hex.EncodeToString(sum[:8]) +} + +func (c *Client) ensureBaselineACL(ctx context.Context, sandbox string) error { + existing, _, err := c.Scoped(ctx, projectName(sandbox), "").GetNetworkACL(aclNameBaseline) + if err == nil { + if existing.Config[metaSandbox] == sandbox && existing.Config[metaVersion] == versionValue { + return nil + } + return fmt.Errorf("acl %q is not owned by sandbox %q", aclNameBaseline, sandbox) + } + if !errors.Is(mapError(err), compute.ErrNotFound) { + return mapOVNError(err) + } + return c.putACL(ctx, sandbox, aclNameBaseline, api.NetworkACLPut{ + Description: "agentcompute baseline management and OOB drops", + Ingress: []api.NetworkACLRule{}, + Egress: []api.NetworkACLRule{ + baselineRule(compute.BaselineEgressMgmt, "10.10.10.0/24"), + baselineRule(compute.BaselineEgressOOB, "10.10.70.0/24"), + }, + Config: map[string]string{ + metaSandbox: sandbox, + metaVersion: versionValue, + metaName: aclNameBaseline, + }, + }) +} + +func baselineRule(id, cidr string) api.NetworkACLRule { + return api.NetworkACLRule{ + Action: "drop", + State: "enabled", + Destination: cidr, + Description: aclRuleIDPrefix + id, + } +} + +func (c *Client) ensureAgentACL(ctx context.Context, sandbox, network string) error { + name := agentACLName(network) + existing, _, err := c.Scoped(ctx, projectName(sandbox), "").GetNetworkACL(name) + if err == nil { + if existing.Config[metaSandbox] == sandbox && existing.Config[metaVersion] == versionValue { + return nil + } + return fmt.Errorf("acl %q is not owned by sandbox %q", name, sandbox) + } + if !errors.Is(mapError(err), compute.ErrNotFound) { + return mapOVNError(err) + } + return c.putACL(ctx, sandbox, name, api.NetworkACLPut{ + Description: "agentcompute agent ACL", + Ingress: []api.NetworkACLRule{}, + Egress: []api.NetworkACLRule{}, + Config: map[string]string{ + metaSandbox: sandbox, + metaVersion: versionValue, + metaName: network, + }, + }) +} + +func (c *Client) putACL(ctx context.Context, sandbox, name string, spec api.NetworkACLPut) error { + srv := c.Scoped(ctx, projectName(sandbox), "") + existing, etag, err := srv.GetNetworkACL(name) + if errors.Is(mapError(err), compute.ErrNotFound) { + return mapOVNError(srv.CreateNetworkACL(api.NetworkACLsPost{ + NetworkACLPost: api.NetworkACLPost{Name: name}, + NetworkACLPut: spec, + })) + } + if err != nil { + return mapOVNError(err) + } + if existing.Config[metaSandbox] != sandbox || existing.Config[metaVersion] != versionValue { + return fmt.Errorf("acl %q is not owned by sandbox %q", name, sandbox) + } + if name == aclNameBaseline { + spec.Ingress = existing.Ingress + spec.Egress = existing.Egress + if spec.Config == nil { + spec.Config = map[string]string{} + } + for key, value := range existing.Config { + if spec.Config[key] == "" { + spec.Config[key] = value + } + } + } + return mapOVNError(srv.UpdateNetworkACL(name, spec, etag)) +} + +func (c *Client) AddACLRule( + ctx context.Context, + sandbox, network string, + rule compute.ACLRule, +) (compute.ACLRule, error) { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + if _, err := c.ownedProjectNetwork(ctx, sandbox, network); err != nil { + return compute.ACLRule{}, err + } + if err := c.ensureNetworkACLs(ctx, sandbox, network); err != nil { + return compute.ACLRule{}, err + } + if rule.ID == "" { + id, err := randomRuleID() + if err != nil { + return compute.ACLRule{}, err + } + rule.ID = id + } + if computeRuleIsBaseline(rule.ID) { + return compute.ACLRule{}, fmt.Errorf("rule %q is a baseline ACL and cannot be modified", rule.ID) + } + + srv := c.Scoped(ctx, projectName(sandbox), "") + acl, etag, err := srv.GetNetworkACL(agentACLName(network)) + if err != nil { + return compute.ACLRule{}, mapOVNError(err) + } + incusRule := toIncusACLRule(rule) + if rule.Direction == aclDirectionIngress { + acl.Ingress = append(acl.Ingress, incusRule) + } else { + acl.Egress = append(acl.Egress, incusRule) + } + if err := srv.UpdateNetworkACL(agentACLName(network), acl.Writable(), etag); err != nil { + return compute.ACLRule{}, mapOVNError(err) + } + return rule, nil +} + +func (c *Client) RemoveACLRule(ctx context.Context, sandbox, network, ruleID string) error { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + if computeRuleIsBaseline(ruleID) { + return fmt.Errorf("rule %q is a baseline ACL and cannot be removed", ruleID) + } + if _, err := c.ownedProjectNetwork(ctx, sandbox, network); err != nil { + return err + } + srv := c.Scoped(ctx, projectName(sandbox), "") + acl, etag, err := srv.GetNetworkACL(agentACLName(network)) + if err != nil { + return mapOVNError(err) + } + ingress, removed := filterACLRules(acl.Ingress, ruleID) + egress, removedEgress := filterACLRules(acl.Egress, ruleID) + if !removed && !removedEgress { + return compute.ErrNotFound + } + acl.Ingress = ingress + acl.Egress = egress + return mapOVNError(srv.UpdateNetworkACL(agentACLName(network), acl.Writable(), etag)) +} + +func (c *Client) ownedProjectNetwork(ctx context.Context, sandbox, logical string) (*api.Network, error) { + network, err := c.projectNetwork(ctx, sandbox, logical) + if err != nil { + return nil, err + } + if network.Config[metaSandbox] != sandbox || network.Config[metaVersion] != versionValue { + return nil, compute.ErrNotFound + } + return network, nil +} + +func (c *Client) deleteOwnedACLs(ctx context.Context, sandbox string) []error { + srv := c.Scoped(ctx, projectName(sandbox), "") + acls, err := srv.GetNetworkACLs() + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return nil + } + return []error{mapError(err)} + } + var errs []error + for _, acl := range acls { + if acl.Config[metaSandbox] != sandbox || acl.Config[metaVersion] != versionValue { + continue + } + if err := srv.DeleteNetworkACL(acl.Name); err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + errs = append(errs, mapError(err)) + } + } + return errs +} + +func toIncusACLRule(rule compute.ACLRule) api.NetworkACLRule { + protocol := rule.Protocol + if protocol == "icmp" { + protocol = "icmp4" + } + return api.NetworkACLRule{ + Action: rule.Action, + Source: rule.Src, + Destination: rule.Dst, + Protocol: protocol, + DestinationPort: rule.Port, + State: "enabled", + Description: aclRuleIDPrefix + rule.ID, + } +} + +func filterACLRules(rules []api.NetworkACLRule, id string) ([]api.NetworkACLRule, bool) { + out := make([]api.NetworkACLRule, 0, len(rules)) + removed := false + for _, rule := range rules { + if aclRuleID(rule) == id { + removed = true + continue + } + out = append(out, rule) + } + return out, removed +} + +func aclRuleID(rule api.NetworkACLRule) string { + return strings.TrimPrefix(rule.Description, aclRuleIDPrefix) +} + +func computeRuleIsBaseline(id string) bool { + switch id { + case compute.BaselineEgressMgmt, compute.BaselineEgressOOB: + return true + default: + return false + } +} + +func randomRuleID() (string, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return "r-" + hex.EncodeToString(buf[:]), nil +} diff --git a/internal/incus/ovn_forward.go b/internal/incus/ovn_forward.go new file mode 100644 index 0000000..d82b3d1 --- /dev/null +++ b/internal/incus/ovn_forward.go @@ -0,0 +1,248 @@ +package incus + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + + "github.com/lxc/incus/v7/shared/api" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +func (c *Client) CreateForward( + ctx context.Context, + sandbox, network string, + ref compute.Ref, + port, listenPort int64, + protocol string, +) (compute.Forward, error) { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + if _, err := c.ownedProjectNetwork(ctx, sandbox, network); err != nil { + return compute.Forward{}, err + } + target, err := c.forwardTargetAddress(ctx, sandbox, network, ref) + if err != nil { + return compute.Forward{}, err + } + listen := strconv.FormatInt(listenPort, 10) + targetPort := strconv.FormatInt(port, 10) + portSpec := api.NetworkForwardPort{ + Protocol: protocol, + ListenPort: listen, + TargetPort: targetPort, + TargetAddress: target, + } + + srv := c.Scoped(ctx, projectName(sandbox), "") + forwards, err := srv.GetNetworkForwards(network) + if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + return compute.Forward{}, mapOVNError(err) + } + if len(forwards) > 0 { + existing := forwards[0] + if forwardHasPort(existing, protocol, listen) { + return compute.Forward{}, fmt.Errorf( + "listen port %s/%s is already forwarded on %s", + listen, + protocol, + existing.ListenAddress, + ) + } + writable := existing.Writable() + writable.Ports = append(writable.Ports, portSpec) + if err := srv.UpdateNetworkForward(network, existing.ListenAddress, writable, ""); err != nil { + return compute.Forward{}, mapOVNError(err) + } + return compute.Forward{ + Address: existing.ListenAddress, + Port: listenPort, + Protocol: protocol, + Network: network, + Instance: ref.Name, + }, nil + } + + for { + address, err := c.allocateForwardAddress(ctx) + if err != nil { + return compute.Forward{}, err + } + err = srv.CreateNetworkForward(network, api.NetworkForwardsPost{ + ListenAddress: address, + NetworkForwardPut: api.NetworkForwardPut{ + Ports: []api.NetworkForwardPort{portSpec}, + Config: map[string]string{ + metaSandbox: sandbox, + metaVersion: versionValue, + metaName: network, + }, + }, + }) + if err == nil { + return compute.Forward{ + Address: address, Port: listenPort, Protocol: protocol, + Network: network, Instance: ref.Name, + }, nil + } + // Retry only a confirmed allocation race. A forward already on this + // network may be an uncertain commit; never create a second one. + current, inspectErr := srv.GetNetworkForwards(network) + if inspectErr != nil || len(current) != 0 { + return compute.Forward{}, mapOVNError(err) + } + used, inspectErr := c.usedOVNAddresses(ctx) + if inspectErr != nil || !used[address] { + return compute.Forward{}, mapOVNError(err) + } + } +} + +func (c *Client) forwardTargetAddress(ctx context.Context, sandbox, network string, ref compute.Ref) (string, error) { + instance, err := c.GetInstance(ctx, ref) + if err != nil { + return "", err + } + for _, nic := range instance.NICs { + if nic.Network != network { + continue + } + for _, address := range nic.Addresses { + ip := net.ParseIP(address) + if ip != nil && ip.To4() != nil { + return ip.String(), nil + } + } + } + leases, err := c.Scoped(ctx, projectName(sandbox), "").GetNetworkLeases(network) + if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + return "", mapError(err) + } + for _, lease := range leases { + if lease.Hostname != ref.Name { + continue + } + ip := net.ParseIP(lease.Address) + if ip != nil && ip.To4() != nil { + return ip.String(), nil + } + } + return "", fmt.Errorf("instance %q has no address on network %q", ref.Name, network) +} + +func (c *Client) allocateForwardAddress(ctx context.Context) (string, error) { + used, err := c.usedOVNAddresses(ctx) + if err != nil { + return "", err + } + start, end, err := parseIPv4Range(c.ovnRangeSpec()) + if err != nil { + return "", err + } + for ip := start; ip.IsValid() && !end.Less(ip); ip = ip.Next() { + addr := ip.String() + if used[addr] { + continue + } + return addr, nil + } + return "", errors.New("no free address remains in the OVN range") +} + +func (c *Client) usedOVNAddresses(ctx context.Context) (map[string]bool, error) { + used := map[string]bool{} + allocations, err := c.Scoped(ctx, api.ProjectDefaultName, "").GetNetworkAllocationsAllProjects() + if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + return nil, mapError(err) + } + for _, allocation := range allocations { + ip, _, parseErr := net.ParseCIDR(allocation.Address) + if parseErr != nil { + ip = net.ParseIP(strings.TrimSpace(allocation.Address)) + } + if ip != nil && ip.To4() != nil { + used[ip.String()] = true + } + } + networks, err := c.Scoped(ctx, "", "").GetNetworksAllProjects() + if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { + return nil, mapError(err) + } + for _, network := range networks { + if network.Type != networkKindOVN { + continue + } + if addr := net.ParseIP(network.Config["volatile.network.ipv4.address"]); addr != nil { + used[addr.String()] = true + } + } + return used, nil +} + +func (c *Client) deleteForwardsInProject(ctx context.Context, project, network string) []error { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + srv := c.Scoped(ctx, project, "") + forwards, err := srv.GetNetworkForwards(network) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return nil + } + return []error{mapOVNError(err)} + } + var errs []error + for _, forward := range forwards { + if err := srv.DeleteNetworkForward(network, forward.ListenAddress); err != nil && + !errors.Is(mapError(err), compute.ErrNotFound) { + errs = append(errs, mapOVNError(err)) + } + } + return errs +} + +func forwardHasPort(forward api.NetworkForward, protocol, listen string) bool { + for _, port := range forward.Ports { + if port.Protocol == protocol && port.ListenPort == listen { + return true + } + } + return false +} + +func parseIPv4Range(spec string) (netip.Addr, netip.Addr, error) { + spec = strings.TrimSpace(spec) + if spec == "" { + return netip.Addr{}, netip.Addr{}, errors.New("OVN range is required") + } + if strings.Contains(spec, "/") { + prefix, err := netip.ParsePrefix(spec) + if err != nil || !prefix.Addr().Is4() { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid OVN range %q", spec) + } + prefix = prefix.Masked() + start := prefix.Addr() + last := start.As4() + hostMask := ^uint32(0) >> prefix.Bits() + binary.BigEndian.PutUint32(last[:], binary.BigEndian.Uint32(last[:])|hostMask) + return start, netip.AddrFrom4(last), nil + } + startText, endText, ok := strings.Cut(spec, "-") + if !ok { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid OVN range %q", spec) + } + start, err := netip.ParseAddr(strings.TrimSpace(startText)) + if err != nil || !start.Is4() { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid OVN range %q", spec) + } + end, err := netip.ParseAddr(strings.TrimSpace(endText)) + if err != nil || !end.Is4() || end.Less(start) { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid OVN range %q", spec) + } + return start, end, nil +} diff --git a/internal/incus/ovn_peer.go b/internal/incus/ovn_peer.go new file mode 100644 index 0000000..d171818 --- /dev/null +++ b/internal/incus/ovn_peer.go @@ -0,0 +1,83 @@ +package incus + +import ( + "context" + "errors" + "fmt" + + "github.com/lxc/incus/v7/shared/api" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +func (c *Client) PeerNetworks(ctx context.Context, sandbox, network, peer string) error { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + left, err := c.ownedProjectNetwork(ctx, sandbox, network) + if err != nil { + return err + } + right, err := c.ownedProjectNetwork(ctx, sandbox, peer) + if err != nil { + return err + } + if left.Type != networkKindOVN || right.Type != networkKindOVN { + return errors.New("network peering requires OVN networks") + } + project := projectName(sandbox) + if err := c.ensurePeer(ctx, sandbox, network, peer, project); err != nil { + return err + } + return c.ensurePeer(ctx, sandbox, peer, network, project) +} + +func (c *Client) ensurePeer(ctx context.Context, sandbox, network, target, targetProject string) error { + srv := c.Scoped(ctx, projectName(sandbox), "") + existing, _, err := srv.GetNetworkPeer(network, target) + if err == nil { + if existing.TargetNetwork == target && + (existing.TargetProject == "" || existing.TargetProject == targetProject) { + return nil + } + return fmt.Errorf("peer %q already exists on network %q", target, network) + } + if !errors.Is(mapError(err), compute.ErrNotFound) { + return mapOVNError(err) + } + return mapOVNError(srv.CreateNetworkPeer(network, api.NetworkPeersPost{ + Name: target, + TargetProject: targetProject, + TargetNetwork: target, + Type: "local", + NetworkPeerPut: api.NetworkPeerPut{ + Config: map[string]string{ + metaSandbox: sandbox, + metaVersion: versionValue, + }, + }, + })) +} + +func (c *Client) deletePeers(ctx context.Context, sandbox, network string) []error { + ctx, cancel := c.ovnContext(ctx) + defer cancel() + srv := c.Scoped(ctx, projectName(sandbox), "") + peers, err := srv.GetNetworkPeers(network) + if err != nil { + if errors.Is(mapError(err), compute.ErrNotFound) { + return nil + } + return []error{mapOVNError(err)} + } + var errs []error + for _, peer := range peers { + if err := srv.DeleteNetworkPeer( + network, + peer.Name, + ); err != nil && + !errors.Is(mapError(err), compute.ErrNotFound) { + errs = append(errs, mapOVNError(err)) + } + } + return errs +} diff --git a/internal/incus/ovn_test.go b/internal/incus/ovn_test.go new file mode 100644 index 0000000..67896de --- /dev/null +++ b/internal/incus/ovn_test.go @@ -0,0 +1,38 @@ +package incus + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRouterCIDRUsesFirstHost(t *testing.T) { + t.Parallel() + + got, err := routerCIDR("192.168.50.0/24") + require.NoError(t, err) + assert.Equal(t, "192.168.50.1/24", got) + + got, err = routerCIDR("192.168.50.1/24") + require.NoError(t, err) + assert.Equal(t, "192.168.50.1/24", got) +} + +func TestParseIPv4RangeCIDR(t *testing.T) { + t.Parallel() + + start, end, err := parseIPv4Range("10.10.40.64/26") + require.NoError(t, err) + assert.Equal(t, "10.10.40.64", start.String()) + assert.Equal(t, "10.10.40.127", end.String()) +} + +func TestParseIPv4RangeHyphen(t *testing.T) { + t.Parallel() + + start, end, err := parseIPv4Range("10.10.40.64-10.10.40.127") + require.NoError(t, err) + assert.Equal(t, "10.10.40.64", start.String()) + assert.Equal(t, "10.10.40.127", end.String()) +} diff --git a/internal/incus/placement.go b/internal/incus/placement.go new file mode 100644 index 0000000..9269c74 --- /dev/null +++ b/internal/incus/placement.go @@ -0,0 +1,144 @@ +package incus + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/lxc/incus/v7/shared/api" + "github.com/meigma/codemode" +) + +const ( + memberStatusOnline = "Online" + schedulerManual = "manual" + schedulerGroup = "group" +) + +type memberLoad struct { + name string + freeRAM uint64 + load float64 +} + +func (c *Client) resolveTarget(ctx context.Context, host string) (string, error) { + if host != "" { + if err := c.ensureOnlineMember(ctx, host); err != nil { + return "", err + } + return host, nil + } + return c.selectLeastLoaded(ctx) +} + +func (c *Client) ensureOnlineMember(ctx context.Context, host string) error { + members, err := c.listedMembers(ctx) + if err != nil { + return err + } + if len(members) == 0 { + if host == c.host { + return nil + } + return placementErrorf("host %q is not available", host) + } + for _, member := range members { + if member.ServerName != host { + continue + } + if !memberOnline(member) { + return placementErrorf("host %q is not online", host) + } + return nil + } + return placementErrorf("host %q is not available", host) +} + +func (c *Client) selectLeastLoaded(ctx context.Context) (string, error) { + members, err := c.listedMembers(ctx) + if err != nil { + return "", err + } + if len(members) == 0 { + if c.host == "" { + return "", placementError("no online cluster member is available") + } + return c.host, nil + } + + candidates := make([]memberLoad, 0, len(members)) + srv := c.Scoped(ctx, api.ProjectDefaultName, "") + for _, member := range members { + if !memberOnline(member) || !autoPlaceable(member) { + continue + } + load := memberLoad{name: member.ServerName} + state, _, stateErr := srv.GetClusterMemberState(member.ServerName) + if stateErr != nil { + return "", fmt.Errorf("inspect member %q load: %w", member.ServerName, mapError(stateErr)) + } + if state == nil || len(state.SysInfo.LoadAverages) == 0 { + return "", fmt.Errorf("member %q returned no load information", member.ServerName) + } + load.freeRAM = state.SysInfo.FreeRAM + load.load = state.SysInfo.LoadAverages[0] + candidates = append(candidates, load) + } + name := pickLeastLoaded(candidates) + if name == "" { + return "", placementError("no online cluster member is available") + } + return name, nil +} + +func (c *Client) listedMembers(ctx context.Context) ([]api.ClusterMember, error) { + if c.server == nil || !c.server.IsClustered() { + return nil, nil + } + members, err := c.Scoped(ctx, api.ProjectDefaultName, "").GetClusterMembers() + if err != nil { + return nil, mapError(err) + } + return members, nil +} + +func pickLeastLoaded(members []memberLoad) string { + if len(members) == 0 { + return "" + } + return slices.MinFunc(members, cmpMemberLoad).name +} + +func cmpMemberLoad(a, b memberLoad) int { + if a.freeRAM != b.freeRAM { + if a.freeRAM > b.freeRAM { + return -1 + } + return 1 + } + if a.load != b.load { + if a.load < b.load { + return -1 + } + return 1 + } + return strings.Compare(a.name, b.name) +} + +func memberOnline(member api.ClusterMember) bool { + return strings.EqualFold(member.Status, memberStatusOnline) +} + +func autoPlaceable(member api.ClusterMember) bool { + scheduler := strings.ToLower(strings.TrimSpace(member.Config["scheduler.instance"])) + return scheduler != schedulerManual && scheduler != schedulerGroup +} + +func placementError(message string) error { + return &codemode.AgentError{Message: message} +} + +func placementErrorf(format string, args ...any) error { + return &codemode.AgentError{Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/incus/placement_test.go b/internal/incus/placement_test.go new file mode 100644 index 0000000..93945b8 --- /dev/null +++ b/internal/incus/placement_test.go @@ -0,0 +1,73 @@ +package incus + +import ( + "testing" + + "github.com/lxc/incus/v7/shared/api" + "github.com/stretchr/testify/assert" +) + +func TestPickLeastLoadedPrefersFreeRAMThenLoadThenName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + members []memberLoad + want string + }{ + { + name: "highest free RAM wins", + members: []memberLoad{ + {name: "lab01", freeRAM: 1 << 30, load: 0.1}, + {name: "lab02", freeRAM: 4 << 30, load: 2.0}, + {name: "lab03", freeRAM: 2 << 30, load: 0.0}, + }, + want: "lab02", + }, + { + name: "equal RAM prefers lower load", + members: []memberLoad{ + {name: "lab01", freeRAM: 2 << 30, load: 1.5}, + {name: "lab03", freeRAM: 2 << 30, load: 0.2}, + }, + want: "lab03", + }, + { + name: "equal RAM and load prefers name", + members: []memberLoad{ + {name: "lab03", freeRAM: 2 << 30, load: 0.5}, + {name: "lab01", freeRAM: 2 << 30, load: 0.5}, + }, + want: "lab01", + }, + { + name: "empty set", + members: nil, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, pickLeastLoaded(tt.members)) + }) + } +} + +func TestAutoPlaceableSkipsManualAndGroupSchedulers(t *testing.T) { + t.Parallel() + + assert.True(t, autoPlaceable(clusterMemberConfig(""))) + assert.True(t, autoPlaceable(clusterMemberConfig("all"))) + assert.False(t, autoPlaceable(clusterMemberConfig("manual"))) + assert.False(t, autoPlaceable(clusterMemberConfig("group"))) +} + +func clusterMemberConfig(scheduler string) api.ClusterMember { + return api.ClusterMember{ + ClusterMemberPut: api.ClusterMemberPut{ + Config: api.ConfigMap{"scheduler.instance": scheduler}, + }, + } +} diff --git a/internal/incus/sandbox.go b/internal/incus/sandbox.go index d1cd9bf..9442527 100644 --- a/internal/incus/sandbox.go +++ b/internal/incus/sandbox.go @@ -3,6 +3,7 @@ package incus import ( "context" "errors" + "fmt" "time" incusclient "github.com/lxc/incus/v7/client" @@ -11,11 +12,26 @@ import ( "github.com/GilmanLab/agentcompute/internal/compute" ) -// CreateSandbox creates a restricted project and its default all-member bridge. +// CreateSandbox creates a restricted project and its default network. func (c *Client) CreateSandbox(ctx context.Context, sandbox compute.Sandbox) error { if sandbox.Name == "" { return errors.New("sandbox name is required") } + kind := sandbox.NetworkKind + if kind == "" { + kind = networkKindBridge + } + switch kind { + case networkKindOVN: + return c.createOVNSandbox(ctx, sandbox) + case networkKindBridge: + return c.createBridgeSandbox(ctx, sandbox) + default: + return fmt.Errorf("network kind %q is not available", kind) + } +} + +func (c *Client) createBridgeSandbox(ctx context.Context, sandbox compute.Sandbox) error { host := sandbox.Host if host == "" { host = c.host @@ -39,7 +55,7 @@ func (c *Client) CreateSandbox(ctx context.Context, sandbox compute.Sandbox) err }, }) if err != nil { - return mapError(err) + return c.recoverBridgeProjectConflict(ctx, sandbox, host, physical, err) } _, err = c.createReservedBridge(ctx, physical, sandbox.Name, defaultLogicalNetwork, compute.Network{ @@ -50,10 +66,67 @@ func (c *Client) CreateSandbox(ctx context.Context, sandbox compute.Sandbox) err NAT: true, DNS: true, }, false) - if err != nil { - return err + return err +} + +func (c *Client) recoverBridgeProjectConflict( + ctx context.Context, + sandbox compute.Sandbox, + host, physical string, + createErr error, +) error { + if !isConflict(createErr) { + return mapError(createErr) + } + existing, _, getErr := c.getProject(ctx, sandbox.Name) + if getErr != nil { + return mapError(createErr) + } + reserved := reservedNetworks(existing.Config)[defaultLogicalNetwork] + if reserved == "" { + reserved = physical + } + _, err := c.createReservedBridge(ctx, reserved, sandbox.Name, defaultLogicalNetwork, compute.Network{ + Name: defaultLogicalNetwork, + Kind: networkKindBridge, + Host: host, + DHCP: true, + NAT: true, + DNS: true, + }, true) + return err +} + +func (c *Client) createOVNSandbox(ctx context.Context, sandbox compute.Sandbox) error { + created := sandbox.CreatedAt.UTC() + if created.IsZero() { + created = time.Now().UTC() } - return nil + expires := sandbox.ExpiresAt.UTC() + config := ovnProjectConfig(sandbox.Subject, created, expires, c.ovnUplinkName()) + + err := c.Scoped(ctx, "", "").CreateProject(api.ProjectsPost{ + Name: projectName(sandbox.Name), + ProjectPut: api.ProjectPut{ + Config: config, + }, + }) + if err != nil && !isConflict(err) { + return mapError(err) + } + if isConflict(err) { + if _, _, getErr := c.getProject(ctx, sandbox.Name); getErr != nil { + return mapError(err) + } + } + _, err = c.createOVNNetwork(ctx, sandbox.Name, compute.Network{ + Name: defaultLogicalNetwork, + Kind: networkKindOVN, + DHCP: true, + NAT: true, + DNS: true, + }) + return err } // ListSandboxes returns owned sandbox projects. @@ -68,7 +141,7 @@ func (c *Client) ListSandboxes(ctx context.Context) ([]compute.Sandbox, error) { if !ok { continue } - if sandbox.Host == "" { + if sandbox.Host == "" && (sandbox.NetworkKind == "" || sandbox.NetworkKind == networkKindBridge) { sandbox.Host = c.host } out = append(out, sandbox) @@ -86,7 +159,7 @@ func (c *Client) GetSandbox(ctx context.Context, name string) (compute.Sandbox, if !ok { return compute.Sandbox{}, compute.ErrNotFound } - if sandbox.Host == "" { + if sandbox.Host == "" && (sandbox.NetworkKind == "" || sandbox.NetworkKind == networkKindBridge) { sandbox.Host = c.host } return sandbox, nil @@ -109,54 +182,103 @@ func (c *Client) ExtendSandbox(ctx context.Context, name string, expires time.Ti // DeleteSandbox removes sandbox resources in dependency order. // // Partial failures are returned so the reaper can retry. The project is never -// deleted while owned bridges still exist. +// deleted while owned networks still exist. func (c *Client) DeleteSandbox(ctx context.Context, name string) error { project, _, projectErr := c.getProject(ctx, name) if projectErr != nil && !errors.Is(projectErr, compute.ErrNotFound) { return projectErr } - physicals, err := c.sandboxBridgeNames(ctx, name, project) if err != nil { return err } + ovns, err := c.ownedOVNNetworks(ctx, name) + if err != nil { + return err + } + if err = c.deleteSandboxForwarding(ctx, name, ovns, physicals); err != nil { + return err + } + if project != nil { + if err = c.emptySandboxProject(ctx, name); err != nil { + return err + } + } + if err = c.deleteSandboxNetworks(ctx, name, ovns, physicals); err != nil { + return err + } + remaining, err := c.ownedOVNNetworks(ctx, name) + if err != nil { + return err + } + if len(remaining) > 0 { + return errors.New("owned OVN networks still present") + } + if project != nil { + if err = c.deleteSandboxProject(ctx, name); err != nil { + return err + } + } + if project == nil && len(physicals) == 0 && len(ovns) == 0 { + return compute.ErrNotFound + } + return nil +} + +func (c *Client) deleteSandboxForwarding( + ctx context.Context, + sandbox string, + ovns []compute.Network, + physicals map[string]struct{}, +) error { var errs []error + for _, network := range ovns { + errs = append(errs, c.deleteForwardsInProject(ctx, projectName(sandbox), network.PhysicalName)...) + errs = append(errs, c.deletePeers(ctx, sandbox, network.PhysicalName)...) + } for physical := range physicals { errs = append(errs, c.deleteForwards(ctx, physical)...) } + return errors.Join(errs...) +} - if project != nil { - if err := c.deleteProjectContents(ctx, name); err != nil { +func (c *Client) emptySandboxProject(ctx context.Context, name string) error { + if err := c.detachSandboxNICs(ctx, name); err != nil { + return err + } + if err := c.deleteProjectContents(ctx, name); err != nil { + return err + } + return c.clearProfileNetworkRefs(ctx, name) +} + +func (c *Client) deleteSandboxNetworks( + ctx context.Context, + name string, + ovns []compute.Network, + physicals map[string]struct{}, +) error { + var errs []error + for _, network := range ovns { + if err := c.deleteOVNNetwork(ctx, name, network.PhysicalName); err != nil { errs = append(errs, err) } } - - var remaining []string for physical := range physicals { if err := c.deleteBridge(ctx, physical); err != nil { errs = append(errs, err) - remaining = append(remaining, physical) - } - } - if len(remaining) > 0 { - errs = append(errs, errors.New("bridges still present")) - return errors.Join(errs...) - } - - if project != nil { - if err := c.Scoped(ctx, "", ""). - DeleteProject(projectName(name)); err != nil && - !errors.Is(mapError(err), compute.ErrNotFound) { - errs = append(errs, mapError(err)) } } + return errors.Join(errs...) +} - joined := errors.Join(errs...) - if joined != nil { - return joined +func (c *Client) deleteSandboxProject(ctx context.Context, name string) error { + if err := errors.Join(c.deleteOwnedACLs(ctx, name)...); err != nil { + return err } - if project == nil && len(physicals) == 0 { - return compute.ErrNotFound + if err := c.Scoped(ctx, "", "").DeleteProject(projectName(name)); err != nil && + !errors.Is(mapError(err), compute.ErrNotFound) { + return mapError(err) } return nil } @@ -167,7 +289,7 @@ func (c *Client) sandboxBridgeNames( project *api.Project, ) (map[string]struct{}, error) { physicals := map[string]struct{}{} - if project != nil { + if project != nil && !featuresNetworks(project) { for _, physical := range reservedNetworks(project.Config) { physicals[physical] = struct{}{} } @@ -191,12 +313,13 @@ func sandboxProjectConfig(host, physical, subject string, created, expires time. return map[string]string{ "features.images": configTrue, "features.profiles": configTrue, - "features.networks": "false", + featuresNetworksKey: configFalse, "restricted": configTrue, "restricted.containers.nesting": configBlock, "restricted.containers.privilege": "unprivileged", "restricted.containers.lowlevel": configBlock, - "restricted.cluster.target": "allow", + "restricted.cluster.target": aclActionAllow, + "restricted.snapshots": aclActionAllow, "restricted.devices.nic": configManaged, "restricted.devices.disk": configManaged, "restricted.devices.gpu": configBlock, @@ -217,13 +340,65 @@ func sandboxProjectConfig(host, physical, subject string, created, expires time. } } +func ovnProjectConfig(subject string, created, expires time.Time, uplink string) map[string]string { + return map[string]string{ + "features.images": configTrue, + "features.profiles": configTrue, + featuresNetworksKey: configTrue, + "restricted": configTrue, + "restricted.containers.nesting": configBlock, + "restricted.containers.privilege": "unprivileged", + "restricted.containers.lowlevel": configBlock, + "restricted.cluster.target": aclActionAllow, + "restricted.snapshots": aclActionAllow, + "restricted.devices.nic": configManaged, + "restricted.devices.disk": configManaged, + "restricted.devices.gpu": configBlock, + "restricted.devices.pci": configBlock, + "restricted.devices.proxy": configBlock, + "restricted.devices.usb": configBlock, + "restricted.devices.unix-block": configBlock, + "restricted.devices.unix-char": configBlock, + "restricted.devices.unix-hotplug": configBlock, + "restricted.devices.infiniband": configBlock, + "restricted.networks.uplinks": uplink, + metaVersion: versionValue, + metaCreatedAt: created.Format(time.RFC3339Nano), + metaExpiresAt: expires.Format(time.RFC3339Nano), + metaSubject: subject, + } +} + +func (c *Client) detachSandboxNICs(ctx context.Context, sandbox string) error { + instances, err := c.ListInstances(ctx, sandbox) + if err != nil && !errors.Is(err, compute.ErrNotFound) { + return err + } + var errs []error + for _, instance := range instances { + if _, err := c.StopInstance(ctx, instance.Ref, true); err != nil { + errs = append(errs, err) + continue + } + for _, nic := range instance.NICs { + if nic.Name == "" { + continue + } + if err := c.DetachNIC(ctx, instance.Ref, nic.Name); err != nil && !errors.Is(err, compute.ErrNotFound) { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + func (c *Client) deleteProjectContents(ctx context.Context, sandbox string) error { srv := c.Scoped(ctx, projectName(sandbox), "") var errs []error instances, err := srv.GetInstances(api.InstanceTypeAny) if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { - errs = append(errs, mapError(err)) + return mapError(err) } for _, instance := range instances { if deleteErr := c.forceDeleteInstance( @@ -235,10 +410,13 @@ func (c *Client) deleteProjectContents(ctx context.Context, sandbox string) erro errs = append(errs, deleteErr) } } + if err = errors.Join(errs...); err != nil { + return err + } images, err := srv.GetImages() if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { - errs = append(errs, mapError(err)) + return mapError(err) } for _, image := range images { if deleteErr := waitDelete(ctx, func() (incusclient.Operation, error) { @@ -248,22 +426,54 @@ func (c *Client) deleteProjectContents(ctx context.Context, sandbox string) erro } } + return errors.Join(errs...) +} + +func (c *Client) clearProfileNetworkRefs(ctx context.Context, sandbox string) error { + srv := c.Scoped(ctx, projectName(sandbox), "") profiles, err := srv.GetProfiles() if err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { - errs = append(errs, mapError(err)) + return mapError(err) } + var errs []error for _, profile := range profiles { if profile.Name == "default" { + if nicErr := stripDefaultProfileNICs(srv, profile); nicErr != nil { + errs = append(errs, nicErr) + } continue } - if err := srv.DeleteProfile(profile.Name); err != nil && !errors.Is(mapError(err), compute.ErrNotFound) { - errs = append(errs, mapError(err)) + if delErr := srv.DeleteProfile( + profile.Name, + ); delErr != nil && + !errors.Is(mapError(delErr), compute.ErrNotFound) { + errs = append(errs, mapError(delErr)) } } - return errors.Join(errs...) } +func stripDefaultProfileNICs(srv incusclient.InstanceServer, profile api.Profile) error { + devices := copyDevices(profile.Devices) + changed := false + for name, device := range devices { + if device[deviceTypeKey] == deviceTypeNIC { + delete(devices, name) + changed = true + } + } + if !changed { + return nil + } + writable := profile.Writable() + writable.Devices = devices + if err := srv.UpdateProfile(profile.Name, writable, ""); err != nil && + !errors.Is(mapError(err), compute.ErrNotFound) { + return mapError(err) + } + return nil +} + func waitDelete(ctx context.Context, fn func() (incusclient.Operation, error)) error { op, err := fn() if err != nil { diff --git a/internal/mcpserver/contract_test.go b/internal/mcpserver/contract_test.go index 8d70941..c12ca52 100644 --- a/internal/mcpserver/contract_test.go +++ b/internal/mcpserver/contract_test.go @@ -67,6 +67,7 @@ type capabilityContract struct { func capabilityContracts() []capabilityContract { networkType := "{name: str, kind: str, cidr: str, gateway: str}" instanceListItemType := "{name: str, kind: str, image: str, status: str, addresses: dict[str, list[str]]}" + snapshotItemType := "{name: str, created_at: str}" return []capabilityContract{ { @@ -221,6 +222,127 @@ func capabilityContracts() []capabilityContract { {Name: "timed_out", Type: "bool", Required: true}, }, }, + { + name: capabilityInstanceStart, + signature: "instance.start(*, sandbox: str, name: str, force: bool | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "force", Type: "bool | None"}, + }, + output: []fieldShape{{Name: "status", Type: "str", Required: true}}, + }, + { + name: capabilityInstanceStop, + signature: "instance.stop(*, sandbox: str, name: str, force: bool | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "force", Type: "bool | None"}, + }, + output: []fieldShape{{Name: "status", Type: "str", Required: true}}, + }, + { + name: capabilityInstanceRestart, + signature: "instance.restart(*, sandbox: str, name: str, force: bool | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "force", Type: "bool | None"}, + }, + output: []fieldShape{{Name: "status", Type: "str", Required: true}}, + }, + { + name: capabilityInstanceWait, + signature: "instance.wait(*, sandbox: str, name: str, until: str, timeout_seconds: int | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "until", Type: "str", Required: true}, + {Name: "timeout_seconds", Type: "int | None"}, + }, + output: []fieldShape{ + {Name: "status", Type: "str", Required: true}, + {Name: "elapsed_seconds", Type: "int", Required: true}, + }, + }, + { + name: capabilityInstanceFileRead, + signature: "instance.file.read(*, sandbox: str, name: str, path: str, max_bytes: int | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "path", Type: "str", Required: true}, + {Name: "max_bytes", Type: "int | None"}, + }, + output: []fieldShape{ + {Name: "content", Type: "str", Required: true}, + {Name: "truncated", Type: "bool", Required: true}, + }, + }, + { + name: capabilityInstanceFileWrite, + signature: "instance.file.write(*, sandbox: str, name: str, path: str, content: str, mode: str | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "path", Type: "str", Required: true}, + {Name: "content", Type: "str", Required: true}, + {Name: "mode", Type: "str | None"}, + }, + output: []fieldShape{{Name: "bytes", Type: "int", Required: true}}, + }, + { + name: capabilityInstanceSnapshotCreate, + signature: "instance.snapshot.create(*, sandbox: str, name: str, snapshot: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "snapshot", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityInstanceSnapshotRestore, + signature: "instance.snapshot.restore(*, sandbox: str, name: str, snapshot: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "snapshot", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityInstanceSnapshotDelete, + signature: "instance.snapshot.delete(*, sandbox: str, name: str, snapshot: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "snapshot", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityInstanceSnapshotList, + signature: "instance.snapshot.list(*, sandbox: str, name: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + }, + output: []fieldShape{ + {Name: "items", Type: "list[" + snapshotItemType + "]", Required: true}, + }, + }, + { + name: capabilityInstancePublish, + signature: "instance.publish(*, sandbox: str, name: str, image: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + {Name: "image", Type: "str", Required: true}, + }, + output: []fieldShape{{Name: "image", Type: "str", Required: true}}, + }, { name: capabilityNetCreate, signature: "net.create(*, sandbox: str, name: str, kind: str | None, cidr: str | None, dhcp: bool | None, nat: bool | None, dns: bool | None)", @@ -256,6 +378,113 @@ func capabilityContracts() []capabilityContract { {Name: "mac", Type: "str", Required: true}, }, }, + { + name: capabilityNetList, + signature: "net.list(*, sandbox: str)", + input: []fieldShape{{Name: "sandbox", Type: "str", Required: true}}, + output: []fieldShape{ + {Name: "items", Type: "list[" + networkType + "]", Required: true}, + }, + }, + { + name: capabilityNetGet, + signature: "net.get(*, sandbox: str, name: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + }, + output: []fieldShape{ + {Name: "name", Type: "str", Required: true}, + {Name: "kind", Type: "str", Required: true}, + {Name: "cidr", Type: "str", Required: true}, + {Name: "gateway", Type: "str", Required: true}, + }, + }, + { + name: capabilityNetDelete, + signature: "net.delete(*, sandbox: str, name: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "name", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityNetDetach, + signature: "net.detach(*, sandbox: str, instance: str, nic: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "instance", Type: "str", Required: true}, + {Name: "nic", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityNetPeer, + signature: "net.peer(*, sandbox: str, network: str, peer: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "network", Type: "str", Required: true}, + {Name: "peer", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityNetACLAdd, + signature: "net.acl.add(*, sandbox: str, network: str, direction: str, action: str, protocol: str | None, src: str | None, dst: str | None, port: str | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "network", Type: "str", Required: true}, + {Name: "direction", Type: "str", Required: true}, + {Name: "action", Type: "str", Required: true}, + {Name: "protocol", Type: "str | None"}, + {Name: "src", Type: "str | None"}, + {Name: "dst", Type: "str | None"}, + {Name: "port", Type: "str | None"}, + }, + output: []fieldShape{{Name: "rule", Type: "str", Required: true}}, + }, + { + name: capabilityNetACLRemove, + signature: "net.acl.remove(*, sandbox: str, network: str, rule: str)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "network", Type: "str", Required: true}, + {Name: "rule", Type: "str", Required: true}, + }, + output: []fieldShape{}, + }, + { + name: capabilityNetForward, + signature: "net.forward(*, sandbox: str, network: str, instance: str, port: int, listen_port: int | None, protocol: str | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "network", Type: "str", Required: true}, + {Name: "instance", Type: "str", Required: true}, + {Name: "port", Type: "int", Required: true}, + {Name: "listen_port", Type: "int | None"}, + {Name: "protocol", Type: "str | None"}, + }, + output: []fieldShape{ + {Name: "address", Type: "str", Required: true}, + {Name: "port", Type: "int", Required: true}, + }, + }, + { + name: capabilityNetImpair, + signature: "net.impair(*, sandbox: str, instance: str, nic: str, latency_ms: int | None, jitter_ms: int | None, loss_percent: float | None, rate_mbit: int | None, clear: bool | None)", + input: []fieldShape{ + {Name: "sandbox", Type: "str", Required: true}, + {Name: "instance", Type: "str", Required: true}, + {Name: "nic", Type: "str", Required: true}, + {Name: "latency_ms", Type: "int | None"}, + {Name: "jitter_ms", Type: "int | None"}, + {Name: "loss_percent", Type: "float | None"}, + {Name: "rate_mbit", Type: "int | None"}, + {Name: "clear", Type: "bool | None"}, + }, + output: []fieldShape{}, + }, } } diff --git a/internal/mcpserver/handlers_test.go b/internal/mcpserver/handlers_test.go index 2b4cd6e..7183ea4 100644 --- a/internal/mcpserver/handlers_test.go +++ b/internal/mcpserver/handlers_test.go @@ -63,30 +63,31 @@ func TestListSandboxesEmptyItemsAreNonNil(t *testing.T) { assert.Empty(t, out.Items) } -func TestNetCreateRejectsOVN(t *testing.T) { +func TestNetCreateRejectsUnknownKind(t *testing.T) { t.Parallel() + kind := "macvlan" _, err := netAPI{}.create(context.Background(), authz.Subject{}, netCreateIn{ Sandbox: "demo", Name: "lan", - Kind: new(kindOVN), + Kind: &kind, }) require.Error(t, err) var actionable *codemode.AgentError require.ErrorAs(t, err, &actionable) } -func TestNetCreateDefaultsToBridgeAndOmitsPhysicalName(t *testing.T) { +func TestNetCreateDefaultsToOVNAndOmitsPhysicalName(t *testing.T) { t.Parallel() tc := newTestDeps(t) tc.network.EXPECT(). - CreateNetwork(mock.Anything, "demo", compute.Network{Name: "lan", Kind: kindBridge}). + CreateNetwork(mock.Anything, "demo", compute.Network{Name: "lan", Kind: kindOVN, DHCP: true, NAT: true, DNS: true}). Return(compute.Network{ Name: "lan", - PhysicalName: "acffffffff", - Kind: kindBridge, - CIDR: "10.1.0.0/24", + PhysicalName: "lan", + Kind: kindOVN, + CIDR: "10.1.0.1/24", Gateway: "10.1.0.1", }, nil) @@ -96,10 +97,10 @@ func TestNetCreateDefaultsToBridgeAndOmitsPhysicalName(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, "lan", out.Name) - assert.Equal(t, kindBridge, out.Kind) + assert.Equal(t, kindOVN, out.Kind) raw, err := json.Marshal(out) require.NoError(t, err) - assert.NotContains(t, string(raw), "acffffffff") + assert.NotContains(t, string(raw), "PhysicalName") } func TestGetInstanceEmptyCollectionsAreNonNil(t *testing.T) { diff --git a/internal/mcpserver/instance.go b/internal/mcpserver/instance.go index 3c7ce9d..3eaebe2 100644 --- a/internal/mcpserver/instance.go +++ b/internal/mcpserver/instance.go @@ -99,12 +99,10 @@ type instanceExecOut struct { type instanceAPI struct { instances instanceService - images imageService } -//nolint:dupl // Explicit typed registrations keep each capability's contract visible. func registerInstance(builder *codemode.Builder, deps Dependencies) { - api := instanceAPI{instances: deps.Instance, images: deps.Image} + api := instanceAPI{instances: deps.Instance} codemode.Register(builder, codemode.Capability[instanceCreateIn, instanceCreateOut]{ ID: capabilityInstanceCreate, Name: capabilityInstanceCreate, @@ -135,6 +133,10 @@ func registerInstance(builder *codemode.Builder, deps Dependencies) { Summary: "Run a shell command in an instance.", Handler: api.exec, }) + registerInstanceLifecycle(builder, api) + registerInstanceFiles(builder, api) + registerInstanceSnapshots(builder, api) + registerInstancePublish(builder, api) } func (api instanceAPI) create( @@ -142,7 +144,7 @@ func (api instanceAPI) create( _ authz.Subject, in instanceCreateIn, ) (instanceCreateOut, error) { - image, err := api.images.CatalogImage(in.Image) + image, err := api.instances.ResolveImage(ctx, in.Sandbox, in.Image) if err != nil { return instanceCreateOut{}, err } diff --git a/internal/mcpserver/instance_file.go b/internal/mcpserver/instance_file.go new file mode 100644 index 0000000..8dcf30c --- /dev/null +++ b/internal/mcpserver/instance_file.go @@ -0,0 +1,87 @@ +package mcpserver + +import ( + "context" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + capabilityInstanceFileRead = "instance.file.read" + capabilityInstanceFileWrite = "instance.file.write" +) + +type instanceFileReadIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Path string `json:"path"` + MaxBytes *int64 `json:"max_bytes,omitempty"` +} + +type instanceFileReadOut struct { + Content string `json:"content"` + Truncated bool `json:"truncated"` +} + +type instanceFileWriteIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Path string `json:"path"` + Content string `json:"content"` + Mode *string `json:"mode,omitempty"` +} + +type instanceFileWriteOut struct { + Bytes int64 `json:"bytes"` +} + +func registerInstanceFiles(builder *codemode.Builder, api instanceAPI) { + codemode.Register(builder, codemode.Capability[instanceFileReadIn, instanceFileReadOut]{ + ID: capabilityInstanceFileRead, + Name: capabilityInstanceFileRead, + Summary: "Read a text-sized file from an instance.", + Handler: api.readFile, + }) + codemode.Register(builder, codemode.Capability[instanceFileWriteIn, instanceFileWriteOut]{ + ID: capabilityInstanceFileWrite, + Name: capabilityInstanceFileWrite, + Summary: "Write a text-sized file to an instance.", + Handler: api.writeFile, + }) +} + +func (api instanceAPI) readFile( + ctx context.Context, + _ authz.Subject, + in instanceFileReadIn, +) (instanceFileReadOut, error) { + result, err := api.instances.ReadFile(ctx, compute.FileReadRequest{ + Ref: compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + Path: in.Path, + MaxBytes: deref(in.MaxBytes, 0), + }) + if err != nil { + return instanceFileReadOut{}, err + } + return instanceFileReadOut{Content: result.Content, Truncated: result.Truncated}, nil +} + +func (api instanceAPI) writeFile( + ctx context.Context, + _ authz.Subject, + in instanceFileWriteIn, +) (instanceFileWriteOut, error) { + result, err := api.instances.WriteFile(ctx, compute.FileWriteRequest{ + Ref: compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + Path: in.Path, + Content: in.Content, + Mode: deref(in.Mode, ""), + }) + if err != nil { + return instanceFileWriteOut{}, err + } + return instanceFileWriteOut{Bytes: result.Bytes}, nil +} diff --git a/internal/mcpserver/instance_lifecycle.go b/internal/mcpserver/instance_lifecycle.go new file mode 100644 index 0000000..719b8aa --- /dev/null +++ b/internal/mcpserver/instance_lifecycle.go @@ -0,0 +1,142 @@ +package mcpserver + +import ( + "context" + "time" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + capabilityInstanceStart = "instance.start" + capabilityInstanceStop = "instance.stop" + capabilityInstanceRestart = "instance.restart" + capabilityInstanceWait = "instance.wait" +) + +type instanceStateIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Force *bool `json:"force,omitempty"` +} + +type instanceStateOut struct { + Status string `json:"status"` +} + +type instanceWaitIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Until string `json:"until"` + TimeoutSeconds *int64 `json:"timeout_seconds,omitempty"` +} + +type instanceWaitOut struct { + Status string `json:"status"` + ElapsedSeconds int64 `json:"elapsed_seconds"` +} + +func registerInstanceLifecycle(builder *codemode.Builder, api instanceAPI) { + codemode.Register(builder, codemode.Capability[instanceStateIn, instanceStateOut]{ + ID: capabilityInstanceStart, + Name: capabilityInstanceStart, + Summary: "Start an instance and wait until it is running.", + Handler: api.start, + }) + codemode.Register(builder, codemode.Capability[instanceStateIn, instanceStateOut]{ + ID: capabilityInstanceStop, + Name: capabilityInstanceStop, + Summary: "Stop an instance and wait until it is stopped.", + Handler: api.stop, + }) + codemode.Register(builder, codemode.Capability[instanceStateIn, instanceStateOut]{ + ID: capabilityInstanceRestart, + Name: capabilityInstanceRestart, + Summary: "Restart an instance and wait until it is running.", + Handler: api.restart, + }) + codemode.Register(builder, codemode.Capability[instanceWaitIn, instanceWaitOut]{ + ID: capabilityInstanceWait, + Name: capabilityInstanceWait, + Summary: "Wait until an instance reaches a readiness stage.", + Handler: api.wait, + }) +} + +func (api instanceAPI) start( + ctx context.Context, + _ authz.Subject, + in instanceStateIn, +) (instanceStateOut, error) { + instance, err := api.instances.StartInstance( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + deref(in.Force, false), + ) + if err != nil { + return instanceStateOut{}, err + } + return instanceStateOut{Status: instance.Status}, nil +} + +func (api instanceAPI) stop( + ctx context.Context, + _ authz.Subject, + in instanceStateIn, +) (instanceStateOut, error) { + instance, err := api.instances.StopInstance( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + deref(in.Force, false), + ) + if err != nil { + return instanceStateOut{}, err + } + return instanceStateOut{Status: instance.Status}, nil +} + +func (api instanceAPI) restart( + ctx context.Context, + _ authz.Subject, + in instanceStateIn, +) (instanceStateOut, error) { + instance, err := api.instances.RestartInstance( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + deref(in.Force, false), + ) + if err != nil { + return instanceStateOut{}, err + } + return instanceStateOut{Status: instance.Status}, nil +} + +func (api instanceAPI) wait( + ctx context.Context, + _ authz.Subject, + in instanceWaitIn, +) (instanceWaitOut, error) { + var timeout int64 + if in.TimeoutSeconds != nil { + timeout = *in.TimeoutSeconds + } + duration, err := secondsToDuration(timeout) + if err != nil { + return instanceWaitOut{}, err + } + result, err := api.instances.WaitInstance(ctx, compute.WaitRequest{ + Ref: compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + Until: in.Until, + Timeout: duration, + }) + if err != nil { + return instanceWaitOut{}, err + } + return instanceWaitOut{ + Status: result.Status, + ElapsedSeconds: int64(result.Elapsed / time.Second), + }, nil +} diff --git a/internal/mcpserver/instance_lifecycle_test.go b/internal/mcpserver/instance_lifecycle_test.go new file mode 100644 index 0000000..465cf0e --- /dev/null +++ b/internal/mcpserver/instance_lifecycle_test.go @@ -0,0 +1,86 @@ +package mcpserver + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/authz" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +func TestSnapshotListEmptyItemsAreNonNil(t *testing.T) { + t.Parallel() + + tc := newTestDeps(t) + tc.instance.EXPECT(). + ListSnapshots(mock.Anything, compute.Ref{Sandbox: "demo", Name: "web"}). + Return(nil, nil) + + out, err := instanceAPI{instances: tc.instance}.listSnapshots( + context.Background(), + authz.Subject{}, + instanceSnapshotListIn{Sandbox: "demo", Name: "web"}, + ) + require.NoError(t, err) + require.NotNil(t, out.Items) + assert.Empty(t, out.Items) +} + +func TestWaitConvertsElapsedToWholeSeconds(t *testing.T) { + t.Parallel() + + tc := newTestDeps(t) + tc.instance.EXPECT(). + WaitInstance(mock.Anything, compute.WaitRequest{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, + Until: compute.WaitUntilRunning, + Timeout: time.Duration(0), + }). + Return(compute.WaitResult{Status: "Running", Elapsed: 1500 * time.Millisecond}, nil) + + out, err := instanceAPI{instances: tc.instance}.wait( + context.Background(), + authz.Subject{}, + instanceWaitIn{Sandbox: "demo", Name: "web", Until: compute.WaitUntilRunning}, + ) + require.NoError(t, err) + assert.Equal(t, "Running", out.Status) + assert.Equal(t, int64(1), out.ElapsedSeconds) +} + +func TestCreateResolvesSandboxImage(t *testing.T) { + t.Parallel() + + tc := newTestDeps(t) + image := compute.CatalogImage{ + Name: "golden", + Kind: "container", + Fingerprint: "abc123", + CPUs: 1, + MemoryMB: 512, + DiskGB: 2, + } + tc.instance.EXPECT().ResolveImage(mock.Anything, "demo", "golden").Return(image, nil) + tc.instance.EXPECT().CreateInstance(mock.Anything, mock.Anything).Return(compute.Instance{ + Ref: compute.Ref{Sandbox: "demo", Name: "web"}, + Kind: "container", + Host: "lab02", + Status: "Running", + }, nil) + + out, err := instanceAPI{instances: tc.instance}.create( + context.Background(), + authz.Subject{}, + instanceCreateIn{Sandbox: "demo", Name: "web", Image: "golden"}, + ) + require.NoError(t, err) + assert.Equal(t, "web", out.Name) + assert.Equal(t, "lab02", out.Host) + require.NotNil(t, out.Addresses) +} diff --git a/internal/mcpserver/instance_publish.go b/internal/mcpserver/instance_publish.go new file mode 100644 index 0000000..ed3c640 --- /dev/null +++ b/internal/mcpserver/instance_publish.go @@ -0,0 +1,43 @@ +package mcpserver + +import ( + "context" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const capabilityInstancePublish = "instance.publish" + +type instancePublishIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Image string `json:"image"` +} + +type instancePublishOut struct { + Image string `json:"image"` +} + +func registerInstancePublish(builder *codemode.Builder, api instanceAPI) { + codemode.Register(builder, codemode.Capability[instancePublishIn, instancePublishOut]{ + ID: capabilityInstancePublish, + Name: capabilityInstancePublish, + Summary: "Publish a sandbox-scoped image from an instance.", + Handler: api.publish, + }) +} + +func (api instanceAPI) publish( + ctx context.Context, + _ authz.Subject, + in instancePublishIn, +) (instancePublishOut, error) { + image, err := api.instances.PublishInstance(ctx, compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, in.Image) + if err != nil { + return instancePublishOut{}, err + } + return instancePublishOut{Image: image}, nil +} diff --git a/internal/mcpserver/instance_snapshot.go b/internal/mcpserver/instance_snapshot.go new file mode 100644 index 0000000..6ff6781 --- /dev/null +++ b/internal/mcpserver/instance_snapshot.go @@ -0,0 +1,130 @@ +package mcpserver + +import ( + "context" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + + "github.com/GilmanLab/agentcompute/internal/compute" +) + +const ( + capabilityInstanceSnapshotCreate = "instance.snapshot.create" + capabilityInstanceSnapshotRestore = "instance.snapshot.restore" + capabilityInstanceSnapshotDelete = "instance.snapshot.delete" + capabilityInstanceSnapshotList = "instance.snapshot.list" +) + +type instanceSnapshotIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` + Snapshot string `json:"snapshot"` +} + +type instanceSnapshotOut struct{} + +type instanceSnapshotListIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` +} + +type instanceSnapshotItem struct { + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +type instanceSnapshotListOut struct { + Items []instanceSnapshotItem `json:"items"` +} + +func registerInstanceSnapshots(builder *codemode.Builder, api instanceAPI) { + codemode.Register(builder, codemode.Capability[instanceSnapshotIn, instanceSnapshotOut]{ + ID: capabilityInstanceSnapshotCreate, + Name: capabilityInstanceSnapshotCreate, + Summary: "Create an instance snapshot.", + Handler: api.createSnapshot, + }) + codemode.Register(builder, codemode.Capability[instanceSnapshotIn, instanceSnapshotOut]{ + ID: capabilityInstanceSnapshotRestore, + Name: capabilityInstanceSnapshotRestore, + Summary: "Recreate and start the same instance name from a snapshot. Deletes its old snapshots; identity changes and MAC/DHCP lease may change.", + Handler: api.restoreSnapshot, + }) + codemode.Register(builder, codemode.Capability[instanceSnapshotIn, instanceSnapshotOut]{ + ID: capabilityInstanceSnapshotDelete, + Name: capabilityInstanceSnapshotDelete, + Summary: "Delete an instance snapshot.", + Handler: api.deleteSnapshot, + }) + codemode.Register(builder, codemode.Capability[instanceSnapshotListIn, instanceSnapshotListOut]{ + ID: capabilityInstanceSnapshotList, + Name: capabilityInstanceSnapshotList, + Summary: "List instance snapshots.", + Handler: api.listSnapshots, + }) +} + +func (api instanceAPI) createSnapshot( + ctx context.Context, + _ authz.Subject, + in instanceSnapshotIn, +) (instanceSnapshotOut, error) { + if err := api.instances.CreateSnapshot( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + in.Snapshot, + ); err != nil { + return instanceSnapshotOut{}, err + } + return instanceSnapshotOut{}, nil +} + +func (api instanceAPI) restoreSnapshot( + ctx context.Context, + _ authz.Subject, + in instanceSnapshotIn, +) (instanceSnapshotOut, error) { + if err := api.instances.RestoreSnapshot( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + in.Snapshot, + ); err != nil { + return instanceSnapshotOut{}, err + } + return instanceSnapshotOut{}, nil +} + +func (api instanceAPI) deleteSnapshot( + ctx context.Context, + _ authz.Subject, + in instanceSnapshotIn, +) (instanceSnapshotOut, error) { + if err := api.instances.DeleteSnapshot( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Name}, + in.Snapshot, + ); err != nil { + return instanceSnapshotOut{}, err + } + return instanceSnapshotOut{}, nil +} + +func (api instanceAPI) listSnapshots( + ctx context.Context, + _ authz.Subject, + in instanceSnapshotListIn, +) (instanceSnapshotListOut, error) { + snapshots, err := api.instances.ListSnapshots(ctx, compute.Ref{Sandbox: in.Sandbox, Name: in.Name}) + if err != nil { + return instanceSnapshotListOut{}, err + } + items := make([]instanceSnapshotItem, 0, len(snapshots)) + for _, snapshot := range snapshots { + items = append(items, instanceSnapshotItem{ + Name: snapshot.Name, + CreatedAt: formatTime(snapshot.CreatedAt), + }) + } + return instanceSnapshotListOut{Items: items}, nil +} diff --git a/internal/mcpserver/mocks_test.go b/internal/mcpserver/mocks_test.go index 806843f..afd4f55 100644 --- a/internal/mcpserver/mocks_test.go +++ b/internal/mcpserver/mocks_test.go @@ -76,7 +76,7 @@ type MocksandboxService_CreateSandbox_Call struct { // - name string // - ttl time.Duration // - subject string -func (_e *MocksandboxService_Expecter) CreateSandbox(ctx any, name any, ttl any, subject any) *MocksandboxService_CreateSandbox_Call { +func (_e *MocksandboxService_Expecter) CreateSandbox(ctx interface{}, name interface{}, ttl interface{}, subject interface{}) *MocksandboxService_CreateSandbox_Call { return &MocksandboxService_CreateSandbox_Call{Call: _e.mock.On("CreateSandbox", ctx, name, ttl, subject)} } @@ -143,7 +143,7 @@ type MocksandboxService_DeleteSandbox_Call struct { // DeleteSandbox is a helper method to define mock.On call // - ctx context.Context // - name string -func (_e *MocksandboxService_Expecter) DeleteSandbox(ctx any, name any) *MocksandboxService_DeleteSandbox_Call { +func (_e *MocksandboxService_Expecter) DeleteSandbox(ctx interface{}, name interface{}) *MocksandboxService_DeleteSandbox_Call { return &MocksandboxService_DeleteSandbox_Call{Call: _e.mock.On("DeleteSandbox", ctx, name)} } @@ -210,7 +210,7 @@ type MocksandboxService_ExtendSandbox_Call struct { // - ctx context.Context // - name string // - ttl time.Duration -func (_e *MocksandboxService_Expecter) ExtendSandbox(ctx any, name any, ttl any) *MocksandboxService_ExtendSandbox_Call { +func (_e *MocksandboxService_Expecter) ExtendSandbox(ctx interface{}, name interface{}, ttl interface{}) *MocksandboxService_ExtendSandbox_Call { return &MocksandboxService_ExtendSandbox_Call{Call: _e.mock.On("ExtendSandbox", ctx, name, ttl)} } @@ -297,7 +297,7 @@ type MocksandboxService_GetSandbox_Call struct { // GetSandbox is a helper method to define mock.On call // - ctx context.Context // - name string -func (_e *MocksandboxService_Expecter) GetSandbox(ctx any, name any) *MocksandboxService_GetSandbox_Call { +func (_e *MocksandboxService_Expecter) GetSandbox(ctx interface{}, name interface{}) *MocksandboxService_GetSandbox_Call { return &MocksandboxService_GetSandbox_Call{Call: _e.mock.On("GetSandbox", ctx, name)} } @@ -364,7 +364,7 @@ type MocksandboxService_ListSandboxes_Call struct { // ListSandboxes is a helper method to define mock.On call // - ctx context.Context -func (_e *MocksandboxService_Expecter) ListSandboxes(ctx any) *MocksandboxService_ListSandboxes_Call { +func (_e *MocksandboxService_Expecter) ListSandboxes(ctx interface{}) *MocksandboxService_ListSandboxes_Call { return &MocksandboxService_ListSandboxes_Call{Call: _e.mock.On("ListSandboxes", ctx)} } @@ -452,7 +452,7 @@ type MockinstanceService_CreateInstance_Call struct { // CreateInstance is a helper method to define mock.On call // - ctx context.Context // - req compute.CreateInstance -func (_e *MockinstanceService_Expecter) CreateInstance(ctx any, req any) *MockinstanceService_CreateInstance_Call { +func (_e *MockinstanceService_Expecter) CreateInstance(ctx interface{}, req interface{}) *MockinstanceService_CreateInstance_Call { return &MockinstanceService_CreateInstance_Call{Call: _e.mock.On("CreateInstance", ctx, req)} } @@ -484,6 +484,69 @@ func (_c *MockinstanceService_CreateInstance_Call) RunAndReturn(run func(ctx con return _c } +// CreateSnapshot provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) CreateSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + ret := _mock.Called(ctx, ref, snapshot) + + if len(ret) == 0 { + panic("no return value specified for CreateSnapshot") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(ctx, ref, snapshot) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockinstanceService_CreateSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSnapshot' +type MockinstanceService_CreateSnapshot_Call struct { + *mock.Call +} + +// CreateSnapshot is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - snapshot string +func (_e *MockinstanceService_Expecter) CreateSnapshot(ctx interface{}, ref interface{}, snapshot interface{}) *MockinstanceService_CreateSnapshot_Call { + return &MockinstanceService_CreateSnapshot_Call{Call: _e.mock.On("CreateSnapshot", ctx, ref, snapshot)} +} + +func (_c *MockinstanceService_CreateSnapshot_Call) Run(run func(ctx context.Context, ref compute.Ref, snapshot string)) *MockinstanceService_CreateSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_CreateSnapshot_Call) Return(err error) *MockinstanceService_CreateSnapshot_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockinstanceService_CreateSnapshot_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, snapshot string) error) *MockinstanceService_CreateSnapshot_Call { + _c.Call.Return(run) + return _c +} + // DeleteInstance provides a mock function for the type MockinstanceService func (_mock *MockinstanceService) DeleteInstance(ctx context.Context, ref compute.Ref) error { ret := _mock.Called(ctx, ref) @@ -509,7 +572,7 @@ type MockinstanceService_DeleteInstance_Call struct { // DeleteInstance is a helper method to define mock.On call // - ctx context.Context // - ref compute.Ref -func (_e *MockinstanceService_Expecter) DeleteInstance(ctx any, ref any) *MockinstanceService_DeleteInstance_Call { +func (_e *MockinstanceService_Expecter) DeleteInstance(ctx interface{}, ref interface{}) *MockinstanceService_DeleteInstance_Call { return &MockinstanceService_DeleteInstance_Call{Call: _e.mock.On("DeleteInstance", ctx, ref)} } @@ -541,6 +604,69 @@ func (_c *MockinstanceService_DeleteInstance_Call) RunAndReturn(run func(ctx con return _c } +// DeleteSnapshot provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) DeleteSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + ret := _mock.Called(ctx, ref, snapshot) + + if len(ret) == 0 { + panic("no return value specified for DeleteSnapshot") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(ctx, ref, snapshot) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockinstanceService_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot' +type MockinstanceService_DeleteSnapshot_Call struct { + *mock.Call +} + +// DeleteSnapshot is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - snapshot string +func (_e *MockinstanceService_Expecter) DeleteSnapshot(ctx interface{}, ref interface{}, snapshot interface{}) *MockinstanceService_DeleteSnapshot_Call { + return &MockinstanceService_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", ctx, ref, snapshot)} +} + +func (_c *MockinstanceService_DeleteSnapshot_Call) Run(run func(ctx context.Context, ref compute.Ref, snapshot string)) *MockinstanceService_DeleteSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_DeleteSnapshot_Call) Return(err error) *MockinstanceService_DeleteSnapshot_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockinstanceService_DeleteSnapshot_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, snapshot string) error) *MockinstanceService_DeleteSnapshot_Call { + _c.Call.Return(run) + return _c +} + // Exec provides a mock function for the type MockinstanceService func (_mock *MockinstanceService) Exec(ctx context.Context, req compute.ExecRequest) (compute.ExecResult, error) { ret := _mock.Called(ctx, req) @@ -575,7 +701,7 @@ type MockinstanceService_Exec_Call struct { // Exec is a helper method to define mock.On call // - ctx context.Context // - req compute.ExecRequest -func (_e *MockinstanceService_Expecter) Exec(ctx any, req any) *MockinstanceService_Exec_Call { +func (_e *MockinstanceService_Expecter) Exec(ctx interface{}, req interface{}) *MockinstanceService_Exec_Call { return &MockinstanceService_Exec_Call{Call: _e.mock.On("Exec", ctx, req)} } @@ -641,7 +767,7 @@ type MockinstanceService_GetInstance_Call struct { // GetInstance is a helper method to define mock.On call // - ctx context.Context // - ref compute.Ref -func (_e *MockinstanceService_Expecter) GetInstance(ctx any, ref any) *MockinstanceService_GetInstance_Call { +func (_e *MockinstanceService_Expecter) GetInstance(ctx interface{}, ref interface{}) *MockinstanceService_GetInstance_Call { return &MockinstanceService_GetInstance_Call{Call: _e.mock.On("GetInstance", ctx, ref)} } @@ -709,7 +835,7 @@ type MockinstanceService_ListInstances_Call struct { // ListInstances is a helper method to define mock.On call // - ctx context.Context // - sandbox string -func (_e *MockinstanceService_Expecter) ListInstances(ctx any, sandbox any) *MockinstanceService_ListInstances_Call { +func (_e *MockinstanceService_Expecter) ListInstances(ctx interface{}, sandbox interface{}) *MockinstanceService_ListInstances_Call { return &MockinstanceService_ListInstances_Call{Call: _e.mock.On("ListInstances", ctx, sandbox)} } @@ -741,76 +867,114 @@ func (_c *MockinstanceService_ListInstances_Call) RunAndReturn(run func(ctx cont return _c } -// NewMocknetworkService creates a new instance of MocknetworkService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMocknetworkService(t interface { - mock.TestingT - Cleanup(func()) -}) *MocknetworkService { - mock := &MocknetworkService{} - mock.Mock.Test(t) +// ListSnapshots provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) ListSnapshots(ctx context.Context, ref compute.Ref) ([]compute.Snapshot, error) { + ret := _mock.Called(ctx, ref) - t.Cleanup(func() { mock.AssertExpectations(t) }) + if len(ret) == 0 { + panic("no return value specified for ListSnapshots") + } - return mock + var r0 []compute.Snapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) ([]compute.Snapshot, error)); ok { + return returnFunc(ctx, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref) []compute.Snapshot); ok { + r0 = returnFunc(ctx, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Snapshot) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref) error); ok { + r1 = returnFunc(ctx, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 } -// MocknetworkService is an autogenerated mock type for the networkService type -type MocknetworkService struct { - mock.Mock +// MockinstanceService_ListSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSnapshots' +type MockinstanceService_ListSnapshots_Call struct { + *mock.Call } -type MocknetworkService_Expecter struct { - mock *mock.Mock +// ListSnapshots is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +func (_e *MockinstanceService_Expecter) ListSnapshots(ctx interface{}, ref interface{}) *MockinstanceService_ListSnapshots_Call { + return &MockinstanceService_ListSnapshots_Call{Call: _e.mock.On("ListSnapshots", ctx, ref)} } -func (_m *MocknetworkService) EXPECT() *MocknetworkService_Expecter { - return &MocknetworkService_Expecter{mock: &_m.Mock} +func (_c *MockinstanceService_ListSnapshots_Call) Run(run func(ctx context.Context, ref compute.Ref)) *MockinstanceService_ListSnapshots_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + run( + arg0, + arg1, + ) + }) + return _c } -// AttachNIC provides a mock function for the type MocknetworkService -func (_mock *MocknetworkService) AttachNIC(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string) (compute.NIC, error) { - ret := _mock.Called(ctx, ref, network, nic, ip, mac) +func (_c *MockinstanceService_ListSnapshots_Call) Return(snapshots []compute.Snapshot, err error) *MockinstanceService_ListSnapshots_Call { + _c.Call.Return(snapshots, err) + return _c +} + +func (_c *MockinstanceService_ListSnapshots_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref) ([]compute.Snapshot, error)) *MockinstanceService_ListSnapshots_Call { + _c.Call.Return(run) + return _c +} + +// PublishInstance provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) PublishInstance(ctx context.Context, ref compute.Ref, image string) (string, error) { + ret := _mock.Called(ctx, ref, image) if len(ret) == 0 { - panic("no return value specified for AttachNIC") + panic("no return value specified for PublishInstance") } - var r0 compute.NIC + var r0 string var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string, string, string, string) (compute.NIC, error)); ok { - return returnFunc(ctx, ref, network, nic, ip, mac) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) (string, error)); ok { + return returnFunc(ctx, ref, image) } - if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string, string, string, string) compute.NIC); ok { - r0 = returnFunc(ctx, ref, network, nic, ip, mac) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) string); ok { + r0 = returnFunc(ctx, ref, image) } else { - r0 = ret.Get(0).(compute.NIC) + r0 = ret.Get(0).(string) } - if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, string, string, string, string) error); ok { - r1 = returnFunc(ctx, ref, network, nic, ip, mac) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, string) error); ok { + r1 = returnFunc(ctx, ref, image) } else { r1 = ret.Error(1) } return r0, r1 } -// MocknetworkService_AttachNIC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AttachNIC' -type MocknetworkService_AttachNIC_Call struct { +// MockinstanceService_PublishInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PublishInstance' +type MockinstanceService_PublishInstance_Call struct { *mock.Call } -// AttachNIC is a helper method to define mock.On call +// PublishInstance is a helper method to define mock.On call // - ctx context.Context // - ref compute.Ref -// - network string -// - nic string -// - ip string -// - mac string -func (_e *MocknetworkService_Expecter) AttachNIC(ctx any, ref any, network any, nic any, ip any, mac any) *MocknetworkService_AttachNIC_Call { - return &MocknetworkService_AttachNIC_Call{Call: _e.mock.On("AttachNIC", ctx, ref, network, nic, ip, mac)} +// - image string +func (_e *MockinstanceService_Expecter) PublishInstance(ctx interface{}, ref interface{}, image interface{}) *MockinstanceService_PublishInstance_Call { + return &MockinstanceService_PublishInstance_Call{Call: _e.mock.On("PublishInstance", ctx, ref, image)} } -func (_c *MocknetworkService_AttachNIC_Call) Run(run func(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string)) *MocknetworkService_AttachNIC_Call { +func (_c *MockinstanceService_PublishInstance_Call) Run(run func(ctx context.Context, ref compute.Ref, image string)) *MockinstanceService_PublishInstance_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -824,108 +988,1406 @@ func (_c *MocknetworkService_AttachNIC_Call) Run(run func(ctx context.Context, r if args[2] != nil { arg2 = args[2].(string) } - var arg3 string - if args[3] != nil { - arg3 = args[3].(string) - } - var arg4 string - if args[4] != nil { - arg4 = args[4].(string) - } - var arg5 string - if args[5] != nil { - arg5 = args[5].(string) - } run( arg0, arg1, arg2, - arg3, - arg4, - arg5, ) }) return _c } -func (_c *MocknetworkService_AttachNIC_Call) Return(nIC compute.NIC, err error) *MocknetworkService_AttachNIC_Call { - _c.Call.Return(nIC, err) +func (_c *MockinstanceService_PublishInstance_Call) Return(s string, err error) *MockinstanceService_PublishInstance_Call { + _c.Call.Return(s, err) return _c } -func (_c *MocknetworkService_AttachNIC_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string) (compute.NIC, error)) *MocknetworkService_AttachNIC_Call { +func (_c *MockinstanceService_PublishInstance_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, image string) (string, error)) *MockinstanceService_PublishInstance_Call { _c.Call.Return(run) return _c } -// CreateNetwork provides a mock function for the type MocknetworkService -func (_mock *MocknetworkService) CreateNetwork(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error) { - ret := _mock.Called(ctx, sandbox, network) +// ReadFile provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) ReadFile(ctx context.Context, req compute.FileReadRequest) (compute.FileReadResult, error) { + ret := _mock.Called(ctx, req) if len(ret) == 0 { - panic("no return value specified for CreateNetwork") + panic("no return value specified for ReadFile") } - var r0 compute.Network + var r0 compute.FileReadResult var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, compute.Network) (compute.Network, error)); ok { - return returnFunc(ctx, sandbox, network) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileReadRequest) (compute.FileReadResult, error)); ok { + return returnFunc(ctx, req) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, compute.Network) compute.Network); ok { - r0 = returnFunc(ctx, sandbox, network) + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileReadRequest) compute.FileReadResult); ok { + r0 = returnFunc(ctx, req) } else { - r0 = ret.Get(0).(compute.Network) + r0 = ret.Get(0).(compute.FileReadResult) } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, compute.Network) error); ok { - r1 = returnFunc(ctx, sandbox, network) + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.FileReadRequest) error); ok { + r1 = returnFunc(ctx, req) } else { r1 = ret.Error(1) } return r0, r1 } -// MocknetworkService_CreateNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNetwork' -type MocknetworkService_CreateNetwork_Call struct { +// MockinstanceService_ReadFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadFile' +type MockinstanceService_ReadFile_Call struct { *mock.Call } -// CreateNetwork is a helper method to define mock.On call +// ReadFile is a helper method to define mock.On call // - ctx context.Context -// - sandbox string -// - network compute.Network -func (_e *MocknetworkService_Expecter) CreateNetwork(ctx any, sandbox any, network any) *MocknetworkService_CreateNetwork_Call { - return &MocknetworkService_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, sandbox, network)} +// - req compute.FileReadRequest +func (_e *MockinstanceService_Expecter) ReadFile(ctx interface{}, req interface{}) *MockinstanceService_ReadFile_Call { + return &MockinstanceService_ReadFile_Call{Call: _e.mock.On("ReadFile", ctx, req)} } -func (_c *MocknetworkService_CreateNetwork_Call) Run(run func(ctx context.Context, sandbox string, network compute.Network)) *MocknetworkService_CreateNetwork_Call { +func (_c *MockinstanceService_ReadFile_Call) Run(run func(ctx context.Context, req compute.FileReadRequest)) *MockinstanceService_ReadFile_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 compute.FileReadRequest if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 compute.Network - if args[2] != nil { - arg2 = args[2].(compute.Network) + arg1 = args[1].(compute.FileReadRequest) } run( arg0, arg1, - arg2, ) }) return _c } -func (_c *MocknetworkService_CreateNetwork_Call) Return(network1 compute.Network, err error) *MocknetworkService_CreateNetwork_Call { - _c.Call.Return(network1, err) +func (_c *MockinstanceService_ReadFile_Call) Return(fileReadResult compute.FileReadResult, err error) *MockinstanceService_ReadFile_Call { + _c.Call.Return(fileReadResult, err) return _c } -func (_c *MocknetworkService_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error)) *MocknetworkService_CreateNetwork_Call { +func (_c *MockinstanceService_ReadFile_Call) RunAndReturn(run func(ctx context.Context, req compute.FileReadRequest) (compute.FileReadResult, error)) *MockinstanceService_ReadFile_Call { + _c.Call.Return(run) + return _c +} + +// ResolveImage provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) ResolveImage(ctx context.Context, sandbox string, name string) (compute.CatalogImage, error) { + ret := _mock.Called(ctx, sandbox, name) + + if len(ret) == 0 { + panic("no return value specified for ResolveImage") + } + + var r0 compute.CatalogImage + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (compute.CatalogImage, error)); ok { + return returnFunc(ctx, sandbox, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) compute.CatalogImage); ok { + r0 = returnFunc(ctx, sandbox, name) + } else { + r0 = ret.Get(0).(compute.CatalogImage) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, sandbox, name) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_ResolveImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveImage' +type MockinstanceService_ResolveImage_Call struct { + *mock.Call +} + +// ResolveImage is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - name string +func (_e *MockinstanceService_Expecter) ResolveImage(ctx interface{}, sandbox interface{}, name interface{}) *MockinstanceService_ResolveImage_Call { + return &MockinstanceService_ResolveImage_Call{Call: _e.mock.On("ResolveImage", ctx, sandbox, name)} +} + +func (_c *MockinstanceService_ResolveImage_Call) Run(run func(ctx context.Context, sandbox string, name string)) *MockinstanceService_ResolveImage_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_ResolveImage_Call) Return(catalogImage compute.CatalogImage, err error) *MockinstanceService_ResolveImage_Call { + _c.Call.Return(catalogImage, err) + return _c +} + +func (_c *MockinstanceService_ResolveImage_Call) RunAndReturn(run func(ctx context.Context, sandbox string, name string) (compute.CatalogImage, error)) *MockinstanceService_ResolveImage_Call { + _c.Call.Return(run) + return _c +} + +// RestartInstance provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) RestartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + ret := _mock.Called(ctx, ref, force) + + if len(ret) == 0 { + panic("no return value specified for RestartInstance") + } + + var r0 compute.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(ctx, ref, force) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(ctx, ref, force) + } else { + r0 = ret.Get(0).(compute.Instance) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(ctx, ref, force) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_RestartInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartInstance' +type MockinstanceService_RestartInstance_Call struct { + *mock.Call +} + +// RestartInstance is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - force bool +func (_e *MockinstanceService_Expecter) RestartInstance(ctx interface{}, ref interface{}, force interface{}) *MockinstanceService_RestartInstance_Call { + return &MockinstanceService_RestartInstance_Call{Call: _e.mock.On("RestartInstance", ctx, ref, force)} +} + +func (_c *MockinstanceService_RestartInstance_Call) Run(run func(ctx context.Context, ref compute.Ref, force bool)) *MockinstanceService_RestartInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_RestartInstance_Call) Return(instance compute.Instance, err error) *MockinstanceService_RestartInstance_Call { + _c.Call.Return(instance, err) + return _c +} + +func (_c *MockinstanceService_RestartInstance_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error)) *MockinstanceService_RestartInstance_Call { + _c.Call.Return(run) + return _c +} + +// RestoreSnapshot provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) RestoreSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error { + ret := _mock.Called(ctx, ref, snapshot) + + if len(ret) == 0 { + panic("no return value specified for RestoreSnapshot") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(ctx, ref, snapshot) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockinstanceService_RestoreSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreSnapshot' +type MockinstanceService_RestoreSnapshot_Call struct { + *mock.Call +} + +// RestoreSnapshot is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - snapshot string +func (_e *MockinstanceService_Expecter) RestoreSnapshot(ctx interface{}, ref interface{}, snapshot interface{}) *MockinstanceService_RestoreSnapshot_Call { + return &MockinstanceService_RestoreSnapshot_Call{Call: _e.mock.On("RestoreSnapshot", ctx, ref, snapshot)} +} + +func (_c *MockinstanceService_RestoreSnapshot_Call) Run(run func(ctx context.Context, ref compute.Ref, snapshot string)) *MockinstanceService_RestoreSnapshot_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_RestoreSnapshot_Call) Return(err error) *MockinstanceService_RestoreSnapshot_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockinstanceService_RestoreSnapshot_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, snapshot string) error) *MockinstanceService_RestoreSnapshot_Call { + _c.Call.Return(run) + return _c +} + +// StartInstance provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) StartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + ret := _mock.Called(ctx, ref, force) + + if len(ret) == 0 { + panic("no return value specified for StartInstance") + } + + var r0 compute.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(ctx, ref, force) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(ctx, ref, force) + } else { + r0 = ret.Get(0).(compute.Instance) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(ctx, ref, force) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_StartInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartInstance' +type MockinstanceService_StartInstance_Call struct { + *mock.Call +} + +// StartInstance is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - force bool +func (_e *MockinstanceService_Expecter) StartInstance(ctx interface{}, ref interface{}, force interface{}) *MockinstanceService_StartInstance_Call { + return &MockinstanceService_StartInstance_Call{Call: _e.mock.On("StartInstance", ctx, ref, force)} +} + +func (_c *MockinstanceService_StartInstance_Call) Run(run func(ctx context.Context, ref compute.Ref, force bool)) *MockinstanceService_StartInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_StartInstance_Call) Return(instance compute.Instance, err error) *MockinstanceService_StartInstance_Call { + _c.Call.Return(instance, err) + return _c +} + +func (_c *MockinstanceService_StartInstance_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error)) *MockinstanceService_StartInstance_Call { + _c.Call.Return(run) + return _c +} + +// StopInstance provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) StopInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) { + ret := _mock.Called(ctx, ref, force) + + if len(ret) == 0 { + panic("no return value specified for StopInstance") + } + + var r0 compute.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) (compute.Instance, error)); ok { + return returnFunc(ctx, ref, force) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, bool) compute.Instance); ok { + r0 = returnFunc(ctx, ref, force) + } else { + r0 = ret.Get(0).(compute.Instance) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, bool) error); ok { + r1 = returnFunc(ctx, ref, force) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_StopInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopInstance' +type MockinstanceService_StopInstance_Call struct { + *mock.Call +} + +// StopInstance is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - force bool +func (_e *MockinstanceService_Expecter) StopInstance(ctx interface{}, ref interface{}, force interface{}) *MockinstanceService_StopInstance_Call { + return &MockinstanceService_StopInstance_Call{Call: _e.mock.On("StopInstance", ctx, ref, force)} +} + +func (_c *MockinstanceService_StopInstance_Call) Run(run func(ctx context.Context, ref compute.Ref, force bool)) *MockinstanceService_StopInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockinstanceService_StopInstance_Call) Return(instance compute.Instance, err error) *MockinstanceService_StopInstance_Call { + _c.Call.Return(instance, err) + return _c +} + +func (_c *MockinstanceService_StopInstance_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error)) *MockinstanceService_StopInstance_Call { + _c.Call.Return(run) + return _c +} + +// WaitInstance provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) WaitInstance(ctx context.Context, req compute.WaitRequest) (compute.WaitResult, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for WaitInstance") + } + + var r0 compute.WaitResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.WaitRequest) (compute.WaitResult, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.WaitRequest) compute.WaitResult); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(compute.WaitResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.WaitRequest) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_WaitInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitInstance' +type MockinstanceService_WaitInstance_Call struct { + *mock.Call +} + +// WaitInstance is a helper method to define mock.On call +// - ctx context.Context +// - req compute.WaitRequest +func (_e *MockinstanceService_Expecter) WaitInstance(ctx interface{}, req interface{}) *MockinstanceService_WaitInstance_Call { + return &MockinstanceService_WaitInstance_Call{Call: _e.mock.On("WaitInstance", ctx, req)} +} + +func (_c *MockinstanceService_WaitInstance_Call) Run(run func(ctx context.Context, req compute.WaitRequest)) *MockinstanceService_WaitInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.WaitRequest + if args[1] != nil { + arg1 = args[1].(compute.WaitRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockinstanceService_WaitInstance_Call) Return(waitResult compute.WaitResult, err error) *MockinstanceService_WaitInstance_Call { + _c.Call.Return(waitResult, err) + return _c +} + +func (_c *MockinstanceService_WaitInstance_Call) RunAndReturn(run func(ctx context.Context, req compute.WaitRequest) (compute.WaitResult, error)) *MockinstanceService_WaitInstance_Call { + _c.Call.Return(run) + return _c +} + +// WriteFile provides a mock function for the type MockinstanceService +func (_mock *MockinstanceService) WriteFile(ctx context.Context, req compute.FileWriteRequest) (compute.FileWriteResult, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for WriteFile") + } + + var r0 compute.FileWriteResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileWriteRequest) (compute.FileWriteResult, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.FileWriteRequest) compute.FileWriteResult); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(compute.FileWriteResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.FileWriteRequest) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockinstanceService_WriteFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteFile' +type MockinstanceService_WriteFile_Call struct { + *mock.Call +} + +// WriteFile is a helper method to define mock.On call +// - ctx context.Context +// - req compute.FileWriteRequest +func (_e *MockinstanceService_Expecter) WriteFile(ctx interface{}, req interface{}) *MockinstanceService_WriteFile_Call { + return &MockinstanceService_WriteFile_Call{Call: _e.mock.On("WriteFile", ctx, req)} +} + +func (_c *MockinstanceService_WriteFile_Call) Run(run func(ctx context.Context, req compute.FileWriteRequest)) *MockinstanceService_WriteFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.FileWriteRequest + if args[1] != nil { + arg1 = args[1].(compute.FileWriteRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockinstanceService_WriteFile_Call) Return(fileWriteResult compute.FileWriteResult, err error) *MockinstanceService_WriteFile_Call { + _c.Call.Return(fileWriteResult, err) + return _c +} + +func (_c *MockinstanceService_WriteFile_Call) RunAndReturn(run func(ctx context.Context, req compute.FileWriteRequest) (compute.FileWriteResult, error)) *MockinstanceService_WriteFile_Call { + _c.Call.Return(run) + return _c +} + +// NewMocknetworkService creates a new instance of MocknetworkService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMocknetworkService(t interface { + mock.TestingT + Cleanup(func()) +}) *MocknetworkService { + mock := &MocknetworkService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MocknetworkService is an autogenerated mock type for the networkService type +type MocknetworkService struct { + mock.Mock +} + +type MocknetworkService_Expecter struct { + mock *mock.Mock +} + +func (_m *MocknetworkService) EXPECT() *MocknetworkService_Expecter { + return &MocknetworkService_Expecter{mock: &_m.Mock} +} + +// AddACLRule provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) AddACLRule(ctx context.Context, sandbox string, network string, rule compute.ACLRule) (compute.ACLRule, error) { + ret := _mock.Called(ctx, sandbox, network, rule) + + if len(ret) == 0 { + panic("no return value specified for AddACLRule") + } + + var r0 compute.ACLRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.ACLRule) (compute.ACLRule, error)); ok { + return returnFunc(ctx, sandbox, network, rule) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.ACLRule) compute.ACLRule); ok { + r0 = returnFunc(ctx, sandbox, network, rule) + } else { + r0 = ret.Get(0).(compute.ACLRule) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, compute.ACLRule) error); ok { + r1 = returnFunc(ctx, sandbox, network, rule) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_AddACLRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddACLRule' +type MocknetworkService_AddACLRule_Call struct { + *mock.Call +} + +// AddACLRule is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - network string +// - rule compute.ACLRule +func (_e *MocknetworkService_Expecter) AddACLRule(ctx interface{}, sandbox interface{}, network interface{}, rule interface{}) *MocknetworkService_AddACLRule_Call { + return &MocknetworkService_AddACLRule_Call{Call: _e.mock.On("AddACLRule", ctx, sandbox, network, rule)} +} + +func (_c *MocknetworkService_AddACLRule_Call) Run(run func(ctx context.Context, sandbox string, network string, rule compute.ACLRule)) *MocknetworkService_AddACLRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 compute.ACLRule + if args[3] != nil { + arg3 = args[3].(compute.ACLRule) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MocknetworkService_AddACLRule_Call) Return(aCLRule compute.ACLRule, err error) *MocknetworkService_AddACLRule_Call { + _c.Call.Return(aCLRule, err) + return _c +} + +func (_c *MocknetworkService_AddACLRule_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network string, rule compute.ACLRule) (compute.ACLRule, error)) *MocknetworkService_AddACLRule_Call { + _c.Call.Return(run) + return _c +} + +// AttachNIC provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) AttachNIC(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string) (compute.NIC, error) { + ret := _mock.Called(ctx, ref, network, nic, ip, mac) + + if len(ret) == 0 { + panic("no return value specified for AttachNIC") + } + + var r0 compute.NIC + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string, string, string, string) (compute.NIC, error)); ok { + return returnFunc(ctx, ref, network, nic, ip, mac) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string, string, string, string) compute.NIC); ok { + r0 = returnFunc(ctx, ref, network, nic, ip, mac) + } else { + r0 = ret.Get(0).(compute.NIC) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, compute.Ref, string, string, string, string) error); ok { + r1 = returnFunc(ctx, ref, network, nic, ip, mac) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_AttachNIC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AttachNIC' +type MocknetworkService_AttachNIC_Call struct { + *mock.Call +} + +// AttachNIC is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - network string +// - nic string +// - ip string +// - mac string +func (_e *MocknetworkService_Expecter) AttachNIC(ctx interface{}, ref interface{}, network interface{}, nic interface{}, ip interface{}, mac interface{}) *MocknetworkService_AttachNIC_Call { + return &MocknetworkService_AttachNIC_Call{Call: _e.mock.On("AttachNIC", ctx, ref, network, nic, ip, mac)} +} + +func (_c *MocknetworkService_AttachNIC_Call) Run(run func(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string)) *MocknetworkService_AttachNIC_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + ) + }) + return _c +} + +func (_c *MocknetworkService_AttachNIC_Call) Return(nIC compute.NIC, err error) *MocknetworkService_AttachNIC_Call { + _c.Call.Return(nIC, err) + return _c +} + +func (_c *MocknetworkService_AttachNIC_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, network string, nic string, ip string, mac string) (compute.NIC, error)) *MocknetworkService_AttachNIC_Call { + _c.Call.Return(run) + return _c +} + +// CreateForward provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) CreateForward(ctx context.Context, sandbox string, network string, ref compute.Ref, port int64, listenPort int64, protocol string) (compute.Forward, error) { + ret := _mock.Called(ctx, sandbox, network, ref, port, listenPort, protocol) + + if len(ret) == 0 { + panic("no return value specified for CreateForward") + } + + var r0 compute.Forward + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.Ref, int64, int64, string) (compute.Forward, error)); ok { + return returnFunc(ctx, sandbox, network, ref, port, listenPort, protocol) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, compute.Ref, int64, int64, string) compute.Forward); ok { + r0 = returnFunc(ctx, sandbox, network, ref, port, listenPort, protocol) + } else { + r0 = ret.Get(0).(compute.Forward) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, compute.Ref, int64, int64, string) error); ok { + r1 = returnFunc(ctx, sandbox, network, ref, port, listenPort, protocol) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_CreateForward_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateForward' +type MocknetworkService_CreateForward_Call struct { + *mock.Call +} + +// CreateForward is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - network string +// - ref compute.Ref +// - port int64 +// - listenPort int64 +// - protocol string +func (_e *MocknetworkService_Expecter) CreateForward(ctx interface{}, sandbox interface{}, network interface{}, ref interface{}, port interface{}, listenPort interface{}, protocol interface{}) *MocknetworkService_CreateForward_Call { + return &MocknetworkService_CreateForward_Call{Call: _e.mock.On("CreateForward", ctx, sandbox, network, ref, port, listenPort, protocol)} +} + +func (_c *MocknetworkService_CreateForward_Call) Run(run func(ctx context.Context, sandbox string, network string, ref compute.Ref, port int64, listenPort int64, protocol string)) *MocknetworkService_CreateForward_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 compute.Ref + if args[3] != nil { + arg3 = args[3].(compute.Ref) + } + var arg4 int64 + if args[4] != nil { + arg4 = args[4].(int64) + } + var arg5 int64 + if args[5] != nil { + arg5 = args[5].(int64) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + ) + }) + return _c +} + +func (_c *MocknetworkService_CreateForward_Call) Return(forward compute.Forward, err error) *MocknetworkService_CreateForward_Call { + _c.Call.Return(forward, err) + return _c +} + +func (_c *MocknetworkService_CreateForward_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network string, ref compute.Ref, port int64, listenPort int64, protocol string) (compute.Forward, error)) *MocknetworkService_CreateForward_Call { + _c.Call.Return(run) + return _c +} + +// CreateNetwork provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) CreateNetwork(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error) { + ret := _mock.Called(ctx, sandbox, network) + + if len(ret) == 0 { + panic("no return value specified for CreateNetwork") + } + + var r0 compute.Network + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, compute.Network) (compute.Network, error)); ok { + return returnFunc(ctx, sandbox, network) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, compute.Network) compute.Network); ok { + r0 = returnFunc(ctx, sandbox, network) + } else { + r0 = ret.Get(0).(compute.Network) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, compute.Network) error); ok { + r1 = returnFunc(ctx, sandbox, network) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_CreateNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNetwork' +type MocknetworkService_CreateNetwork_Call struct { + *mock.Call +} + +// CreateNetwork is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - network compute.Network +func (_e *MocknetworkService_Expecter) CreateNetwork(ctx interface{}, sandbox interface{}, network interface{}) *MocknetworkService_CreateNetwork_Call { + return &MocknetworkService_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, sandbox, network)} +} + +func (_c *MocknetworkService_CreateNetwork_Call) Run(run func(ctx context.Context, sandbox string, network compute.Network)) *MocknetworkService_CreateNetwork_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 compute.Network + if args[2] != nil { + arg2 = args[2].(compute.Network) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MocknetworkService_CreateNetwork_Call) Return(network1 compute.Network, err error) *MocknetworkService_CreateNetwork_Call { + _c.Call.Return(network1, err) + return _c +} + +func (_c *MocknetworkService_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error)) *MocknetworkService_CreateNetwork_Call { + _c.Call.Return(run) + return _c +} + +// DeleteNetwork provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) DeleteNetwork(ctx context.Context, sandbox string, name string) error { + ret := _mock.Called(ctx, sandbox, name) + + if len(ret) == 0 { + panic("no return value specified for DeleteNetwork") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, sandbox, name) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MocknetworkService_DeleteNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteNetwork' +type MocknetworkService_DeleteNetwork_Call struct { + *mock.Call +} + +// DeleteNetwork is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - name string +func (_e *MocknetworkService_Expecter) DeleteNetwork(ctx interface{}, sandbox interface{}, name interface{}) *MocknetworkService_DeleteNetwork_Call { + return &MocknetworkService_DeleteNetwork_Call{Call: _e.mock.On("DeleteNetwork", ctx, sandbox, name)} +} + +func (_c *MocknetworkService_DeleteNetwork_Call) Run(run func(ctx context.Context, sandbox string, name string)) *MocknetworkService_DeleteNetwork_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MocknetworkService_DeleteNetwork_Call) Return(err error) *MocknetworkService_DeleteNetwork_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MocknetworkService_DeleteNetwork_Call) RunAndReturn(run func(ctx context.Context, sandbox string, name string) error) *MocknetworkService_DeleteNetwork_Call { + _c.Call.Return(run) + return _c +} + +// DetachNIC provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) DetachNIC(ctx context.Context, ref compute.Ref, nic string) error { + ret := _mock.Called(ctx, ref, nic) + + if len(ret) == 0 { + panic("no return value specified for DetachNIC") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string) error); ok { + r0 = returnFunc(ctx, ref, nic) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MocknetworkService_DetachNIC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetachNIC' +type MocknetworkService_DetachNIC_Call struct { + *mock.Call +} + +// DetachNIC is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - nic string +func (_e *MocknetworkService_Expecter) DetachNIC(ctx interface{}, ref interface{}, nic interface{}) *MocknetworkService_DetachNIC_Call { + return &MocknetworkService_DetachNIC_Call{Call: _e.mock.On("DetachNIC", ctx, ref, nic)} +} + +func (_c *MocknetworkService_DetachNIC_Call) Run(run func(ctx context.Context, ref compute.Ref, nic string)) *MocknetworkService_DetachNIC_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MocknetworkService_DetachNIC_Call) Return(err error) *MocknetworkService_DetachNIC_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MocknetworkService_DetachNIC_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, nic string) error) *MocknetworkService_DetachNIC_Call { + _c.Call.Return(run) + return _c +} + +// GetNetwork provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) GetNetwork(ctx context.Context, sandbox string, name string) (compute.Network, error) { + ret := _mock.Called(ctx, sandbox, name) + + if len(ret) == 0 { + panic("no return value specified for GetNetwork") + } + + var r0 compute.Network + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (compute.Network, error)); ok { + return returnFunc(ctx, sandbox, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) compute.Network); ok { + r0 = returnFunc(ctx, sandbox, name) + } else { + r0 = ret.Get(0).(compute.Network) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, sandbox, name) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_GetNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetwork' +type MocknetworkService_GetNetwork_Call struct { + *mock.Call +} + +// GetNetwork is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - name string +func (_e *MocknetworkService_Expecter) GetNetwork(ctx interface{}, sandbox interface{}, name interface{}) *MocknetworkService_GetNetwork_Call { + return &MocknetworkService_GetNetwork_Call{Call: _e.mock.On("GetNetwork", ctx, sandbox, name)} +} + +func (_c *MocknetworkService_GetNetwork_Call) Run(run func(ctx context.Context, sandbox string, name string)) *MocknetworkService_GetNetwork_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MocknetworkService_GetNetwork_Call) Return(network compute.Network, err error) *MocknetworkService_GetNetwork_Call { + _c.Call.Return(network, err) + return _c +} + +func (_c *MocknetworkService_GetNetwork_Call) RunAndReturn(run func(ctx context.Context, sandbox string, name string) (compute.Network, error)) *MocknetworkService_GetNetwork_Call { + _c.Call.Return(run) + return _c +} + +// ImpairNIC provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) ImpairNIC(ctx context.Context, ref compute.Ref, nic string, impairment compute.Impairment) error { + ret := _mock.Called(ctx, ref, nic, impairment) + + if len(ret) == 0 { + panic("no return value specified for ImpairNIC") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, compute.Ref, string, compute.Impairment) error); ok { + r0 = returnFunc(ctx, ref, nic, impairment) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MocknetworkService_ImpairNIC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ImpairNIC' +type MocknetworkService_ImpairNIC_Call struct { + *mock.Call +} + +// ImpairNIC is a helper method to define mock.On call +// - ctx context.Context +// - ref compute.Ref +// - nic string +// - impairment compute.Impairment +func (_e *MocknetworkService_Expecter) ImpairNIC(ctx interface{}, ref interface{}, nic interface{}, impairment interface{}) *MocknetworkService_ImpairNIC_Call { + return &MocknetworkService_ImpairNIC_Call{Call: _e.mock.On("ImpairNIC", ctx, ref, nic, impairment)} +} + +func (_c *MocknetworkService_ImpairNIC_Call) Run(run func(ctx context.Context, ref compute.Ref, nic string, impairment compute.Impairment)) *MocknetworkService_ImpairNIC_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 compute.Ref + if args[1] != nil { + arg1 = args[1].(compute.Ref) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 compute.Impairment + if args[3] != nil { + arg3 = args[3].(compute.Impairment) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MocknetworkService_ImpairNIC_Call) Return(err error) *MocknetworkService_ImpairNIC_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MocknetworkService_ImpairNIC_Call) RunAndReturn(run func(ctx context.Context, ref compute.Ref, nic string, impairment compute.Impairment) error) *MocknetworkService_ImpairNIC_Call { + _c.Call.Return(run) + return _c +} + +// ListNetworks provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) ListNetworks(ctx context.Context, sandbox string) ([]compute.Network, error) { + ret := _mock.Called(ctx, sandbox) + + if len(ret) == 0 { + panic("no return value specified for ListNetworks") + } + + var r0 []compute.Network + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]compute.Network, error)); ok { + return returnFunc(ctx, sandbox) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []compute.Network); ok { + r0 = returnFunc(ctx, sandbox) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]compute.Network) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, sandbox) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MocknetworkService_ListNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNetworks' +type MocknetworkService_ListNetworks_Call struct { + *mock.Call +} + +// ListNetworks is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +func (_e *MocknetworkService_Expecter) ListNetworks(ctx interface{}, sandbox interface{}) *MocknetworkService_ListNetworks_Call { + return &MocknetworkService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx, sandbox)} +} + +func (_c *MocknetworkService_ListNetworks_Call) Run(run func(ctx context.Context, sandbox string)) *MocknetworkService_ListNetworks_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MocknetworkService_ListNetworks_Call) Return(networks []compute.Network, err error) *MocknetworkService_ListNetworks_Call { + _c.Call.Return(networks, err) + return _c +} + +func (_c *MocknetworkService_ListNetworks_Call) RunAndReturn(run func(ctx context.Context, sandbox string) ([]compute.Network, error)) *MocknetworkService_ListNetworks_Call { + _c.Call.Return(run) + return _c +} + +// PeerNetworks provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) PeerNetworks(ctx context.Context, sandbox string, network string, peer string) error { + ret := _mock.Called(ctx, sandbox, network, peer) + + if len(ret) == 0 { + panic("no return value specified for PeerNetworks") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(ctx, sandbox, network, peer) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MocknetworkService_PeerNetworks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PeerNetworks' +type MocknetworkService_PeerNetworks_Call struct { + *mock.Call +} + +// PeerNetworks is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - network string +// - peer string +func (_e *MocknetworkService_Expecter) PeerNetworks(ctx interface{}, sandbox interface{}, network interface{}, peer interface{}) *MocknetworkService_PeerNetworks_Call { + return &MocknetworkService_PeerNetworks_Call{Call: _e.mock.On("PeerNetworks", ctx, sandbox, network, peer)} +} + +func (_c *MocknetworkService_PeerNetworks_Call) Run(run func(ctx context.Context, sandbox string, network string, peer string)) *MocknetworkService_PeerNetworks_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MocknetworkService_PeerNetworks_Call) Return(err error) *MocknetworkService_PeerNetworks_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MocknetworkService_PeerNetworks_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network string, peer string) error) *MocknetworkService_PeerNetworks_Call { + _c.Call.Return(run) + return _c +} + +// RemoveACLRule provides a mock function for the type MocknetworkService +func (_mock *MocknetworkService) RemoveACLRule(ctx context.Context, sandbox string, network string, rule string) error { + ret := _mock.Called(ctx, sandbox, network, rule) + + if len(ret) == 0 { + panic("no return value specified for RemoveACLRule") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(ctx, sandbox, network, rule) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MocknetworkService_RemoveACLRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveACLRule' +type MocknetworkService_RemoveACLRule_Call struct { + *mock.Call +} + +// RemoveACLRule is a helper method to define mock.On call +// - ctx context.Context +// - sandbox string +// - network string +// - rule string +func (_e *MocknetworkService_Expecter) RemoveACLRule(ctx interface{}, sandbox interface{}, network interface{}, rule interface{}) *MocknetworkService_RemoveACLRule_Call { + return &MocknetworkService_RemoveACLRule_Call{Call: _e.mock.On("RemoveACLRule", ctx, sandbox, network, rule)} +} + +func (_c *MocknetworkService_RemoveACLRule_Call) Run(run func(ctx context.Context, sandbox string, network string, rule string)) *MocknetworkService_RemoveACLRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MocknetworkService_RemoveACLRule_Call) Return(err error) *MocknetworkService_RemoveACLRule_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MocknetworkService_RemoveACLRule_Call) RunAndReturn(run func(ctx context.Context, sandbox string, network string, rule string) error) *MocknetworkService_RemoveACLRule_Call { _c.Call.Return(run) return _c } @@ -990,7 +2452,7 @@ type MockimageService_CatalogImage_Call struct { // CatalogImage is a helper method to define mock.On call // - name string -func (_e *MockimageService_Expecter) CatalogImage(name any) *MockimageService_CatalogImage_Call { +func (_e *MockimageService_Expecter) CatalogImage(name interface{}) *MockimageService_CatalogImage_Call { return &MockimageService_CatalogImage_Call{Call: _e.mock.On("CatalogImage", name)} } @@ -1045,7 +2507,7 @@ type MockimageService_ListImages_Call struct { // - os string // - desktop *bool // - platform string -func (_e *MockimageService_Expecter) ListImages(os any, desktop any, platform any) *MockimageService_ListImages_Call { +func (_e *MockimageService_Expecter) ListImages(os interface{}, desktop interface{}, platform interface{}) *MockimageService_ListImages_Call { return &MockimageService_ListImages_Call{Call: _e.mock.On("ListImages", os, desktop, platform)} } diff --git a/internal/mcpserver/net.go b/internal/mcpserver/net.go index 4fb197b..0f3eea2 100644 --- a/internal/mcpserver/net.go +++ b/internal/mcpserver/net.go @@ -9,6 +9,18 @@ import ( "github.com/GilmanLab/agentcompute/internal/compute" ) +const ( + capabilityNetList = "net.list" + capabilityNetGet = "net.get" + capabilityNetDelete = "net.delete" + capabilityNetDetach = "net.detach" + capabilityNetPeer = "net.peer" + capabilityNetACLAdd = "net.acl.add" + capabilityNetACLRemove = "net.acl.remove" + capabilityNetForward = "net.forward" + capabilityNetImpair = "net.impair" +) + type netCreateIn struct { Sandbox string `json:"sandbox"` Name string `json:"name"` @@ -26,6 +38,26 @@ type networkOut struct { Gateway string `json:"gateway"` } +type netListIn struct { + Sandbox string `json:"sandbox"` +} + +type netListOut struct { + Items []networkOut `json:"items"` +} + +type netGetIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` +} + +type netDeleteIn struct { + Sandbox string `json:"sandbox"` + Name string `json:"name"` +} + +type netDeleteOut struct{} + type netAttachIn struct { Sandbox string `json:"sandbox"` Instance string `json:"instance"` @@ -40,6 +72,72 @@ type netAttachOut struct { MAC string `json:"mac"` } +type netDetachIn struct { + Sandbox string `json:"sandbox"` + Instance string `json:"instance"` + NIC string `json:"nic"` +} + +type netDetachOut struct{} + +type netPeerIn struct { + Sandbox string `json:"sandbox"` + Network string `json:"network"` + Peer string `json:"peer"` +} + +type netPeerOut struct{} + +type netACLAddIn struct { + Sandbox string `json:"sandbox"` + Network string `json:"network"` + Direction string `json:"direction"` + Action string `json:"action"` + Protocol *string `json:"protocol,omitempty"` + Src *string `json:"src,omitempty"` + Dst *string `json:"dst,omitempty"` + Port *string `json:"port,omitempty"` +} + +type netACLAddOut struct { + Rule string `json:"rule"` +} + +type netACLRemoveIn struct { + Sandbox string `json:"sandbox"` + Network string `json:"network"` + Rule string `json:"rule"` +} + +type netACLRemoveOut struct{} + +type netForwardIn struct { + Sandbox string `json:"sandbox"` + Network string `json:"network"` + Instance string `json:"instance"` + Port int64 `json:"port"` + ListenPort *int64 `json:"listen_port,omitempty"` + Protocol *string `json:"protocol,omitempty"` +} + +type netForwardOut struct { + Address string `json:"address"` + Port int64 `json:"port"` +} + +type netImpairIn struct { + Sandbox string `json:"sandbox"` + Instance string `json:"instance"` + NIC string `json:"nic"` + LatencyMS *int64 `json:"latency_ms,omitempty"` + JitterMS *int64 `json:"jitter_ms,omitempty"` + LossPercent *float64 `json:"loss_percent,omitempty"` + RateMbit *int64 `json:"rate_mbit,omitempty"` + Clear *bool `json:"clear,omitempty"` +} + +type netImpairOut struct{} + type netAPI struct { networks networkService } @@ -49,15 +147,69 @@ func registerNet(builder *codemode.Builder, deps Dependencies) { codemode.Register(builder, codemode.Capability[netCreateIn, networkOut]{ ID: capabilityNetCreate, Name: capabilityNetCreate, - Summary: "Create a sandbox bridge network.", + Summary: "Create a sandbox network. nat=false networks are unreachable from outside the sandbox; attach a router instance or use net.peer. No uplink gateway is accepted.", Handler: api.create, }) + codemode.Register(builder, codemode.Capability[netListIn, netListOut]{ + ID: capabilityNetList, + Name: capabilityNetList, + Summary: "List sandbox networks.", + Handler: api.list, + }) + codemode.Register(builder, codemode.Capability[netGetIn, networkOut]{ + ID: capabilityNetGet, + Name: capabilityNetGet, + Summary: "Get one sandbox network.", + Handler: api.get, + }) + codemode.Register(builder, codemode.Capability[netDeleteIn, netDeleteOut]{ + ID: capabilityNetDelete, + Name: capabilityNetDelete, + Summary: "Delete a sandbox network.", + Handler: api.delete, + }) codemode.Register(builder, codemode.Capability[netAttachIn, netAttachOut]{ ID: capabilityNetAttach, Name: capabilityNetAttach, Summary: "Attach a NIC to an instance.", Handler: api.attach, }) + codemode.Register(builder, codemode.Capability[netDetachIn, netDetachOut]{ + ID: capabilityNetDetach, + Name: capabilityNetDetach, + Summary: "Detach a NIC from an instance.", + Handler: api.detach, + }) + codemode.Register(builder, codemode.Capability[netPeerIn, netPeerOut]{ + ID: capabilityNetPeer, + Name: capabilityNetPeer, + Summary: "Peer two OVN networks in a sandbox.", + Handler: api.peer, + }) + codemode.Register(builder, codemode.Capability[netACLAddIn, netACLAddOut]{ + ID: capabilityNetACLAdd, + Name: capabilityNetACLAdd, + Summary: "Add an ACL rule. Allow rules require an explicit destination IP/CIDR outside management and OOB.", + Handler: api.addACL, + }) + codemode.Register(builder, codemode.Capability[netACLRemoveIn, netACLRemoveOut]{ + ID: capabilityNetACLRemove, + Name: capabilityNetACLRemove, + Summary: "Remove a network ACL rule.", + Handler: api.removeACL, + }) + codemode.Register(builder, codemode.Capability[netForwardIn, netForwardOut]{ + ID: capabilityNetForward, + Name: capabilityNetForward, + Summary: "Forward a port from the OVN uplink range on a NAT-enabled network. Isolated nat=false networks cannot have forwards.", + Handler: api.forward, + }) + codemode.Register(builder, codemode.Capability[netImpairIn, netImpairOut]{ + ID: capabilityNetImpair, + Name: capabilityNetImpair, + Summary: "Apply Linux tc netem impairment on a NIC.", + Handler: api.impair, + }) } func (api netAPI) create( @@ -69,13 +221,14 @@ func (api netAPI) create( if err != nil { return networkOut{}, err } + ovn := kind == kindOVN network, err := api.networks.CreateNetwork(ctx, in.Sandbox, compute.Network{ Name: in.Name, Kind: kind, CIDR: deref(in.CIDR, ""), - DHCP: deref(in.DHCP, false), - NAT: deref(in.NAT, false), - DNS: deref(in.DNS, false), + DHCP: deref(in.DHCP, ovn), + NAT: deref(in.NAT, ovn), + DNS: deref(in.DNS, ovn), }) if err != nil { return networkOut{}, err @@ -83,6 +236,38 @@ func (api netAPI) create( return networkDTO(network), nil } +func (api netAPI) list( + ctx context.Context, + _ authz.Subject, + in netListIn, +) (netListOut, error) { + networks, err := api.networks.ListNetworks(ctx, in.Sandbox) + if err != nil { + return netListOut{}, err + } + return netListOut{Items: networkDTOs(networks)}, nil +} + +func (api netAPI) get( + ctx context.Context, + _ authz.Subject, + in netGetIn, +) (networkOut, error) { + network, err := api.networks.GetNetwork(ctx, in.Sandbox, in.Name) + if err != nil { + return networkOut{}, err + } + return networkDTO(network), nil +} + +func (api netAPI) delete( + ctx context.Context, + _ authz.Subject, + in netDeleteIn, +) (netDeleteOut, error) { + return netDeleteOut{}, api.networks.DeleteNetwork(ctx, in.Sandbox, in.Name) +} + func (api netAPI) attach( ctx context.Context, _ authz.Subject, @@ -102,16 +287,100 @@ func (api netAPI) attach( return netAttachOut{NIC: nic.Name, MAC: nic.MAC}, nil } +func (api netAPI) detach( + ctx context.Context, + _ authz.Subject, + in netDetachIn, +) (netDetachOut, error) { + return netDetachOut{}, api.networks.DetachNIC( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Instance}, + in.NIC, + ) +} + +func (api netAPI) peer( + ctx context.Context, + _ authz.Subject, + in netPeerIn, +) (netPeerOut, error) { + return netPeerOut{}, api.networks.PeerNetworks(ctx, in.Sandbox, in.Network, in.Peer) +} + +func (api netAPI) addACL( + ctx context.Context, + _ authz.Subject, + in netACLAddIn, +) (netACLAddOut, error) { + rule, err := api.networks.AddACLRule(ctx, in.Sandbox, in.Network, compute.ACLRule{ + Direction: in.Direction, + Action: in.Action, + Protocol: deref(in.Protocol, ""), + Src: deref(in.Src, ""), + Dst: deref(in.Dst, ""), + Port: deref(in.Port, ""), + }) + if err != nil { + return netACLAddOut{}, err + } + return netACLAddOut{Rule: rule.ID}, nil +} + +func (api netAPI) removeACL( + ctx context.Context, + _ authz.Subject, + in netACLRemoveIn, +) (netACLRemoveOut, error) { + return netACLRemoveOut{}, api.networks.RemoveACLRule(ctx, in.Sandbox, in.Network, in.Rule) +} + +func (api netAPI) forward( + ctx context.Context, + _ authz.Subject, + in netForwardIn, +) (netForwardOut, error) { + created, err := api.networks.CreateForward( + ctx, + in.Sandbox, + in.Network, + compute.Ref{Sandbox: in.Sandbox, Name: in.Instance}, + in.Port, + deref(in.ListenPort, 0), + deref(in.Protocol, ""), + ) + if err != nil { + return netForwardOut{}, err + } + return netForwardOut{Address: created.Address, Port: created.Port}, nil +} + +func (api netAPI) impair( + ctx context.Context, + _ authz.Subject, + in netImpairIn, +) (netImpairOut, error) { + return netImpairOut{}, api.networks.ImpairNIC( + ctx, + compute.Ref{Sandbox: in.Sandbox, Name: in.Instance}, + in.NIC, + compute.Impairment{ + LatencyMS: deref(in.LatencyMS, 0), + JitterMS: deref(in.JitterMS, 0), + LossPercent: deref(in.LossPercent, 0), + RateMbit: deref(in.RateMbit, 0), + Clear: deref(in.Clear, false), + }, + ) +} + func resolveNetworkKind(kind *string) (string, error) { - value := deref(kind, kindBridge) + value := deref(kind, kindOVN) if value == "" { - value = kindBridge + value = kindOVN } switch value { - case kindBridge: - return kindBridge, nil - case kindOVN: - return "", agentError("OVN networks are not available yet") + case kindBridge, kindOVN: + return value, nil default: return "", agentErrorf("unsupported network kind %q", value) } diff --git a/internal/mcpserver/sandbox.go b/internal/mcpserver/sandbox.go index 0e72344..a68b352 100644 --- a/internal/mcpserver/sandbox.go +++ b/internal/mcpserver/sandbox.go @@ -69,7 +69,6 @@ type sandboxAPI struct { instances instanceService } -//nolint:dupl // Explicit typed registrations keep each capability's contract visible. func registerSandbox(builder *codemode.Builder, deps Dependencies) { api := sandboxAPI{sandboxes: deps.Sandbox, instances: deps.Instance} codemode.Register(builder, codemode.Capability[sandboxCreateIn, sandboxCreateOut]{ diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 8091a7e..b2b7390 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -64,12 +64,39 @@ type instanceService interface { GetInstance(ctx context.Context, ref compute.Ref) (compute.Instance, error) DeleteInstance(ctx context.Context, ref compute.Ref) error Exec(ctx context.Context, req compute.ExecRequest) (compute.ExecResult, error) + StartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) + StopInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) + RestartInstance(ctx context.Context, ref compute.Ref, force bool) (compute.Instance, error) + WaitInstance(ctx context.Context, req compute.WaitRequest) (compute.WaitResult, error) + ReadFile(ctx context.Context, req compute.FileReadRequest) (compute.FileReadResult, error) + WriteFile(ctx context.Context, req compute.FileWriteRequest) (compute.FileWriteResult, error) + CreateSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error + RestoreSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error + DeleteSnapshot(ctx context.Context, ref compute.Ref, snapshot string) error + ListSnapshots(ctx context.Context, ref compute.Ref) ([]compute.Snapshot, error) + PublishInstance(ctx context.Context, ref compute.Ref, image string) (string, error) + ResolveImage(ctx context.Context, sandbox, name string) (compute.CatalogImage, error) } // networkService is the network surface consumed by net.* handlers. type networkService interface { CreateNetwork(ctx context.Context, sandbox string, network compute.Network) (compute.Network, error) AttachNIC(ctx context.Context, ref compute.Ref, network, nic, ip, mac string) (compute.NIC, error) + ListNetworks(ctx context.Context, sandbox string) ([]compute.Network, error) + GetNetwork(ctx context.Context, sandbox, name string) (compute.Network, error) + DeleteNetwork(ctx context.Context, sandbox, name string) error + DetachNIC(ctx context.Context, ref compute.Ref, nic string) error + PeerNetworks(ctx context.Context, sandbox, network, peer string) error + AddACLRule(ctx context.Context, sandbox, network string, rule compute.ACLRule) (compute.ACLRule, error) + RemoveACLRule(ctx context.Context, sandbox, network, rule string) error + CreateForward( + ctx context.Context, + sandbox, network string, + ref compute.Ref, + port, listenPort int64, + protocol string, + ) (compute.Forward, error) + ImpairNIC(ctx context.Context, ref compute.Ref, nic string, impairment compute.Impairment) error } // imageService is the catalog surface consumed by image.list and instance.create.