From ea2383c84af158df827d2d7be81773fbf898c083 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 9 Sep 2026 20:02:30 -0500 Subject: [PATCH 1/5] feat(agent): let the assistant create workloads Add discovery, render, validate, and token-bound plan/apply tools to compute-mcp, a workload-create skill, and a shared workloadspec package used by both the assistant and datumctl compute deploy. Prefix every published tool with compute_. Co-Authored-By: Claude Fable 5.1 --- cmd/compute-mcp/docs_test.go | 1 + cmd/compute-mcp/main.go | 136 ++- cmd/compute-mcp/main_test.go | 110 +- docs/agent/README.md | 41 +- docs/agent/llms-full.txt | 84 +- docs/agent/skills/instance-not-ready.md | 4 +- docs/agent/skills/placement-triage.md | 4 +- docs/agent/skills/quota-triage.md | 8 +- docs/agent/skills/stalled-transient.md | 10 +- docs/agent/skills/workload-create.md | 256 +++++ docs/agent/skills/workload-not-available.md | 4 +- internal/agent/discovery.go | 394 +++++++ internal/agent/discovery_test.go | 357 +++++++ internal/agent/tools.go | 63 +- internal/agent/tools_test.go | 107 +- internal/agent/write.go | 1019 +++++++++++++++++++ internal/agent/write_test.go | 945 +++++++++++++++++ internal/cmd/compute/deploy/deploy.go | 61 +- internal/cmd/compute/util/quota.go | 160 +-- internal/quotaview/quota.go | 211 ++++ internal/quotaview/quota_test.go | 122 +++ internal/validation/instance_validation.go | 18 +- internal/workloadspec/diff.go | 83 ++ internal/workloadspec/diff_test.go | 72 ++ internal/workloadspec/render.go | 916 +++++++++++++++++ internal/workloadspec/render_test.go | 715 +++++++++++++ 26 files changed, 5615 insertions(+), 286 deletions(-) create mode 100644 docs/agent/skills/workload-create.md create mode 100644 internal/agent/discovery.go create mode 100644 internal/agent/discovery_test.go create mode 100644 internal/agent/write.go create mode 100644 internal/agent/write_test.go create mode 100644 internal/quotaview/quota.go create mode 100644 internal/quotaview/quota_test.go create mode 100644 internal/workloadspec/diff.go create mode 100644 internal/workloadspec/diff_test.go create mode 100644 internal/workloadspec/render.go create mode 100644 internal/workloadspec/render_test.go diff --git a/cmd/compute-mcp/docs_test.go b/cmd/compute-mcp/docs_test.go index 3cb6ddf5..b2bd177a 100644 --- a/cmd/compute-mcp/docs_test.go +++ b/cmd/compute-mcp/docs_test.go @@ -113,6 +113,7 @@ func TestSkillsMatchDocumentedSet(t *testing.T) { "/runbooks/referenced-data-triage.md": true, "/runbooks/placement-triage.md": true, "/runbooks/stalled-transient.md": true, + "/runbooks/workload-create.md": true, } got := docs.paths() if len(got) != len(want) { diff --git a/cmd/compute-mcp/main.go b/cmd/compute-mcp/main.go index 74835e06..966c46e0 100644 --- a/cmd/compute-mcp/main.go +++ b/cmd/compute-mcp/main.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -// Command compute-mcp serves compute's read-only diagnostic tools over MCP, -// alongside the knowledge and skills an assistant reads before calling them: +// Command compute-mcp serves compute's tools over MCP, alongside the knowledge +// and skills an assistant reads before calling them: // // POST /mcp Streamable HTTP MCP, stateless // GET /llms-full.txt Knowledge: the compute resource model @@ -12,19 +12,26 @@ // Only /mcp takes a credential; see docs.go for why the documents do not. // // The server holds no credential of its own for the project control plane: it -// reads through a client built from the caller's own bearer token. So a tool -// call can never see more than the person who asked, the platform's RBAC stays -// the single enforcement point, and there is no impersonation privilege here to -// escalate with. +// reads and writes through a client built from the caller's own bearer token. +// So a tool call can never see or create more than the person who asked, the +// platform's RBAC stays the single enforcement point, and there is no +// impersonation privilege here to escalate with. // // The project a request reads is taken from a header, never from a tool // argument: arguments are chosen by the model, and a model that could name its // own project would be one prompt-injection away from another tenant's // workloads. The header is set by the already-authenticated caller. +// +// Two of the published tools can change something — compute_workload_plan and +// compute_workload_apply — and apply only ever creates the manifest a plan +// token was minted for. Those tokens are signed with PLAN_TOKEN_KEY; see +// resolvePlanTokenKey for what a deployment owes it. package main import ( "context" + "crypto/rand" + "encoding/base64" "errors" "flag" "fmt" @@ -48,6 +55,10 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/agent" + "go.datum.net/compute/internal/locations" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" ) const ( @@ -70,6 +81,16 @@ const ( misconfiguredClientNote = "The person who asked did nothing wrong and re-authenticating will not " + "help: this is a configuration problem for whoever operates that client" + // planTokenKeyEnv names the environment variable holding the key plan + // tokens are signed with. Base64 or raw, at least minPlanTokenKeyLen bytes + // either way. + planTokenKeyEnv = "PLAN_TOKEN_KEY" + + // minPlanTokenKeyLen is the shortest key accepted. HMAC-SHA256's block + // structure gets nothing from a key longer than its 32-byte output, and a + // shorter one is a weaker signature than the scheme is meant to have. + minPlanTokenKeyLen = 32 + // resourceNamespace is where compute's objects live inside a project's // control plane: the project routes to the control plane, and within it // everything is in "default". Mirrors util.ResourceNamespace, not imported @@ -80,18 +101,41 @@ const ( var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") + + // locationSource selects which API group the discovery tools read a + // project's locations from. Deployment configuration, resolved once at + // startup and read by every request, mirroring how the manager takes it + // from its own config. The zero value reads the group every deployment + // serves today. + locationSource locations.Source + + // planTokenKey signs the plan tokens compute_workload_plan mints and + // compute_workload_apply checks. Deployment configuration, resolved once at + // startup: every request reads it, and a key that differs between replicas + // means a plan minted by one is refused by another. + planTokenKey []byte ) +// The scheme carries every group a tool reads: compute's own objects for the +// diagnosis walk, plus networks, locations and quota for discovery. A group +// missing here fails at the first read with a scheme error, which says nothing +// about which tool wanted it. func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(computev1alpha.AddToScheme(scheme)) + utilruntime.Must(networkingv1alpha.AddToScheme(scheme)) + utilruntime.Must(locationsv1alpha1.AddToScheme(scheme)) + utilruntime.Must(quotav1alpha1.AddToScheme(scheme)) } func main() { - var addr string + var addr, locationSourceFlag string flag.StringVar(&addr, "addr", envOr("COMPUTE_MCP_ADDR", ":8080"), "address to serve MCP on") + flag.StringVar(&locationSourceFlag, "location-source", envOr("LOCATION_SOURCE", ""), + fmt.Sprintf("API group to read a project's locations from: %q or %q (default %q)", + locations.SourceNetworkServices, locations.SourceLocations, locations.SourceNetworkServices)) opts := zap.Options{Development: true} opts.BindFlags(flag.CommandLine) @@ -99,6 +143,23 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + // Resolved at startup rather than per request: a misspelled source is a + // deployment mistake, and it should stop the process rather than turn + // every compute_locations_list call into an error a customer sees. + resolvedSource, err := locations.Source(locationSourceFlag).Resolve() + if err != nil { + setupLog.Error(err, "refusing to start") + os.Exit(1) + } + locationSource = resolvedSource + + key, err := resolvePlanTokenKey(os.Getenv(planTokenKeyEnv)) + if err != nil { + setupLog.Error(err, "refusing to start") + os.Exit(1) + } + planTokenKey = key + // GetConfig resolves the --kubeconfig flag that controller-runtime // registers, then KUBECONFIG, then in-cluster config, then ~/.kube/config. // Only the endpoint and CA are used; see clientForToken. @@ -119,6 +180,44 @@ func main() { } } +// resolvePlanTokenKey returns the key plan tokens are signed with, given the +// environment's value for it. +// +// A missing key is not fatal: the server generates one and runs. A plan token +// is only ever checked by the process that minted it, and one process holding +// a key nobody else knows is exactly what the scheme needs. What it costs is +// that a plan minted by one replica is refused by another, and a restart +// refuses every token outstanding — so the warning says that plainly rather +// than letting an operator discover it as intermittent refusals under a load +// balancer. +func resolvePlanTokenKey(configured string) ([]byte, error) { + if configured = strings.TrimSpace(configured); configured != "" { + // Base64 first, since a key generated with `openssl rand -base64 32` + // is 44 printable characters that would otherwise pass the raw check + // while carrying only 32 bytes of the entropy it was meant to have. + if decoded, err := base64.StdEncoding.DecodeString(configured); err == nil && + len(decoded) >= minPlanTokenKeyLen { + return decoded, nil + } + if len(configured) >= minPlanTokenKeyLen { + return []byte(configured), nil + } + return nil, fmt.Errorf( + "%s is too short: it must be at least %d bytes, either raw or base64-encoded. Generate one "+ + "with: openssl rand -base64 32", planTokenKeyEnv, minPlanTokenKeyLen) + } + + key := make([]byte, minPlanTokenKeyLen) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generating a plan token key: %w", err) + } + setupLog.Info("no plan token key configured, generated one for this process", + "warning", "plan tokens will not validate across replicas or survive a restart; set "+ + planTokenKeyEnv+" to the same value on every replica", + "env", planTokenKeyEnv) + return key, nil +} + // checkControlPlaneEndpoint refuses to start when the configuration resolved to // the API server of the cluster this process runs in. // @@ -173,8 +272,9 @@ func run(addr string, baseConfig *rest.Config) error { agent.RegisterTools(s, depsFromRequest(r, baseConfig)) return s }, - // Stateless: no session state is needed for read-only tools, and it - // keeps the server robust against client crashes. + // Stateless: no tool needs session state — what a plan settled travels + // in the token it returns, not in memory here — and it keeps the + // server robust against client crashes. &mcp.StreamableHTTPOptions{Stateless: true}, ) @@ -231,7 +331,23 @@ func depsFromRequest(r *http.Request, baseConfig *rest.Config) agent.DepsFor { if err != nil { return agent.ToolDeps{}, err } - return agent.ToolDeps{Reader: agent.NewClientReader(c), Namespace: resourceNamespace}, nil + // One client serves all three: the reads a tool makes are the reads the + // person who asked could make themselves, and a workload it creates is + // one they could have created themselves, whichever tool does it. The + // Discoverer is given no platform client — this server holds no + // credential of its own, so quota display units fall back rather than + // being fetched with an identity the caller does not have. + return agent.ToolDeps{ + Reader: agent.NewClientReader(c), + Discoverer: agent.NewClientDiscoverer(c, locationSource), + Writer: agent.NewClientWriter(c), + Namespace: resourceNamespace, + // The project a plan is bound to is the header's, the same one the + // client above addresses, so a token minted for one project can + // never be spent in another. + Project: project, + PlanTokenKey: planTokenKey, + }, nil } } diff --git a/cmd/compute-mcp/main_test.go b/cmd/compute-mcp/main_test.go index 34052149..811ceee6 100644 --- a/cmd/compute-mcp/main_test.go +++ b/cmd/compute-mcp/main_test.go @@ -4,6 +4,7 @@ package main import ( "context" + "encoding/base64" "net/http" "net/http/httptest" "regexp" @@ -19,13 +20,19 @@ import ( "go.datum.net/compute/internal/agent" ) -const testToken = "caller-token" +const ( + testToken = "caller-token" + // testHost stands in for the control plane a deployment is pointed at, and + // testProjectName for the project a request names in its header. + testHost = "https://api.datum.example" + testProjectName = "my-project" +) func baseConfig() *rest.Config { // Shaped like an in-cluster config: endpoint and CA, plus a server identity // that must not survive into a caller's read. return &rest.Config{ - Host: "https://api.datum.example", + Host: testHost, BearerToken: "server-service-account-token", BearerTokenFile: "/var/run/secrets/kubernetes.io/serviceaccount/token", TLSClientConfig: rest.TLSClientConfig{CAFile: "/var/run/secrets/ca.crt"}, @@ -37,7 +44,7 @@ func baseConfig() *rest.Config { // internal/referenceddata and the datumctl plugin perform. The namespace within // that control plane is "default", never the project name. func TestClientConfigAddressesProjectControlPlane(t *testing.T) { - cfg, err := clientConfig(baseConfig(), testToken, "my-project") + cfg, err := clientConfig(baseConfig(), testToken, testProjectName) if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -55,7 +62,7 @@ func TestClientConfigAddressesProjectControlPlane(t *testing.T) { // whole design rests on: the server must never read as itself. func TestClientConfigCarriesOnlyTheCallerCredential(t *testing.T) { base := baseConfig() - cfg, err := clientConfig(base, testToken, "my-project") + cfg, err := clientConfig(base, testToken, testProjectName) if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -73,7 +80,7 @@ func TestClientConfigCarriesOnlyTheCallerCredential(t *testing.T) { t.Errorf("CAFile = %q, want the base config's %q", cfg.CAFile, base.CAFile) } // The base config must be left alone; it is shared by every request. - if base.BearerToken != "server-service-account-token" || base.Host != "https://api.datum.example" { + if base.BearerToken != "server-service-account-token" || base.Host != testHost { t.Error("clientConfig mutated the shared base config") } } @@ -105,8 +112,8 @@ func TestDepsFromRequestRequiresCredentials(t *testing.T) { project string want string }{ - {name: "no token", project: "my-project", want: "no credentials"}, - {name: "wrong scheme", auth: "Basic abc", project: "my-project", want: "no credentials"}, + {name: "no token", project: testProjectName, want: "no credentials"}, + {name: "wrong scheme", auth: "Basic abc", project: testProjectName, want: "no credentials"}, {name: "no project", auth: "Bearer " + testToken, want: "no project"}, {name: "invalid project", auth: "Bearer " + testToken, project: "a/b", want: "invalid project"}, } @@ -262,7 +269,7 @@ func TestReadsGoThroughTheProjectControlPlane(t *testing.T) { })) defer api.Close() - cfg, err := clientConfig(&rest.Config{Host: api.URL}, testToken, "my-project") + cfg, err := clientConfig(&rest.Config{Host: api.URL}, testToken, testProjectName) if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -399,3 +406,90 @@ func TestGuardNamesNoEnvironment(t *testing.T) { t.Errorf("localClusterEndpoint() = %q, want %q", got, want) } } + +// TestResolvePlanTokenKey covers the key plan tokens are signed with. A key +// too short to be one is a deployment mistake worth refusing to start over; a +// missing one is not, because a single process signing with a key only it +// knows is a working configuration — it just cannot survive a second replica. +func TestResolvePlanTokenKey(t *testing.T) { + raw := strings.Repeat("k", minPlanTokenKeyLen) + encoded := base64.StdEncoding.EncodeToString([]byte(raw)) + + t.Run("base64", func(t *testing.T) { + got, err := resolvePlanTokenKey(encoded) + if err != nil { + t.Fatalf("resolvePlanTokenKey: %v", err) + } + // Decoded, not taken as the 44 printable characters it is written as: + // the operator generated 32 bytes of entropy and that is what signs. + if string(got) != raw { + t.Errorf("key = %q, want the decoded %q", got, raw) + } + }) + + t.Run("raw", func(t *testing.T) { + got, err := resolvePlanTokenKey(raw) + if err != nil { + t.Fatalf("resolvePlanTokenKey: %v", err) + } + if string(got) != raw { + t.Errorf("key = %q, want %q", got, raw) + } + }) + + t.Run("too short", func(t *testing.T) { + if _, err := resolvePlanTokenKey("hunter2"); err == nil { + t.Error("accepted a key too short to sign with") + } else if !strings.Contains(err.Error(), planTokenKeyEnv) { + t.Errorf("error = %q, want it to name the setting to fix", err) + } + }) + + t.Run("unset", func(t *testing.T) { + first, err := resolvePlanTokenKey("") + if err != nil { + t.Fatalf("resolvePlanTokenKey: %v", err) + } + if len(first) < minPlanTokenKeyLen { + t.Errorf("generated a %d-byte key, want at least %d", len(first), minPlanTokenKeyLen) + } + second, err := resolvePlanTokenKey("") + if err != nil { + t.Fatalf("resolvePlanTokenKey: %v", err) + } + if string(first) == string(second) { + t.Error("generated the same key twice; it must be random per process") + } + }) +} + +// TestDepsBindThePlanToTheHeadersProject: a plan token is only good in the +// project it was minted for, and that project is the header's — the same one +// the client addresses. If these two could ever differ, a token minted in one +// tenant would spend in another. +func TestDepsBindThePlanToTheHeadersProject(t *testing.T) { + planTokenKey = []byte(strings.Repeat("k", minPlanTokenKeyLen)) + t.Cleanup(func() { planTokenKey = nil }) + + r := httptest.NewRequest(http.MethodPost, "/mcp", nil) + r.Header.Set("Authorization", "Bearer "+testToken) + r.Header.Set(projectHeader, testProjectName) + + // No CA file: this builds a real client, and the point here is what the + // deps carry, not what they can reach. + deps, err := depsFromRequest(r, &rest.Config{Host: testHost})(context.Background()) + if err != nil { + t.Fatalf("depsFromRequest: %v", err) + } + if deps.Project != testProjectName { + t.Errorf("Project = %q, want the header's %q", deps.Project, testProjectName) + } + if string(deps.PlanTokenKey) != string(planTokenKey) { + t.Error("PlanTokenKey did not reach the tools; no plan could be minted") + } + // The writer runs as the caller, like every other tool: one client, built + // from the bearer token on this request. + if deps.Writer == nil { + t.Error("no Writer on the deps; the write tools would report themselves unconfigured") + } +} diff --git a/docs/agent/README.md b/docs/agent/README.md index 3887af6c..967c1e28 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -21,9 +21,19 @@ assistant owns the document schema that carries it. ## Status Landed here: the reason catalog, the diagnosis walk, the knowledge and skills -above, and `cmd/compute-mcp` — the MCP server that publishes the five read-only -tools (`workloads_list`, `workloads_get`, `instances_list`, `workload_diagnose`, -`reason_explain`) over Streamable HTTP. +above, and `cmd/compute-mcp` — the MCP server that publishes compute's tools +over Streamable HTTP: + +| Tools | Names | +|---|---| +| Diagnosis, read-only | `compute_workloads_list`, `compute_workloads_get`, `compute_instances_list`, `compute_workload_diagnose`, `compute_reason_explain` | +| Discovery, read-only | `compute_locations_list`, `compute_networks_list`, `compute_quota_get`, `compute_instance_types_list` — what a project may place, attach to, afford, and ask for | +| Planning, writes nothing | `compute_workload_render` (inputs to a manifest, pure), `compute_workload_validate` (the server's verdict on that manifest without creating it) | +| Mutating | `compute_workload_plan`, `compute_workload_apply` | + +Every tool is prefixed `compute_`, so the assistant can compose tools from +several services in one conversation without names colliding; the capability +document must register the prefixed names. ## HTTP surface @@ -59,8 +69,28 @@ Three properties of the server are worth knowing before you deploy it: prompt injection away from another tenant's workloads. The caller sets `X-Datum-Project` after authenticating the user. -Compute publishes no mutating tool. Allow-list enforcement is the gateway's job, -but a tool that does not exist cannot be called through any path. +Compute publishes exactly two mutating tools, `compute_workload_plan` and +`compute_workload_apply`, and they are deliberately one operation split in half. +`compute_workload_plan` validates a manifest, resolves whether it is a create or an +update, reports whether the network the interface names would have to be +created too, and returns the manifest, the diff, and a plan token — a hash of +that manifest, the project, and the version of the workload it saw. +`compute_workload_apply` accepts that manifest and that token and nothing else, and +re-derives the hash before it writes: a manifest edited after the plan, a token +from another project, or a workload someone else changed in the meantime is +refused. So the only thing apply can produce is the manifest the model already +put in front of the person who asked. A model that reads a poisoned status message cannot +smuggle a different workload past a confirmation of this one, and a manifest +nobody was shown has no token and cannot be applied at all. + +The rest of the surface is unchanged by this. Every write runs as the caller, +from the bearer token on the request, so the server holds no credential of its +own and can create nothing the person could not create themselves; the project +still comes from the header. Whether `compute_workload_apply` is offered to a given +project at all is the gateway's decision, from its allow-list — the split above +constrains what a published tool can do, not which projects get it. Adding a +third mutating tool is a new decision and gets its own review: the argument +above is about these two and does not generalise. ## Why the knowledge leads with "how to read conditions" @@ -90,6 +120,7 @@ orientation and classification; the procedures live here and nowhere else. | `referenced-data-triage` | Missing, unauthorized, or oversized ConfigMaps/Secrets | | `placement-triage` | `NoMatchingLocation`, `AmbiguousServingLocation`, `CityCodeMismatch` | | `stalled-transient` | A transient reason that has outlived its expected window | +| `workload-create` | Deploying something new: prerequisites, the choices that are final at create, and render → validate → show → plan → confirm → apply | A skill never grants privileges. It can only direct the model toward tools that are independently on the enforced allow-list, which is why these go through the diff --git a/docs/agent/llms-full.txt b/docs/agent/llms-full.txt index 4f34e209..64747467 100644 --- a/docs/agent/llms-full.txt +++ b/docs/agent/llms-full.txt @@ -78,8 +78,8 @@ Pointer reasons you must read *through*, never report as the answer: PendingQuota -> look at Instance.QuotaGranted SchedulingGatesPresent -> something else is holding it; find what -The `workload_diagnose` tool does this walk for you and returns the leaf cause. -Prefer it over assembling the tree by hand. +The `compute_workload_diagnose` tool does this walk for you and returns the +leaf cause. Prefer it over assembling the tree by hand. ## Kinds of cause @@ -121,8 +121,8 @@ reason, and only the elapsed time tells them apart. Every tool result that names a cause carries how long the state has held: `rootCauseFor` / `rootCauseSince` in the fleet view, `inStateFor` / `lastTransitionTime` on a diagnosis. Every transient reason carries the window -it should clear inside (`expectedWithin` from `reason_explain`). Past that -window the actionability comes back as `stalled`. +it should clear inside (`expectedWithin` from `compute_reason_explain`). Past +that window the actionability comes back as `stalled`. `stalled` is not the same as a platform fault. Nothing reported a cause; the classification has merely been contradicted by the clock. Report the duration @@ -182,21 +182,77 @@ Instance runtime infrastructure that runs the container, not by compute. Load `instance-not-ready`. +## Creating a workload + +A Workload is three things: a name, one instance template, and one or more +placements. The template says what runs — either containers in a sandbox, or a +virtual machine booting a full operating system — at one instance type, on one +network interface, with whatever volumes the containers or the machine attach. +Each placement names city codes and a replica count, so where it runs and how +many of it there are live in the placement, not in the template. + +Some of that is settled for good at create. The workload's name, the runtime +kind, and the network interface — its name, the address families it carries, +any extra addresses such as a public IPv4 one, and what becomes of those +addresses afterwards — cannot be changed later. Getting one of them wrong means +a new workload rather than an edit, which is why the create procedure asks +about them explicitly rather than defaulting them quietly. + +Two things are outside your reach entirely. You cannot build or push a +container image: it must already exist in a registry, fully qualified, and be +built for the runtime Datum runs it on — `datumctl compute build` is the step +the customer runs. And you cannot enable Compute for a project or grant it +quota; both are Datum's to give. + +Writing is a sequence, not a call. `compute_workload_render` turns inputs into +a manifest and touches nothing. `compute_workload_validate` has the server +check that manifest without creating anything, and returns either the exact +rejection or the diff against an existing workload. `compute_workload_plan` +validates, settles create versus update, says whether the network has to be +created too, and returns a canonical manifest with a plan token that is a hash +of it. `compute_workload_apply` takes that manifest and that token and nothing +else, and re-derives the hash, so the only thing that can be created is the +manifest you showed the customer and they agreed to. + +Load `workload-create` before any of this. The prerequisites, the inputs, the +rejections that are worth pre-empting, and what to do at each failure are all +there, and this section deliberately does not restate them. + ## What the tools give you - workloads_list fleet view, worst first, with root-cause reason, - actionability, and how long that cause has held - workloads_get raw condition tree for one workload - instances_list per-instance conditions, to see failure distribution - workload_diagnose the walk, the leaf cause with its age, and next steps - reason_explain any reason, explained, classified, and — when transient — - the window it should clear inside + compute_workloads_list fleet view, worst first, with root-cause reason, + actionability, and how long that cause has held + compute_workloads_get raw condition tree for one workload + compute_instances_list per-instance conditions, to see failure + distribution + compute_workload_diagnose the walk, the leaf cause with its age, and next + steps + compute_reason_explain any reason, explained, classified, and — when + transient — the window it should clear inside + compute_locations_list the city codes this project may place a workload + in + compute_networks_list the networks an interface may attach to + compute_quota_get how much compute the project is allowed, and + what is left + compute_instance_types_list the instance types a workload may ask for + + compute_workload_render inputs to a full manifest; writes nothing, reads + nothing + compute_workload_validate the server's own verdict on a manifest, without + creating it: the exact rejection, or the diff + against what exists + compute_workload_plan validate, resolve create versus update, check + the network, and mint a plan token over the + manifest + compute_workload_apply create or update — the planned manifest and its + token, and nothing that was not planned and + shown Skills (load on demand) carry the procedures: workload-not-available, quota-triage, instance-not-ready, referenced-data-triage, placement-triage, -stalled-transient. This document is orientation; it deliberately does not -restate what a skill covers, so reach for the skill rather than answering a -triage question from what is in the prompt. +stalled-transient, workload-create. This document is orientation; it +deliberately does not restate what a skill covers, so reach for the skill +rather than answering a triage or creation question from what is in the prompt. ## Telling compute what it could not do diff --git a/docs/agent/skills/instance-not-ready.md b/docs/agent/skills/instance-not-ready.md index c57d8e09..ded82177 100644 --- a/docs/agent/skills/instance-not-ready.md +++ b/docs/agent/skills/instance-not-ready.md @@ -46,7 +46,7 @@ it — the logs are there either way. unpacked. Say to wait. Only if it persists well beyond a few minutes should you treat it as Datum's problem. -6. **Check whether every instance fails the same way.** `instances_list` for the +6. **Check whether every instance fails the same way.** `compute_instances_list` for the workload: all of them failing the same way points at the workload or the image; one failing among healthy siblings points at one machine or one location, which is Datum's. @@ -81,7 +81,7 @@ When that happens: "capability": "container log retrieval for a crashing instance", "kind": "UnactionableGuidance", "evidence": { - "tool": "instances_list", + "tool": "compute_instances_list", "observed": "InstanceCrashing; remediation points at the logs", "contradictedBy": "log retrieval fails outright on this instance: the port answers plain HTTP where encrypted diff --git a/docs/agent/skills/placement-triage.md b/docs/agent/skills/placement-triage.md index 05167133..85ffa8c9 100644 --- a/docs/agent/skills/placement-triage.md +++ b/docs/agent/skills/placement-triage.md @@ -22,7 +22,7 @@ end. - `CityCodeMismatch` — the workload asked for one city and was sent to another. It was routed to the wrong place. -2. **Confirm the scope.** `workloads_list` shows whether other workloads in the +2. **Confirm the scope.** `compute_workloads_list` shows whether other workloads in the same placement are also failing. Several failing in one place is a location-wide problem and is worth reporting as such; a single one may be a leftover deployment. @@ -33,7 +33,7 @@ end. 4. **Escalate with specifics.** Datum needs: the WorkloadDeployment name, its `cityCode`, its (empty or wrong) `location`, and the status message. Pull - these from `workloads_get`. + these from `compute_workloads_get`. ## Reporting diff --git a/docs/agent/skills/quota-triage.md b/docs/agent/skills/quota-triage.md index ffdc9022..6cb6af33 100644 --- a/docs/agent/skills/quota-triage.md +++ b/docs/agent/skills/quota-triage.md @@ -11,8 +11,8 @@ service that evaluates it, and not the request compute files against it. ## Procedure 1. **Get the real reason.** `QuotaNotGranted` on the Workload or - WorkloadDeployment is a pointer. Call `workload_diagnose`, or read the - Instance's `QuotaGranted` condition via `instances_list`. Never report + WorkloadDeployment is a pointer. Call `compute_workload_diagnose`, or read the + Instance's `QuotaGranted` condition via `compute_instances_list`. Never report `QuotaNotGranted` as the cause. 2. **Separate the four cases.** They look alike and lead to opposite advice: @@ -40,7 +40,7 @@ service that evaluates it, and not the request compute files against it. there, the checking service itself is stuck — treat it as `QuotaBackendUnavailable` and hand it to Datum. -5. **Check the split.** `instances_list` shows how many instances were cleared +5. **Check the split.** `compute_instances_list` shows how many instances were cleared and how many were not. Partial is the common case: the workload is serving at reduced capacity, which is worth saying explicitly. @@ -64,7 +64,7 @@ against the tool you read it from, quoting the message you were given: "capability": "how much of the project's compute quota is left", "kind": "InsufficientDetail", "evidence": { - "tool": "instances_list", + "tool": "compute_instances_list", "observed": "QuotaGranted=False, QuotaExceeded, \"quota exceeded\"", "contradictedBy": "no requested or remaining amount in the response" } diff --git a/docs/agent/skills/stalled-transient.md b/docs/agent/skills/stalled-transient.md index 464fe94b..0285ab11 100644 --- a/docs/agent/skills/stalled-transient.md +++ b/docs/agent/skills/stalled-transient.md @@ -37,7 +37,7 @@ and neither is licence to rule the customer's own workload out. ## Procedure -1. **Quantify it, and use the larger number.** `workload_diagnose` gives two +1. **Quantify it, and use the larger number.** `compute_workload_diagnose` gives two ages on the root cause and they answer different questions: - `inStateFor` — how long the *status* has said this. @@ -54,7 +54,7 @@ and neither is licence to rule the customer's own workload out. an object broken for nine days means something is rewriting the status without ever finishing. Say so. - Then call `reason_explain` for `expectedWithin` — how long this step should + Then call `compute_reason_explain` for `expectedWithin` — how long this step should take. "Nine days, against thirty minutes" is the whole finding. Two things the tools will not give you, on purpose. An age is omitted rather @@ -76,13 +76,13 @@ and neither is licence to rule the customer's own workload out. something is working on this and never saying how it turned out. It does **not** name a culprit — see step 5. -3. **Check whether it is one object or all of them.** `instances_list` for the +3. **Check whether it is one object or all of them.** `compute_instances_list` for the workload. Every instance stuck the same way points at the place they all run; one stuck among healthy siblings points at that object. Say which — it decides who Datum wakes up. 4. **Look underneath before escalating.** Read `contributingConditions` from - `workload_diagnose`. A stalled pointer reason (`InstancesProvisioning`, + `compute_workload_diagnose`. A stalled pointer reason (`InstancesProvisioning`, `PendingQuota`, `SchedulingGatesPresent`) often has a real cause below it that arrived after the stall began. If one is there, that is the answer — follow its skill instead. @@ -166,7 +166,7 @@ copied out of the tool result: "capability": "duration-aware classification of transient reasons", "kind": "MisleadingOutput", "evidence": { - "tool": "workload_diagnose", + "tool": "compute_workload_diagnose", "observed": "actionability: transient, remediation \"Wait.\"", "contradictedBy": "failingFor: 9d, inStateFor: 9h30m, expectedWithin: 30m" } diff --git a/docs/agent/skills/workload-create.md b/docs/agent/skills/workload-create.md new file mode 100644 index 00000000..a8fac61f --- /dev/null +++ b/docs/agent/skills/workload-create.md @@ -0,0 +1,256 @@ +# Skill: create a workload + +Use when someone asks to deploy, run, or create something on Datum — a new +Workload, or a change to one that does not exist yet — and whenever you are +about to call `compute_workload_render`, `compute_workload_validate`, `compute_workload_plan` or +`compute_workload_apply`. + +## The one thing to know + +**You never write a workload directly. You render it, validate it, show it, and +apply only what the user agreed to.** `compute_workload_apply` takes the manifest +`compute_workload_plan` returned and that plan's token, and nothing else. The token is +a hash of that manifest — the same one you put in front of the user. Change the +manifest by one character and the token stops matching, so what gets created is +exactly what was shown and agreed to, or nothing at all. + +Two things you cannot do, however the request is phrased: + +- **You cannot build or push an image.** The image has to exist in a registry + before any of this starts. +- **You cannot turn Compute on for a project, and you cannot grant it quota.** + Both are Datum's to grant. Say so and name the step the user takes. + +The project is fixed by the request that reached you. There is no tool argument +for it, so you cannot create a workload in a project other than the one the +conversation is already scoped to. If the user names a different project, say +that this conversation only reaches the current one. + +## 1. Check the prerequisites before gathering anything + +Four things have to be true. Each has a read-only tool, and each failure has a +different answer: + +| Check | Tool | If it fails | +|---|---|---| +| Compute is enabled for the project | `compute_locations_list` | Nothing can be placed. Datum's to enable — the user runs `datumctl compute access request`, and approval is a manual step on Datum's side. | +| Somewhere to run it | `compute_locations_list` | The city codes it returns are the only ones a placement may name. An empty list means nothing is available to this project yet; that is Datum's, not something the user can add. | +| A network | `compute_networks_list` | `default` by convention. If it is missing, `compute_workload_plan` says so and `compute_workload_apply` creates it alongside the workload — say so when you show the plan, because it is a second object being created. | +| Quota | `compute_quota_get` | Quota is granted by Datum and cannot be self-served. A project with none can still create a workload; its instances then sit at `QuotaGranted=False` with `QuotaNoBudget` and never start. | + +Do the quota arithmetic before you apply, not after. Replicas times the instance +type against what `compute_quota_get` says is left tells you whether this will start. +If it will not, say so *before* asking for confirmation — a workload that +creates cleanly and then sits at `QuotaExceeded` looks like a success and is +not. Load `quota-triage` for the difference between being over quota and having +none. + +## 2. Container or virtual machine + +There are two runtimes and a workload picks exactly one. This is not adjustable +later — switching means a different workload. + +**Container (a sandbox).** The common case. One or more containers, each with a +fully qualified image. Choose this unless the user needs a whole operating +system. + +**Virtual machine.** A full OS booted from a disk image. Choose this only if the +user asks for one, or needs to log into the machine. It carries the extra +requirements in the trap list below. + +### The image is a prerequisite, not an input you can produce + +The image must: + +- **already exist in a registry** the platform can reach. You cannot build one. +- **be fully qualified** — `docker.io/netdata/netdata:latest`, not `netdata`. + A bare name is the most common cause of `ImageUnavailable` afterwards. +- **be built for the runtime Datum runs it on.** An image that runs on a laptop + can still fail here. The user builds it with `datumctl compute build`, which + checks for the known incompatibilities and can fix them. + +If the user has no image yet, stop and say that: the build is theirs to run, and +everything below waits on it. Do not render a manifest around an image name +nobody has pushed. + +## 3. Gather the inputs + +Ask for what is missing rather than inventing it. `compute_workload_render` takes: + +- **name** — a DNS label (lowercase letters, digits and `-`). It is the object's + name and cannot be changed later. +- **image** — fully qualified, per above. +- **placements** — one or more city codes from `compute_locations_list`, and the + replica count for each. Group cities that scale together into one placement. +- **replicas** — `minReplicas` must be at least 1. There is no scaling from + zero, and the ceiling is 1000. +- **port** — optional, and named. A port is how anything reaches the workload; + ask whether it serves traffic rather than guessing. +- **environment variables** — literal values, or drawn from a ConfigMap or a + Secret. +- **ConfigMap and Secret references** — mounted as volumes, or read as + environment variables. They must already exist in the project, and the user + must be able to read them, or create is rejected. +- **a public IPv4 address** — only if the workload has to be reachable from the + internet on IPv4. Ask; do not add one by default, and do not leave it out of a + workload that clearly needs one, because it cannot be added afterwards. + +## 4. The traps + +These are the ones that cost a round trip. Check the rendered manifest against +this list before you validate. + +1. **One instance type.** `datumcloud/d1-standard-2` is the only one accepted + today. `compute_instance_types_list` is the check; anything else is rejected outright. + +2. **Per-container CPU and memory are not accepted.** A `resources` block on a + container is rejected, and so are adjustments to the instance type's own + requests. The size of an instance comes from the instance type and nothing + else. If the user wants a different size, that is a request to Datum. + +3. **ConfigMap volumes use `name`; Secret volumes use `secretName`.** The two + spellings sit next to each other in the same list and are not + interchangeable. Getting it wrong reads as a missing required field. + +4. **Every volume must be attached.** A volume that is declared and never + attached to a container or to the virtual machine is rejected — the create + fails on the volume, not on the attachment. + +5. **The network interface is settled at create.** Its name, the address + families it carries, any extra addresses (a public IPv4 among them), and what + becomes of those addresses when the instance goes away are all immutable. An + instance gets one interface. If any of this turns out to be wrong later, the + fix is a new workload, so ask now: + - IPv6 only is the default. If the workload has to answer on IPv4, that has + to be asked for at create. + - A published address — one in DNS, or allowed through someone's firewall — + wants a reclaim policy that keeps it, and that choice is also final. + +6. **Virtual machines need two extra things.** SSH keys on the template's + metadata, under the annotation `compute.datumapis.com/ssh-keys`, one + `username:key` line per key — a create without them is rejected. And the + first volume attached must be a bootable disk populated by an Ubuntu image. + First, not merely present. + +7. **ConfigMaps and Secrets have size limits**: 256 KiB per object, and 1 MiB + for everything one workload references put together. Over either and the + workload reports `SourceTooLarge` rather than failing at create. + +8. **Editing a ConfigMap does not restart anything.** The new contents reach the + machines, but a process that read the file at startup goes on running with + what it read. Say this whenever a config change is the point of the + conversation — the user has to restart the workload themselves, and there is + no tool here that does it. + +## 5. The sequence + +Follow it in order. Each step exists because of a failure the next one cannot +catch. + +1. **`compute_workload_render`** — inputs in, a full manifest out. It writes nothing and + reaches nothing. Read what came back rather than assuming it matches what you + asked for. + +2. **`compute_workload_validate`** — the server checks the manifest without creating + anything. This is where the traps above surface as real rejections, and it is + also where you learn whether a workload of this name already exists: for an + existing one, validate returns the diff instead. + +3. **Show the user the manifest and, if there is one, the diff.** Whole, not + summarised. Then say in plain words what will be created, where, how many, + and what it will cost against their quota. If the plan says the network has + to be created too, say that: it is a second object. + +4. **`compute_workload_plan`** — validates again, settles whether this is a create or an + update, says whether the network has to be created too, and mints the token + over the manifest it returns. Show that manifest, not your own draft. + +5. **Get an explicit yes.** A question about the plan is not a yes. "Looks + right" is. If the user asks for any change, go back to step 1 — a token + minted for the old manifest is not valid for the new one, and must not be + applied because it was close. + +6. **`compute_workload_apply`** with the plan's manifest and its token. + +7. **`compute_workload_diagnose`** for the rollout. Creation succeeding means the + request was accepted, not that anything is running. Tell the user what to + expect: instances appear, then start, and the first pull of a large image + takes a while. If it is not serving, that is `workload-not-available`'s + procedure, not this one. + +## What to do when a step fails + +- **Render is missing something** — an input you did not gather. Ask for it by + name. Do not fill it in with a plausible default; a guessed port or city is a + workload that runs in the wrong place. + +- **Validate rejects it** — this is the server's own answer, in its own words, + and it names the exact field. Quote the field path verbatim and translate the + rule beside it: `spec.template.spec.volumes[1].name: volume must be attached + at least 1 time` is "the `config` volume is declared but never mounted". Fix + it, render again, validate again. Never apply something that failed validate. + +- **Validate returns a diff you did not expect** — a workload of that name is + already there. Stop and say so. Ask whether the user meant to change the + existing one, and check the diff for anything immutable from trap 5 before + going on, because those rejections arrive at apply and not before. + +- **Plan fails** — the manifest was rejected on the second look, or the + workload moved underneath you between validate and plan. A failed plan mints + no token, so there is nothing to apply. Re-read, re-render, and show the user + again. Do not retry a plan you do not understand the failure of. + +- **Apply refuses the token** — something changed after the plan. That refusal + is the mechanism working. Re-plan, show the new manifest, and ask again. + Never work around it. + +- **Apply succeeds and nothing starts** — hand it to `compute_workload_diagnose` and + follow the skill it names. Quota and image problems both look like this and + lead to opposite advice. + +## If the user has a shell + +You are usually working without one. When the user is at a terminal, the same +workload is one command, and these are theirs to run, not yours to assume: + + datumctl compute access request + datumctl compute build --push --output ghcr.io/acme/api:1.4.2 . + datumctl compute deploy api --image=ghcr.io/acme/api:1.4.2 --city=DFW --min=1 --port=8080 + +Offer them when a step above has no tool behind it — the access request has +none at all — and otherwise stay with the tools, which is the path that shows +the user the manifest before anything is created. + +## Reporting + +Say what will exist, where, and how many, in the user's own words first: "one +container running `ghcr.io/acme/api:1.4.2` in Dallas, two replicas, answering on +port 8080". Then the identifiers — the workload name, the image with its tag, +the city codes — because those are what they need to check it themselves or to +escalate. + +After apply, say plainly that the workload was created and that it is not +running yet, and what you will look at next. A create reported as a deploy is +the same mistake as reporting a pointer reason: technically true, and it reads +as more than it is. + +## When to file a capability gap + +`report_capability_gap__compute-datumapis-com` is for cases where these tools +could not get a legitimate creation done: + +- A field the user needs that `compute_workload_render` has no input for, where the API + clearly supports it — `InsufficientDetail`, quoting the field and what you + tried. +- A validate rejection whose message does not name what to change, so the user + cannot act on it — `UnactionableGuidance`, quoting the message verbatim. + +Not gaps, however awkward the turn: + +- **No image.** Building one was never in scope here. +- **No quota, or Compute not enabled.** Those are grants, and the tools + reporting them accurately is the tools working. +- **A rejection that was right.** An unsupported instance type or an unattached + volume is validate doing its job — that is the answer, and it saved a broken + workload. +- **The user declined to confirm.** Not applying is the correct outcome. diff --git a/docs/agent/skills/workload-not-available.md b/docs/agent/skills/workload-not-available.md index 284f7d0b..cebe8927 100644 --- a/docs/agent/skills/workload-not-available.md +++ b/docs/agent/skills/workload-not-available.md @@ -4,7 +4,7 @@ Use when someone asks why a Workload is not running, not available, or stuck. ## Procedure -1. **Diagnose before you read.** Call `workload_diagnose` with the workload +1. **Diagnose before you read.** Call `compute_workload_diagnose` with the workload name. It walks Workload -> WorkloadDeployment -> Instance and returns the leaf cause. Do not assemble the tree by hand first — the top-level reason is usually a pointer, not a cause. @@ -61,7 +61,7 @@ tried. `kind` may be left off — this is the default: "capability": "per-instance CPU and memory usage for a workload", "evidence": { - "tool": "workload_diagnose", + "tool": "compute_workload_diagnose", "observed": "instances.ready 3 of 3; no usage figures on any field" } Describe the need in your own words. Do not copy the customer's message into diff --git a/internal/agent/discovery.go b/internal/agent/discovery.go new file mode 100644 index 00000000..9991a252 --- /dev/null +++ b/internal/agent/discovery.go @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + "fmt" + "sort" + + "github.com/modelcontextprotocol/go-sdk/mcp" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.datum.net/compute/internal/locations" + "go.datum.net/compute/internal/quotaview" + "go.datum.net/compute/internal/validation" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// The discovery tools answer what a project MAY deploy, where the diagnostic +// tools answer what it HAS deployed. +// +// They exist because an assistant asked to write a Workload otherwise invents +// the three fields it cannot guess — a location, a network, an instance type — +// and invents them plausibly. A workload naming a location the project is not +// entitled to, or a size the API will not take, fails after the customer has +// been told it was written correctly. Every one of these is read from the +// project itself, so the answer is what that project will actually accept. +// +// All four are read-only. +const ( + ToolLocationsList = "compute_locations_list" + ToolNetworksList = "compute_networks_list" + ToolQuotaGet = "compute_quota_get" + ToolInstanceTypesList = "compute_instance_types_list" +) + +// Discoverer reads what a project is entitled to deploy. +// +// Separate from Reader rather than folded into it: Reader is scoped to the +// compute objects a diagnosis walks, and these three reads reach other API +// groups entirely. Keeping them apart means a caller that only diagnoses need +// not be given the wiring for a locations or quota read it will never do. +// +// Like Reader, whoever constructs one decides the identity its reads run under. +type Discoverer interface { + // ListPlacementLocations returns the locations the project may place + // workloads at. Not namespaced: entitlement is a property of the project. + ListPlacementLocations(ctx context.Context) ([]locations.PlacementLocation, error) + // ListNetworks returns the Networks in the namespace. + ListNetworks(ctx context.Context, namespace string) ([]networkingv1alpha.Network, error) + // GetQuota returns the project's compute quota, one row per resource type. + GetQuota(ctx context.Context) ([]quotaview.QuotaRow, error) +} + +// ClientDiscoverer implements Discoverer against a controller-runtime client. +type ClientDiscoverer struct { + // Client reads the project, with whatever credentials it carries. + Client client.Client + + // PlatformClient supplies display metadata for quota rows, and may be nil. + // A server that reads only as the person who asked holds no platform + // credential of its own; the numbers are read from the project either way, + // and only the unit labels fall back to a generic form without it. + PlatformClient client.Client + + // Source selects which API group locations are read from. The zero value + // reads the group every deployment serves today, matching the manager's + // own default. + Source locations.Source +} + +var _ Discoverer = (*ClientDiscoverer)(nil) + +// NewClientDiscoverer returns a Discoverer backed by c, reading locations from +// source. +func NewClientDiscoverer(c client.Client, source locations.Source) *ClientDiscoverer { + return &ClientDiscoverer{Client: c, Source: source} +} + +func (d *ClientDiscoverer) ListPlacementLocations(ctx context.Context) ([]locations.PlacementLocation, error) { + found, err := locations.ListPlacementLocations(ctx, d.Client, d.Source) + if err != nil { + return nil, fmt.Errorf("listing the locations this project may place at: %w", err) + } + return found, nil +} + +func (d *ClientDiscoverer) ListNetworks(ctx context.Context, namespace string) ([]networkingv1alpha.Network, error) { + var list networkingv1alpha.NetworkList + if err := d.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, fmt.Errorf("listing networks in %s: %w", namespace, err) + } + return list.Items, nil +} + +func (d *ClientDiscoverer) GetQuota(ctx context.Context) ([]quotaview.QuotaRow, error) { + rows, err := quotaview.ListComputeQuota(ctx, d.Client, d.PlatformClient) + if err != nil { + return nil, fmt.Errorf("reading this project's compute quota: %w", err) + } + return rows, nil +} + +// ---------------------------------------------------------------- I/O types + +// LocationView is one location a project may place at. +type LocationView struct { + Name string `json:"name"` + // CityCode is the city the location serves, e.g. "DFW". Empty when the + // location declares none, which is worth reporting rather than hiding: a + // placement that names a city cannot be satisfied by such a location. + CityCode string `json:"cityCode,omitempty"` + // DisplayName is the human-readable label, when the location carries one. + DisplayName string `json:"displayName,omitempty"` + // Topology is the full set of attributes the location declares, city code + // included, so a placement can be matched on more than the city once more + // attributes are published. + Topology map[string]string `json:"topology,omitempty"` +} + +// NetworkView is one network a workload's instances can attach to. +type NetworkView struct { + Name string `json:"name"` + IPFamilies []string `json:"ipFamilies,omitempty"` + // Ready reports whether the network holds everything it needs to be used. + Ready bool `json:"ready"` + // Reason says why, when it is not ready. + Reason string `json:"reason,omitempty"` +} + +// InstanceTypeView is one instance type a Workload may ask for. +type InstanceTypeView struct { + Name string `json:"name"` + // VCPU is how many virtual CPUs the type provides. Fractional, because the + // size is stored in thousandths and a future type need not be a whole one. + VCPU float64 `json:"vcpu"` + // MemoryMiB is the RAM the type provides, in mebibytes. + MemoryMiB int64 `json:"memoryMiB"` + // Default marks the type to use when the customer expressed no preference. + Default bool `json:"default"` +} + +// LocationsListInput takes no arguments: the project is fixed by the request. +type LocationsListInput struct{} + +// LocationsListOutput is every location the project may place at. +type LocationsListOutput struct { + Locations []LocationView `json:"locations"` +} + +// NetworksListInput takes no arguments. +type NetworksListInput struct{} + +// NetworksListOutput is every network in the project. +type NetworksListOutput struct { + Networks []NetworkView `json:"networks"` +} + +// QuotaGetInput takes no arguments. +type QuotaGetInput struct{} + +// QuotaGetOutput is the project's compute quota, one row per resource type. +// The rows are quotaview's own, so what an assistant reports and what +// `datumctl compute quota` prints cannot drift apart. +type QuotaGetOutput struct { + Resources []quotaview.QuotaRow `json:"resources"` +} + +// InstanceTypesListInput takes no arguments. +type InstanceTypesListInput struct{} + +// InstanceTypesListOutput is the catalog of instance types. +type InstanceTypesListOutput struct { + InstanceTypes []InstanceTypeView `json:"instanceTypes"` +} + +// ------------------------------------------------------------ registration + +// RegisterDiscoveryTools adds the tools that answer what a project may deploy. +// Called by RegisterTools; separate so the set can be read on its own. +func RegisterDiscoveryTools(s *mcp.Server, deps DepsFor) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolLocationsList, + Title: "List locations", + Description: "List the locations this project may place a Workload in, each with its city code " + + "(e.g. \"DFW\") and the attributes it declares. These are the only places this project may " + + "place a workload: a location missing from this list either does not offer compute at all " + + "or this project is not entitled to it, and a placement naming it will never come up. Call " + + "this before writing a Workload's placements rather than guessing a city. Read-only.", + }, locationsList(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolNetworksList, + Title: "List networks", + Description: "List the networks in this project, each with its IP families and whether it is ready " + + "to use. Every Workload attaches its instances to a network by name, so a draft needs one; " + + "\"default\" is the conventional name and is what a project normally has. A network that is " + + "not ready will hold new instances back, and its reason says what it is waiting on. Read-only.", + }, networksList(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolQuotaGet, + Title: "Get compute quota", + Description: "Report this project's compute quota: for each resource type — workloads, instances, " + + "vCPUs, memory — the limit, how much is already in use, and how much is left. Call it before " + + "proposing a replica count or an instance size, so the workload fits in what the project has, " + + "and call it when something reports QuotaExceeded to see how much room there actually is. " + + "Read-only.", + }, quotaGet(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolInstanceTypesList, + Title: "List instance types", + Description: "List the instance types a Workload may ask for, with the vCPU and memory each one " + + "provides and which is the default. Only these names are accepted — a Workload naming any " + + "other is rejected the moment it is submitted, so never invent a size. Read-only.", + }, instanceTypesList(deps)) +} + +// ---------------------------------------------------------------- handlers + +func locationsList(deps DepsFor) mcp.ToolHandlerFor[LocationsListInput, LocationsListOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, _ LocationsListInput, + ) (*mcp.CallToolResult, LocationsListOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, LocationsListOutput{}, err + } + disc, err := d.discoverer() + if err != nil { + return nil, LocationsListOutput{}, err + } + + found, err := disc.ListPlacementLocations(ctx) + if err != nil { + return nil, LocationsListOutput{}, err + } + + out := LocationsListOutput{Locations: make([]LocationView, 0, len(found))} + for _, location := range found { + code, _ := location.CityCode() + out.Locations = append(out.Locations, LocationView{ + Name: location.Name, + CityCode: code, + Topology: location.Topology, + }) + } + // By name, so two calls in one conversation read the same way. + sort.Slice(out.Locations, func(i, j int) bool { + return out.Locations[i].Name < out.Locations[j].Name + }) + return nil, out, nil + } +} + +func networksList(deps DepsFor) mcp.ToolHandlerFor[NetworksListInput, NetworksListOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, _ NetworksListInput, + ) (*mcp.CallToolResult, NetworksListOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, NetworksListOutput{}, err + } + disc, err := d.discoverer() + if err != nil { + return nil, NetworksListOutput{}, err + } + + found, err := disc.ListNetworks(ctx, d.Namespace) + if err != nil { + return nil, NetworksListOutput{}, err + } + + out := NetworksListOutput{Networks: make([]NetworkView, 0, len(found))} + for i := range found { + n := &found[i] + view := NetworkView{Name: n.Name} + for _, family := range n.Spec.IPFamilies { + view.IPFamilies = append(view.IPFamilies, string(family)) + } + // A network with no Ready condition at all has not been looked at + // yet, which reads as not ready with nothing to say about why. + if ready := apimeta.FindStatusCondition(n.Status.Conditions, networkingv1alpha.NetworkReady); ready != nil { + view.Ready = ready.Status == metav1.ConditionTrue + if !view.Ready { + view.Reason = ready.Reason + } + } + out.Networks = append(out.Networks, view) + } + sort.Slice(out.Networks, func(i, j int) bool { + return out.Networks[i].Name < out.Networks[j].Name + }) + return nil, out, nil + } +} + +func quotaGet(deps DepsFor) mcp.ToolHandlerFor[QuotaGetInput, QuotaGetOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, _ QuotaGetInput, + ) (*mcp.CallToolResult, QuotaGetOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, QuotaGetOutput{}, err + } + disc, err := d.discoverer() + if err != nil { + return nil, QuotaGetOutput{}, err + } + + rows, err := disc.GetQuota(ctx) + if err != nil { + return nil, QuotaGetOutput{}, err + } + // An empty result is "no quota is configured", not an error: the tool + // says so by returning an empty list rather than a null one. + if rows == nil { + rows = []quotaview.QuotaRow{} + } + return nil, QuotaGetOutput{Resources: rows}, nil + } +} + +// instanceTypesList reads only the catalog, but still resolves deps for the +// same reason reasonExplain does: an unauthenticated caller must not be able to +// use it to probe the server. +func instanceTypesList(deps DepsFor) mcp.ToolHandlerFor[InstanceTypesListInput, InstanceTypesListOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, _ InstanceTypesListInput, + ) (*mcp.CallToolResult, InstanceTypesListOutput, error) { + if _, err := deps(ctx); err != nil { + return nil, InstanceTypesListOutput{}, err + } + return nil, InstanceTypesListOutput{InstanceTypes: Catalog()}, nil + } +} + +// ----------------------------------------------------------------- helpers + +// discoverer returns the Discoverer for this call, or an error naming the +// wiring mistake. A nil one is a server that was built without discovery, and +// saying so beats a nil dereference in a handler. +func (d ToolDeps) discoverer() (Discoverer, error) { + if d.Discoverer == nil { + return nil, fmt.Errorf( + "this server was built without the ability to read what the project may deploy, so this " + + "tool cannot answer. The person who asked did nothing wrong: whoever operates this " + + "server needs to configure it") + } + return d.Discoverer, nil +} + +// instanceTypeSize is the vCPU and memory one instance type provides. +type instanceTypeSize struct { + // CPUMillicores is thousandths of a vCPU: 1000 is one. + CPUMillicores int64 + MemoryMiB int64 +} + +// instanceTypeSizes gives the size behind each supported instance type name. +// +// The names come from internal/validation, which is what actually accepts or +// rejects a Workload, so this table can never offer a type the API would turn +// down. The sizes are the platform-declared ones, duplicated here from the +// instance controller's own accounting table. +// +// TODO(#137): both halves belong in one served catalog. Until there is one, +// a new instance type has to be added in three places — validation, the +// controller's accounting, and here — and a type missing from this table is +// reported with no size rather than being silently dropped. +var instanceTypeSizes = map[string]instanceTypeSize{ + "datumcloud/d1-standard-2": {CPUMillicores: 1000, MemoryMiB: 2048}, +} + +// Catalog returns the instance types a Workload may ask for, in offer order. +// The first is the default: validation accepts exactly one type today, and the +// order it lists them in is the order to prefer them. +func Catalog() []InstanceTypeView { + supported := validation.SupportedInstanceTypes() + out := make([]InstanceTypeView, 0, len(supported)) + for i, name := range supported { + size := instanceTypeSizes[name] + out = append(out, InstanceTypeView{ + Name: name, + VCPU: float64(size.CPUMillicores) / 1000, + MemoryMiB: size.MemoryMiB, + Default: i == 0, + }) + } + return out +} diff --git a/internal/agent/discovery_test.go b/internal/agent/discovery_test.go new file mode 100644 index 00000000..fcd61888 --- /dev/null +++ b/internal/agent/discovery_test.go @@ -0,0 +1,357 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.datum.net/compute/internal/locations" + "go.datum.net/compute/internal/quotaview" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// fakeDiscoverer serves canned entitlement facts so the discovery tools can be +// exercised without a cluster. +type fakeDiscoverer struct { + locations []locations.PlacementLocation + networks []networkingv1alpha.Network + quota []quotaview.QuotaRow + err error +} + +var _ Discoverer = (*fakeDiscoverer)(nil) + +func (f *fakeDiscoverer) ListPlacementLocations(context.Context) ([]locations.PlacementLocation, error) { + return f.locations, f.err +} + +func (f *fakeDiscoverer) ListNetworks(context.Context, string) ([]networkingv1alpha.Network, error) { + return f.networks, f.err +} + +func (f *fakeDiscoverer) GetQuota(context.Context) ([]quotaview.QuotaRow, error) { + return f.quota, f.err +} + +// fixtureDiscoverer covers the shapes worth distinguishing: a location that +// declares a city and one that does not, a ready network and one that is still +// waiting, and quota with room in one dimension and none in another. +func fixtureDiscoverer() *fakeDiscoverer { + return &fakeDiscoverer{ + // Deliberately out of alphabetical order, so the sort is proven. + locations: []locations.PlacementLocation{ + {Name: "us-south-dfw", Topology: map[string]string{locations.TopologyCityCodeKey: cityDFW}}, + {Name: "eu-west-ams", Topology: map[string]string{locations.TopologyCityCodeKey: cityAMS}}, + {Name: "no-city", Topology: map[string]string{"topology.datum.net/region": "unknown"}}, + }, + networks: []networkingv1alpha.Network{ + network("staging", []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, + metav1.Condition{ + Type: networkingv1alpha.NetworkReady, + Status: metav1.ConditionFalse, + Reason: networkingv1alpha.NetworkReasonProjectNamespaceNotFound, + }), + network("default", + []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol, networkingv1alpha.IPv6Protocol}, + metav1.Condition{ + Type: networkingv1alpha.NetworkReady, + Status: metav1.ConditionTrue, + Reason: networkingv1alpha.NetworkReadyReasonReady, + }), + }, + quota: []quotaview.QuotaRow{ + {ResourceType: "compute.datumapis.com/workloads", DisplayName: "Workloads", Unit: "workloads", + Limit: 10, Used: 3, Available: 7}, + {ResourceType: "compute.datumapis.com/vcpus", DisplayName: "vCPUs", Unit: "vCPUs", + Limit: 8, Used: 8, Available: 0}, + }, + } +} + +func network(name string, families []networkingv1alpha.IPFamily, conditions ...metav1.Condition) networkingv1alpha.Network { + return networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, + Spec: networkingv1alpha.NetworkSpec{IPFamilies: families}, + Status: networkingv1alpha.NetworkStatus{Conditions: conditions}, + } +} + +// discoveryDeps supplies both halves, since RegisterTools registers every tool +// against one DepsFor. +func discoveryDeps(d Discoverer) DepsFor { + return func(context.Context) (ToolDeps, error) { + return ToolDeps{Reader: fixtureReader(), Discoverer: d, Namespace: testNamespace}, nil + } +} + +func TestLocationsListReportsCityCodesAndSortsByName(t *testing.T) { + deps := discoveryDeps(fixtureDiscoverer()) + + _, out, err := locationsList(deps)(context.Background(), nil, LocationsListInput{}) + if err != nil { + t.Fatalf("compute_locations_list: %v", err) + } + if len(out.Locations) != 3 { + t.Fatalf("got %d locations, want 3", len(out.Locations)) + } + + wantOrder := []string{"eu-west-ams", "no-city", "us-south-dfw"} + for i, want := range wantOrder { + if got := out.Locations[i].Name; got != want { + t.Errorf("locations[%d] = %q, want %q (the list must be sorted by name)", i, got, want) + } + } + + byName := make(map[string]LocationView, len(out.Locations)) + for _, l := range out.Locations { + byName[l.Name] = l + } + if got := byName["us-south-dfw"].CityCode; got != cityDFW { + t.Errorf("us-south-dfw cityCode = %q, want %q", got, cityDFW) + } + // A location with no city is reported rather than dropped: a placement that + // names a city cannot be satisfied by it, and the model needs to see that. + if got := byName["no-city"].CityCode; got != "" { + t.Errorf("no-city cityCode = %q, want empty", got) + } + if len(byName["no-city"].Topology) == 0 { + t.Error("no-city lost its topology; the attributes are what is left to match on") + } +} + +func TestNetworksListReportsReadinessAndFamilies(t *testing.T) { + deps := discoveryDeps(fixtureDiscoverer()) + + _, out, err := networksList(deps)(context.Background(), nil, NetworksListInput{}) + if err != nil { + t.Fatalf("compute_networks_list: %v", err) + } + if len(out.Networks) != 2 { + t.Fatalf("got %d networks, want 2", len(out.Networks)) + } + + // "default" is the one an assistant reaches for, and sorting puts it first. + first := out.Networks[0] + if first.Name != "default" { + t.Fatalf("networks[0] = %q, want default (the list must be sorted by name)", first.Name) + } + if !first.Ready { + t.Error("default Ready = false, want true") + } + if first.Reason != "" { + t.Errorf("default reason = %q, want empty on a ready network", first.Reason) + } + if strings.Join(first.IPFamilies, ",") != "IPv4,IPv6" { + t.Errorf("default ipFamilies = %v, want [IPv4 IPv6]", first.IPFamilies) + } + + second := out.Networks[1] + if second.Ready { + t.Error("staging Ready = true, want false") + } + // The reason is the whole point of reporting an unready network: it says + // what the network is waiting on. + if second.Reason != networkingv1alpha.NetworkReasonProjectNamespaceNotFound { + t.Errorf("staging reason = %q, want %q", second.Reason, + networkingv1alpha.NetworkReasonProjectNamespaceNotFound) + } +} + +// TestNetworksListTreatsAnUnreportedNetworkAsNotReady pins the safe default: a +// network nothing has looked at yet must not read as usable. +func TestNetworksListTreatsAnUnreportedNetworkAsNotReady(t *testing.T) { + d := &fakeDiscoverer{networks: []networkingv1alpha.Network{ + network("fresh", []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}), + }} + + _, out, err := networksList(discoveryDeps(d))(context.Background(), nil, NetworksListInput{}) + if err != nil { + t.Fatalf("compute_networks_list: %v", err) + } + if len(out.Networks) != 1 || out.Networks[0].Ready { + t.Errorf("networks = %+v, want one network reported not ready", out.Networks) + } +} + +func TestQuotaGetReturnsEveryResourceType(t *testing.T) { + deps := discoveryDeps(fixtureDiscoverer()) + + _, out, err := quotaGet(deps)(context.Background(), nil, QuotaGetInput{}) + if err != nil { + t.Fatalf("compute_quota_get: %v", err) + } + if len(out.Resources) != 2 { + t.Fatalf("got %d resources, want 2", len(out.Resources)) + } + // Order is the caller's, not re-sorted here: quotaview already returns the + // rows in the order a person reads them. + if got := out.Resources[0].ResourceType; got != "compute.datumapis.com/workloads" { + t.Errorf("resources[0] = %q, want the workloads row first", got) + } + exhausted := out.Resources[1] + if exhausted.Available != 0 || exhausted.Used != exhausted.Limit { + t.Errorf("vCPU row = %+v, want a row with nothing available", exhausted) + } +} + +// TestQuotaGetReturnsAnEmptyListWhenNoQuotaIsConfigured keeps "no quota is set +// up" from arriving as a null the model has to interpret. +func TestQuotaGetReturnsAnEmptyListWhenNoQuotaIsConfigured(t *testing.T) { + _, out, err := quotaGet(discoveryDeps(&fakeDiscoverer{}))(context.Background(), nil, QuotaGetInput{}) + if err != nil { + t.Fatalf("compute_quota_get: %v", err) + } + if out.Resources == nil { + t.Fatal("Resources is nil; an empty project must return an empty list") + } + if len(out.Resources) != 0 { + t.Errorf("Resources = %+v, want empty", out.Resources) + } +} + +func TestInstanceTypesListOffersOnlyWhatValidationAccepts(t *testing.T) { + deps := discoveryDeps(fixtureDiscoverer()) + + _, out, err := instanceTypesList(deps)(context.Background(), nil, InstanceTypesListInput{}) + if err != nil { + t.Fatalf("compute_instance_types_list: %v", err) + } + if len(out.InstanceTypes) == 0 { + t.Fatal("no instance types offered; a model with no catalog invents one") + } + + var defaults int + for _, it := range out.InstanceTypes { + if it.Default { + defaults++ + } + // A type with no size is worse than useless: it invites a replica count + // chosen against nothing. + if it.VCPU <= 0 || it.MemoryMiB <= 0 { + t.Errorf("%s = %g vCPU / %d MiB, want a real size", it.Name, it.VCPU, it.MemoryMiB) + } + } + if defaults != 1 { + t.Errorf("got %d default instance types, want exactly 1", defaults) + } + + // The one supported type today, with the sizing quota is accounted against. + first := out.InstanceTypes[0] + if first.Name != "datumcloud/d1-standard-2" || first.VCPU != 1 || first.MemoryMiB != 2048 { + t.Errorf("first type = %+v, want datumcloud/d1-standard-2 at 1 vCPU / 2048 MiB", first) + } +} + +// TestDiscoveryToolsFailWhenDepsAreUnavailable covers the path every handler +// shares: an unauthenticated or misconfigured caller must be turned away before +// any read, so no tool can be used to probe the server. +func TestDiscoveryToolsFailWhenDepsAreUnavailable(t *testing.T) { + wantErr := errors.New("no credentials on this request") + deps := DepsFor(func(context.Context) (ToolDeps, error) { return ToolDeps{}, wantErr }) + ctx := context.Background() + + calls := map[string]func() error{ + ToolLocationsList: func() error { + _, _, err := locationsList(deps)(ctx, nil, LocationsListInput{}) + return err + }, + ToolNetworksList: func() error { + _, _, err := networksList(deps)(ctx, nil, NetworksListInput{}) + return err + }, + ToolQuotaGet: func() error { + _, _, err := quotaGet(deps)(ctx, nil, QuotaGetInput{}) + return err + }, + // Answerable from the catalog alone, and still refused. + ToolInstanceTypesList: func() error { + _, _, err := instanceTypesList(deps)(ctx, nil, InstanceTypesListInput{}) + return err + }, + } + for name, call := range calls { + if err := call(); !errors.Is(err, wantErr) { + t.Errorf("%s error = %v, want the deps error to surface unchanged", name, err) + } + } +} + +// TestDiscoveryToolsExplainAMissingDiscoverer covers a server wired for +// diagnosis only: the tools must name the wiring gap rather than panic. +func TestDiscoveryToolsExplainAMissingDiscoverer(t *testing.T) { + deps := func(context.Context) (ToolDeps, error) { + return ToolDeps{Reader: fixtureReader(), Namespace: testNamespace}, nil + } + ctx := context.Background() + + if _, _, err := locationsList(deps)(ctx, nil, LocationsListInput{}); err == nil { + t.Error("compute_locations_list succeeded with no Discoverer, want an error") + } + if _, _, err := networksList(deps)(ctx, nil, NetworksListInput{}); err == nil { + t.Error("compute_networks_list succeeded with no Discoverer, want an error") + } + if _, _, err := quotaGet(deps)(ctx, nil, QuotaGetInput{}); err == nil { + t.Error("compute_quota_get succeeded with no Discoverer, want an error") + } + // compute_instance_types_list reads no project state, so it still answers. + if _, _, err := instanceTypesList(deps)(ctx, nil, InstanceTypesListInput{}); err != nil { + t.Errorf("compute_instance_types_list: %v, want the catalog to answer without a Discoverer", err) + } +} + +// TestDiscoveryToolsAnswerOverTheWire proves registration, not just the +// handlers: a tool that is never wired into RegisterTools passes every unit +// test above and is uncallable in production. +func TestDiscoveryToolsAnswerOverTheWire(t *testing.T) { + ctx := context.Background() + + server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) + RegisterTools(server, discoveryDeps(fixtureDiscoverer())) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connecting server: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connecting client: %v", err) + } + defer func() { _ = clientSession.Close() }() + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: ToolLocationsList, + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("calling %s: %v", ToolLocationsList, err) + } + if res.IsError { + t.Fatalf("%s returned an error result: %+v", ToolLocationsList, res.Content) + } + + // Round-tripped through the wire's JSON, so the output schema is exercised + // as the model would receive it. + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshalling structured content: %v", err) + } + var out LocationsListOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decoding %s output: %v", ToolLocationsList, err) + } + if len(out.Locations) != 3 { + t.Fatalf("got %d locations over the wire, want 3: %s", len(out.Locations), raw) + } + if out.Locations[0].CityCode != cityAMS { + t.Errorf("locations[0].cityCode = %q, want the city the location declares", out.Locations[0].CityCode) + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index d5251950..b2999aea 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -12,25 +12,52 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" ) -// The tools compute publishes to an assistant. All five are read-only. +// The tools compute publishes to an assistant. These five are read-only, as +// are the four discovery tools in discovery.go. // -// There is deliberately no mutating tool — no delete, no scale, no restart. -// The gateway's allow-list is the enforcement point, but a tool that is never -// implemented cannot be called through any path at all. Adding one needs its -// own review, not a quiet addition here. +// Compute publishes exactly two tools that can change anything: +// compute_workload_plan and compute_workload_apply, in write.go. They are one +// operation split in half. Plan returns a manifest and a token that is a hash +// of it; apply takes that manifest and that token and re-derives the hash, so +// the only thing that can be created is the manifest the model already showed +// the person who asked, unchanged, in this project, against the workload the +// plan saw. Everything else about the surface is unchanged by them: every call +// runs as the caller's own credential, so a tool can write nothing the person +// could not write themselves, and whether the mutating tools are offered to a +// given project at all is the gateway's allow-list to decide. +// +// There is still no delete, no scale, and no restart. A third mutating tool is +// a new decision and gets its own review — the argument for these two is about +// these two and does not generalise. const ( - ToolWorkloadsList = "workloads_list" - ToolWorkloadsGet = "workloads_get" - ToolInstancesList = "instances_list" - ToolWorkloadDiagnose = "workload_diagnose" - ToolReasonExplain = "reason_explain" + ToolWorkloadsList = "compute_workloads_list" + ToolWorkloadsGet = "compute_workloads_get" + ToolInstancesList = "compute_instances_list" + ToolWorkloadDiagnose = "compute_workload_diagnose" + ToolReasonExplain = "compute_reason_explain" ) // ToolDeps is what one request's tool calls operate over: where to read from, // and which project's namespace they are confined to. type ToolDeps struct { - Reader Reader + Reader Reader + // Discoverer reads what the project may deploy — locations, networks, + // quota. May be nil on a server built for diagnosis only; the discovery + // tools then fail with a message naming that, rather than panicking. + Discoverer Discoverer + // Writer creates and changes workloads. Nil on a server built for + // diagnosis only, and the write tools then say so rather than panicking — + // a deployment that publishes no write path is a supported configuration. + Writer Writer Namespace string + // Project is the project this request is for. Tools never take it as an + // argument; it is carried here so a plan token can be bound to it, and a + // plan minted for one project is refused in another. + Project string + // PlanTokenKey signs plan tokens. Empty on a server built without the + // write path, which then refuses to mint or accept one: a server that + // cannot check a token must not issue something that looks like one. + PlanTokenKey []byte } // DepsFor resolves the dependencies for a tool call. A function rather than a @@ -168,9 +195,9 @@ type ReasonExplainOutput struct { // ------------------------------------------------------------ registration -// RegisterTools adds compute's read-only diagnostic tools to s. deps is -// consulted per call rather than captured once, so no caller can inherit -// another's identity or project. +// RegisterTools adds every tool compute publishes to s: diagnosis, discovery, +// and the write path. deps is consulted per call rather than captured once, so +// no caller can inherit another's identity or project. func RegisterTools(s *mcp.Server, deps DepsFor) { mcp.AddTool(s, &mcp.Tool{ Name: ToolWorkloadsList, @@ -225,6 +252,14 @@ func RegisterTools(s *mcp.Server, deps DepsFor) { "argument to list the whole catalog. Use when you encounter a reason on a resource the " + "diagnose tool did not cover. Read-only.", }, reasonExplain(deps)) + + // What the project may deploy, alongside what it has deployed. See + // discovery.go for why an assistant needs both. + RegisterDiscoveryTools(s, deps) + + // And the write path: render and validate, which change nothing, then the + // two token-bound tools that do. See write.go. + RegisterWriteTools(s, deps) } // ---------------------------------------------------------------- handlers diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index b0964784..85ff118d 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -21,6 +21,15 @@ const ( depAPIBackend = "api-backend-a" placementUSCentral = "us-central" cityDFW = "DFW" + cityAMS = "AMS" +) + +// The identities the in-memory MCP transports are exercised under. Shared, so +// the several tests that stand a server up are obviously the same setup. +const ( + testImplVersion = "0.0.1" + testServerName = "test" + testClientName = "test-client" ) // fakeReader serves canned objects so the tools can be exercised without a @@ -109,7 +118,7 @@ func fixtureReader() *fakeReader { computev1alpha.WorkloadDeploymentReasonNoMatchingLocation, "The cell has not been told which location it serves.")) edgeDeployment.Spec.PlacementName = "ams-edge" - edgeDeployment.Spec.CityCode = "AMS" + edgeDeployment.Spec.CityCode = cityAMS apiDeployment := deployment(depAPIBackend, cond(computev1alpha.WorkloadDeploymentAvailable, "False", @@ -141,7 +150,7 @@ func TestWorkloadsListReportsRootCauseAndOrdersWorstFirst(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } if len(out.Workloads) != 3 { t.Fatalf("got %d workloads, want 3", len(out.Workloads)) @@ -194,7 +203,7 @@ func TestWorkloadsGetReturnsFullTree(t *testing.T) { _, out, err := workloadsGet(deps)(context.Background(), nil, WorkloadsGetInput{Name: wlAPIBackend}) if err != nil { - t.Fatalf("workloads_get: %v", err) + t.Fatalf("compute_workloads_get: %v", err) } if out.Workload.Name != wlAPIBackend || out.Workload.Namespace != testNamespace { @@ -230,7 +239,7 @@ func TestInstancesListFilters(t *testing.T) { _, filtered, err := instancesList(deps)(context.Background(), nil, InstancesListInput{Workload: wlAPIBackend}) if err != nil { - t.Fatalf("instances_list filtered: %v", err) + t.Fatalf("compute_instances_list filtered: %v", err) } if len(filtered.Instances) != 3 { t.Errorf("filtered instances = %d, want 3", len(filtered.Instances)) @@ -238,7 +247,7 @@ func TestInstancesListFilters(t *testing.T) { _, all, err := instancesList(deps)(context.Background(), nil, InstancesListInput{}) if err != nil { - t.Fatalf("instances_list unfiltered: %v", err) + t.Fatalf("compute_instances_list unfiltered: %v", err) } // web-frontend has 3, api-backend has 3, edge-cache has none. if len(all.Instances) != 6 { @@ -277,7 +286,7 @@ func TestWorkloadDiagnoseSurfacesLeafCause(t *testing.T) { _, d, err := workloadDiagnose(deps)( context.Background(), nil, WorkloadDiagnoseInput{Name: tc.workload}) if err != nil { - t.Fatalf("workload_diagnose: %v", err) + t.Fatalf("compute_workload_diagnose: %v", err) } if d.RootCause == nil { t.Fatalf("RootCause is nil, want %q", tc.wantReason) @@ -305,7 +314,7 @@ func TestReasonExplain(t *testing.T) { _, one, err := reasonExplain(deps)(ctx, nil, ReasonExplainInput{Reason: "QuotaNoBudget"}) if err != nil { - t.Fatalf("reason_explain: %v", err) + t.Fatalf("compute_reason_explain: %v", err) } if one.Reason == nil { t.Fatal("Reason is nil") @@ -319,7 +328,7 @@ func TestReasonExplain(t *testing.T) { _, all, err := reasonExplain(deps)(ctx, nil, ReasonExplainInput{}) if err != nil { - t.Fatalf("reason_explain (all): %v", err) + t.Fatalf("compute_reason_explain (all): %v", err) } if len(all.Reasons) != len(AllReasons()) { t.Errorf("got %d reasons, want the whole catalog (%d)", len(all.Reasons), len(AllReasons())) @@ -339,19 +348,19 @@ func TestToolsFailWhenDepsUnavailable(t *testing.T) { ctx := context.Background() if _, _, err := workloadsList(denied)(ctx, nil, WorkloadsListInput{}); err == nil { - t.Error("workloads_list should fail without deps") + t.Error("compute_workloads_list should fail without deps") } if _, _, err := workloadsGet(denied)(ctx, nil, WorkloadsGetInput{Name: "x"}); err == nil { - t.Error("workloads_get should fail without deps") + t.Error("compute_workloads_get should fail without deps") } if _, _, err := instancesList(denied)(ctx, nil, InstancesListInput{}); err == nil { - t.Error("instances_list should fail without deps") + t.Error("compute_instances_list should fail without deps") } if _, _, err := workloadDiagnose(denied)(ctx, nil, WorkloadDiagnoseInput{Name: "x"}); err == nil { - t.Error("workload_diagnose should fail without deps") + t.Error("compute_workload_diagnose should fail without deps") } if _, _, err := reasonExplain(denied)(ctx, nil, ReasonExplainInput{}); err == nil { - t.Error("reason_explain should fail without deps: it must not be a probe for unauthenticated callers") + t.Error("compute_reason_explain should fail without deps: it must not be a probe for unauthenticated callers") } } @@ -363,18 +372,31 @@ func TestReaderErrorsPropagate(t *testing.T) { deps := fixtureDeps(r) if _, _, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}); err == nil { - t.Error("workloads_list should surface a reader error") + t.Error("compute_workloads_list should surface a reader error") } } -// TestRegisterToolsPublishesExactlyTheReadOnlySet inspects what a registered -// server actually advertises: five read-only tools and no mutating one, so -// anything extra over the wire is a bug. It also catches a schema that fails to -// infer, since AddTool panics on a bad one. -func TestRegisterToolsPublishesExactlyTheReadOnlySet(t *testing.T) { +// TestRegisterToolsPublishesExactlyTheDocumentedSet inspects what a registered +// server actually advertises. Two things are pinned here, and they are the +// reason this test is worth its length. +// +// The set is closed: thirteen tools, named, so a fourteenth cannot arrive +// without someone editing this list. The gateway's allow-list is the +// enforcement point, but a tool that does not exist cannot be called through +// any path at all. +// +// And of those thirteen, exactly two may leave out the promise that they +// change nothing: compute_workload_plan and compute_workload_apply. That promise is load +// bearing — it is what tells the model it can run a tool without asking first +// — so a tool that quietly stops making it, or a new mutating tool that never +// made it, fails here rather than in a conversation. +// +// It also catches a schema that fails to infer, since AddTool panics on a bad +// one. +func TestRegisterToolsPublishesExactlyTheDocumentedSet(t *testing.T) { ctx := context.Background() - server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) RegisterTools(server, fixtureDeps(fixtureReader())) serverTransport, clientTransport := mcp.NewInMemoryTransports() @@ -384,7 +406,7 @@ func TestRegisterToolsPublishesExactlyTheReadOnlySet(t *testing.T) { } defer func() { _ = serverSession.Close() }() - client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.1"}, nil) + client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) clientSession, err := client.Connect(ctx, clientTransport, nil) if err != nil { t.Fatalf("connecting client: %v", err) @@ -402,11 +424,22 @@ func TestRegisterToolsPublishesExactlyTheReadOnlySet(t *testing.T) { } want := []string{ + // What the project has deployed. ToolWorkloadsList, ToolWorkloadsGet, ToolInstancesList, ToolWorkloadDiagnose, ToolReasonExplain, + // What the project may deploy. + ToolLocationsList, + ToolNetworksList, + ToolQuotaGet, + ToolInstanceTypesList, + // Writing: two that cannot change anything, and two that can. + ToolWorkloadRender, + ToolWorkloadValidate, + ToolWorkloadPlan, + ToolWorkloadApply, } if len(got) != len(want) { t.Errorf("published %d tools %v, want exactly %d", len(got), keysOf(got), len(want)) @@ -424,13 +457,21 @@ func TestRegisterToolsPublishesExactlyTheReadOnlySet(t *testing.T) { } } - // Compute ships no mutating tool. Enforcement of the allow-list is the - // gateway's job, but a tool that does not exist cannot be called at all. - for name := range got { - for _, forbidden := range []string{"delete", "create", "update", "scale", "restart"} { - if strings.Contains(name, forbidden) { - t.Errorf("tool %q looks mutating; compute publishes read-only tools only", name) - } + // The two mutating tools, and no others. A tool whose description does not + // promise it changes nothing is one the model has to ask about first, so + // the set of tools making no such promise IS the mutating surface, as the + // model sees it. + mutating := map[string]bool{ToolWorkloadPlan: true, ToolWorkloadApply: true} + for name, desc := range got { + promises := strings.Contains(desc, "Read-only.") || strings.Contains(desc, "Writes nothing.") + switch { + case promises && mutating[name]: + t.Errorf("tool %q changes things but its description promises it does not; "+ + "the model will call it without asking", name) + case !promises && !mutating[name]: + t.Errorf("tool %q does not say it is read-only or writes nothing. Either say so, or — if "+ + "it really can change something — a third mutating tool is a new decision that gets "+ + "its own review, not a quiet addition here", name) } } } @@ -468,7 +509,7 @@ func TestWorkloadsListCarriesTheAgeOfTheRootCause(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } if len(out.Workloads) != 1 { t.Fatalf("got %d workloads, want 1", len(out.Workloads)) @@ -490,7 +531,7 @@ func TestWorkloadsListOmitsAgeForHealthyWorkloads(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } for _, row := range out.Workloads { if row.Workload == wlWebFrontend && (row.RootCauseSince != "" || row.RootCauseFor != "") { @@ -510,7 +551,7 @@ func TestWorkloadsListFlagsTheStagingStall(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } row := out.Workloads[0] if row.RootCauseReason != computev1alpha.InstanceProgrammedReasonProgrammingInProgress { @@ -561,7 +602,7 @@ func TestWorkloadsListLeavesFreshTransientStateAlone(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } if got := out.Workloads[0].Actionability; got != ActionabilityTransient { t.Errorf("Actionability = %q, want %q for a two-minute-old ProgrammingInProgress", @@ -592,7 +633,7 @@ func TestWorkloadsListCarriesTheFailureFloor(t *testing.T) { _, out, err := workloadsList(deps)(context.Background(), nil, WorkloadsListInput{}) if err != nil { - t.Fatalf("workloads_list: %v", err) + t.Fatalf("compute_workloads_list: %v", err) } row := out.Workloads[0] if row.RootCauseFor != stagingInState { diff --git a/internal/agent/write.go b/internal/agent/write.go new file mode 100644 index 00000000..ef6861f7 --- /dev/null +++ b/internal/agent/write.go @@ -0,0 +1,1019 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + sigsyaml "sigs.k8s.io/yaml" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/workloadspec" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +// The write path: four tools, of which exactly two can change anything. +// +// It is one operation split into four steps because the model is not the +// person. compute_workload_render turns inputs into a manifest and touches +// nothing. compute_workload_validate asks the server for its verdict on that +// manifest without creating anything. Neither can write, so both are safe to +// run as often as it takes to get the manifest right. +// +// compute_workload_plan and compute_workload_apply are the two that matter. +// Plan returns a canonical manifest and a token that is a hash of it, together +// with the project and the version of the workload the plan saw. Apply takes +// that manifest and that token and re-derives the hash: a manifest edited after +// the plan, a token minted for another project, or a workload someone else +// changed in the meantime all fail to match, and apply refuses rather than +// writing something nobody agreed to. So the only thing that can reach the API +// is the manifest the model already put in front of the person who asked — a +// model that reads a poisoned status message cannot smuggle a different +// workload past a confirmation of this one, and a manifest nobody was shown has +// no token and cannot be applied at all. +const ( + ToolWorkloadRender = "compute_workload_render" + ToolWorkloadValidate = "compute_workload_validate" + ToolWorkloadPlan = "compute_workload_plan" + ToolWorkloadApply = "compute_workload_apply" +) + +const ( + // planTokenTTL is how long a plan token stays good. Long enough for the + // model to show the manifest and the person to read it and answer, short + // enough that a token cannot outlive the conversation it belongs to. + planTokenTTL = 15 * time.Minute + + // actionCreate and actionUpdate are the two things an apply can be. + actionCreate = "create" + actionUpdate = "update" + + // fieldManifest is the field a rejection names when the manifest could not + // be read at all, rather than when the server named a path inside it. + fieldManifest = "manifest" +) + +// Writer creates and changes the objects a deployment needs. +// +// Separate from Reader for the reason the interface exists at all: a server +// built for diagnosis is given no Writer, and the write tools then say so +// rather than failing somewhere further down. Like Reader, whoever constructs +// one decides the identity its writes run under — the server builds one per +// request from the caller's own credentials, so a tool call can create nothing +// the person who asked could not create themselves. +type Writer interface { + // DryRunCreate asks the server to check a create without performing it. + // The error is the server's own rejection, unwrapped, because its wording + // and the field path it names are the answer. + DryRunCreate(ctx context.Context, w *computev1alpha.Workload) error + // DryRunUpdate asks the server to check an update without performing it. + DryRunUpdate(ctx context.Context, w *computev1alpha.Workload) error + // Create creates the workload. + Create(ctx context.Context, w *computev1alpha.Workload) error + // Update replaces the workload. The caller sets the resource version it + // read, so a change made in between is refused by the server. + Update(ctx context.Context, w *computev1alpha.Workload) error + // GetNetwork returns one network by name. A missing network is reported as + // a not-found error, which callers distinguish with apierrors.IsNotFound. + GetNetwork(ctx context.Context, namespace, name string) (*networkingv1alpha.Network, error) + // CreateNetwork creates a network. + CreateNetwork(ctx context.Context, n *networkingv1alpha.Network) error +} + +// ClientWriter implements Writer against a controller-runtime client. +type ClientWriter struct { + // Client writes the project, with whatever credentials it carries. + Client client.Client +} + +var _ Writer = (*ClientWriter)(nil) + +// NewClientWriter returns a Writer backed by c. Every write is performed with +// whatever credentials c carries. +func NewClientWriter(c client.Client) *ClientWriter { + return &ClientWriter{Client: c} +} + +// DryRunCreate copies the workload before handing it over: a dry run still +// returns a populated object, and the caller's copy is what the plan token is +// computed over, so it must come back unchanged. +func (w *ClientWriter) DryRunCreate(ctx context.Context, workload *computev1alpha.Workload) error { + return w.Client.Create(ctx, workload.DeepCopy(), client.DryRunAll) +} + +func (w *ClientWriter) DryRunUpdate(ctx context.Context, workload *computev1alpha.Workload) error { + return w.Client.Update(ctx, workload.DeepCopy(), client.DryRunAll) +} + +func (w *ClientWriter) Create(ctx context.Context, workload *computev1alpha.Workload) error { + if err := w.Client.Create(ctx, workload); err != nil { + return fmt.Errorf("creating workload %s: %w", workload.Name, err) + } + return nil +} + +func (w *ClientWriter) Update(ctx context.Context, workload *computev1alpha.Workload) error { + if err := w.Client.Update(ctx, workload); err != nil { + return fmt.Errorf("updating workload %s: %w", workload.Name, err) + } + return nil +} + +func (w *ClientWriter) GetNetwork( + ctx context.Context, namespace, name string, +) (*networkingv1alpha.Network, error) { + var n networkingv1alpha.Network + key := client.ObjectKey{Namespace: namespace, Name: name} + if err := w.Client.Get(ctx, key, &n); err != nil { + return nil, fmt.Errorf("getting network %s: %w", name, err) + } + return &n, nil +} + +func (w *ClientWriter) CreateNetwork(ctx context.Context, n *networkingv1alpha.Network) error { + if err := w.Client.Create(ctx, n); err != nil { + return fmt.Errorf("creating network %s: %w", n.Name, err) + } + return nil +} + +// ---------------------------------------------------------------- I/O types + +// RenderPlacement is one group of cities scaled together. +type RenderPlacement struct { + Name string `json:"name,omitempty" jsonschema:"Placement name, a DNS label. Defaults to \"default\"."` + CityCodes []string `json:"cityCodes" jsonschema:"City codes this placement runs in, e.g. [\"DFW\"]. Only codes compute_locations_list returned can ever be satisfied."` + MinReplicas int32 `json:"minReplicas,omitempty" jsonschema:"Instances to run per placement. At least 1 — there is no scaling to zero — and at most 1000. Defaults to 1."` +} + +// RenderPort is a named port the workload serves. +type RenderPort struct { + Name string `json:"name" jsonschema:"Port name, e.g. \"http\". At most 15 characters, and must contain a letter."` + Port int32 `json:"port" jsonschema:"Port number, 1 to 65535."` + Protocol string `json:"protocol,omitempty" jsonschema:"TCP, UDP or SCTP. Defaults to TCP."` +} + +// RenderKeyRef selects one key of a ConfigMap or Secret. +type RenderKeyRef struct { + Name string `json:"name" jsonschema:"Name of the ConfigMap or Secret, which must already exist in the project."` + Key string `json:"key" jsonschema:"Key within it."` +} + +// RenderEnvVar is one environment variable on the container. +type RenderEnvVar struct { + Name string `json:"name" jsonschema:"Variable name."` + Value string `json:"value,omitempty" jsonschema:"Literal value. Set at most one of value, configMapKeyRef, secretKeyRef."` + ConfigMapKeyRef *RenderKeyRef `json:"configMapKeyRef,omitempty" jsonschema:"Read the value from a ConfigMap key instead."` + SecretKeyRef *RenderKeyRef `json:"secretKeyRef,omitempty" jsonschema:"Read the value from a Secret key instead."` +} + +// RenderMount projects a ConfigMap or Secret into the instance's filesystem. +type RenderMount struct { + Name string `json:"name,omitempty" jsonschema:"Volume name. Defaults to the ConfigMap or Secret name."` + ConfigMap string `json:"configMap,omitempty" jsonschema:"Name of the ConfigMap to mount. Set exactly one of configMap or secret."` + Secret string `json:"secret,omitempty" jsonschema:"Name of the Secret to mount. Set exactly one of configMap or secret."` + MountPath string `json:"mountPath" jsonschema:"Absolute path the contents appear at inside the instance."` +} + +// RenderVM asks for a virtual machine rather than a container. +type RenderVM struct { + SSHKeys []string `json:"sshKeys" jsonschema:"Keys authorized to log in, each \"username:ssh-public-key\". At least one — a machine with no key is unreachable and is rejected."` + BootImage string `json:"bootImage,omitempty" jsonschema:"Disk image the machine boots. Defaults to datumcloud/ubuntu-2204-lts, currently the only one accepted."` +} + +// WorkloadRenderInput is the flat description a manifest is rendered from. It +// mirrors workloadspec.Input field for field, so the manifest a model renders +// and the one `datumctl compute deploy` writes cannot drift apart. +type WorkloadRenderInput struct { + Name string `json:"name" jsonschema:"Workload name, a DNS label, e.g. \"api-backend\". Cannot be changed later."` + Image string `json:"image,omitempty" jsonschema:"Fully qualified container image, e.g. \"ghcr.io/acme/api:1.4.2\". Required unless vm is set. A bare name is the most common cause of ImageUnavailable afterwards."` + InstanceType string `json:"instanceType,omitempty" jsonschema:"Instance type from compute_instance_types_list. Defaults to the only one accepted today."` + Network string `json:"network,omitempty" jsonschema:"Network the instance attaches to. Defaults to \"default\"."` + Placements []RenderPlacement `json:"placements" jsonschema:"Where instances run and how many. At least one is required."` + Ports []RenderPort `json:"ports,omitempty" jsonschema:"Named ports the workload serves. Each is also opened to the internet, since a port nothing can reach is not useful."` + Env []RenderEnvVar `json:"env,omitempty" jsonschema:"Environment variables on the container. Not accepted for a virtual machine."` + ConfigMounts []RenderMount `json:"configMounts,omitempty" jsonschema:"ConfigMaps and Secrets projected into the instance's filesystem."` + PublicIPv4 bool `json:"publicIPv4,omitempty" jsonschema:"Ask for a public IPv4 address. Settled at create: it cannot be added or removed later, so ask before rendering rather than defaulting it."` + Labels map[string]string `json:"labels,omitempty" jsonschema:"Labels applied to the workload and to every instance it creates."` + VM *RenderVM `json:"vm,omitempty" jsonschema:"Render a virtual machine instead of a container. Only when the person needs a whole operating system to log into."` +} + +// WorkloadRenderOutput is the manifest and what rendering it settled. +type WorkloadRenderOutput struct { + // Manifest is the complete Workload, as YAML. + Manifest string `json:"manifest"` + // Notes are the decisions this manifest fixes for the life of the workload + // and the defaults that were filled in. Worth reading out: several of them + // cannot be changed after the first apply. + Notes []string `json:"notes,omitempty"` +} + +// FieldError is one rejection, with the field it names. +type FieldError struct { + // Field is the path the server named, e.g. + // "spec.template.spec.volumes[1].name". Empty when the rejection is about + // the manifest as a whole. + Field string `json:"field,omitempty"` + // Message is the server's own wording, kept verbatim so it can be quoted. + Message string `json:"message"` +} + +// WorkloadValidateInput is one manifest to check. +type WorkloadValidateInput struct { + Manifest string `json:"manifest" jsonschema:"A complete Workload manifest as YAML, normally the one compute_workload_render returned."` +} + +// WorkloadValidateOutput is the server's verdict. +type WorkloadValidateOutput struct { + Valid bool `json:"valid"` + Errors []FieldError `json:"errors,omitempty"` + // Exists reports whether a workload of this name is already there, which + // decides whether applying would create or change one. + Exists bool `json:"exists"` + // Diff is what applying would change about the existing workload. Empty + // for a create, and empty for an update that changes nothing this diff + // covers. + Diff []string `json:"diff,omitempty"` +} + +// NetworkPlan says what the plan found out about the network the interface +// names. +type NetworkPlan struct { + Name string `json:"name"` + Exists bool `json:"exists"` + // WillCreate reports that applying this plan creates the network too. Say + // so when showing the plan: it is a second object being created. + WillCreate bool `json:"willCreate"` +} + +// WorkloadPlanInput is the manifest to plan. +type WorkloadPlanInput struct { + Manifest string `json:"manifest" jsonschema:"A complete Workload manifest as YAML, normally the one compute_workload_render returned and compute_workload_validate accepted."` +} + +// WorkloadPlanOutput is everything the person needs to see before agreeing, +// plus the token that binds their agreement to this manifest. +type WorkloadPlanOutput struct { + // Valid is false when the manifest was rejected. There is no token in that + // case, and nothing can be applied until it is fixed. + Valid bool `json:"valid"` + Errors []FieldError `json:"errors,omitempty"` + // Manifest is the canonical form of what was planned. This exact manifest is + // what the token covers and what compute_workload_apply has to be given, and + // it is the one to show the person who asked. + Manifest string `json:"manifest,omitempty"` + // Action is "create" or "update". + Action string `json:"action,omitempty"` + Diff []string `json:"diff,omitempty"` + Network *NetworkPlan `json:"network,omitempty"` + // PlanToken authorizes applying this manifest, and nothing else. + PlanToken string `json:"planToken,omitempty"` + // ExpiresAt is when the token stops being accepted, in RFC 3339. + ExpiresAt string `json:"expiresAt,omitempty"` +} + +// WorkloadApplyInput is the plan, handed back whole. +type WorkloadApplyInput struct { + Manifest string `json:"manifest" jsonschema:"The manifest compute_workload_plan returned, verbatim. One changed character and the token stops matching and nothing is created."` + PlanToken string `json:"planToken" jsonschema:"The token compute_workload_plan returned for that manifest. Only call this after the person who asked has seen the manifest and the diff and said yes."` +} + +// NetworkApplied reports whether the network had to be created alongside the +// workload. +type NetworkApplied struct { + Name string `json:"name,omitempty"` + Created bool `json:"created"` +} + +// WorkloadApplyOutput is what was done. +type WorkloadApplyOutput struct { + Action string `json:"action"` + Workload string `json:"workload"` + Network NetworkApplied `json:"network"` + // Next is the step that turns an accepted request into a running workload, + // which are not the same thing. + Next string `json:"next"` +} + +// ------------------------------------------------------------ registration + +// RegisterWriteTools adds the render, validate, plan and apply tools. Called +// by RegisterTools; separate so the two mutating tools can be read on their +// own, which is what a review of this surface wants to look at. +func RegisterWriteTools(s *mcp.Server, deps DepsFor) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolWorkloadRender, + Title: "Render a workload manifest", + Description: "Turn a short description of a deployment — name, image, which cities, how many — into a " + + "complete Workload manifest, and report what rendering it settled. Nothing is read and nothing " + + "is changed, so render as often as it takes to get the manifest right. Read the manifest that " + + "comes back rather than assuming it says what was asked for, and read the notes: they name the " + + "choices that cannot be changed once the workload exists, the interface's address families and " + + "a public IPv4 address among them. Gather the inputs from the person rather than inventing " + + "them, and take city codes from compute_locations_list and the instance type from compute_instance_types_list. " + + "Load the workload-create skill before using this. Writes nothing.", + }, workloadRender(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolWorkloadValidate, + Title: "Validate a workload manifest", + Description: "Ask the server whether a manifest would be accepted, without creating anything. Returns " + + "the exact rejection with the field path it names, whether a workload of that name already " + + "exists, and — when it does — what applying this manifest would change about it. Every " + + "rejection reported here is one that would otherwise arrive after the person was told the " + + "workload was written correctly. Quote the field path verbatim and say in plain words what it " + + "means, fix the manifest, render it again, and validate again. Never plan or apply a manifest " + + "that failed validation. Writes nothing.", + }, workloadValidate(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolWorkloadPlan, + Title: "Plan a workload change", + Description: "Settle everything that has to be true before a workload can be created or changed, and " + + "mint the token that authorizes exactly that. Validates the manifest, resolves whether this is " + + "a create or an update, reports what would change, says whether the network the interface names " + + "is already there or would be created alongside the workload, and returns a canonical manifest " + + "with a plan token that is a hash of it. The manifest in this output is the one the token " + + "covers: show that manifest in full, and the diff, to the person who asked, say what will exist " + + "and where and how many, and get an explicit yes before calling compute_workload_apply. A question " + + "about the plan is not a yes. If anything changes, plan again — a token minted for the earlier " + + "manifest will be refused, and applying it because it was close is exactly what this prevents. " + + "Tokens are good for 15 minutes. Planning by itself creates and changes nothing.", + }, workloadPlan(deps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolWorkloadApply, + Title: "Apply a planned workload", + Description: "Create or change the workload a plan token was minted for, and nothing else. Takes the " + + "manifest compute_workload_plan returned and that plan's token: the token is a hash of that manifest, " + + "the project, and the version of the workload the plan saw, so a manifest edited after the " + + "plan, a token from another project, or a workload someone else changed in the meantime is " + + "refused rather than applied. Call this only once the person who asked has been shown the " + + "plan's manifest and diff and has said yes; if they asked for a change instead, go back and " + + "plan again. When the plan said the network was missing, it is created first. A workload being " + + "created means the request was accepted, not that anything is running — call compute_workload_diagnose " + + "next and say that plainly rather than reporting a deployment.", + }, workloadApply(deps)) +} + +// ---------------------------------------------------------------- handlers + +func workloadRender(deps DepsFor) mcp.ToolHandlerFor[WorkloadRenderInput, WorkloadRenderOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, in WorkloadRenderInput, + ) (*mcp.CallToolResult, WorkloadRenderOutput, error) { + // Rendering reads nothing, but an unauthenticated caller must not be + // able to use it as a probe, the same rule compute_reason_explain follows. + if _, err := deps(ctx); err != nil { + return nil, WorkloadRenderOutput{}, err + } + + spec := toSpecInput(in) + workload, err := workloadspec.Render(spec) + if err != nil { + return nil, WorkloadRenderOutput{}, err + } + manifest, err := workloadspec.MarshalYAML(workload) + if err != nil { + return nil, WorkloadRenderOutput{}, err + } + + return nil, WorkloadRenderOutput{ + Manifest: string(manifest), + Notes: renderNotes(spec), + }, nil + } +} + +func workloadValidate(deps DepsFor) mcp.ToolHandlerFor[WorkloadValidateInput, WorkloadValidateOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, in WorkloadValidateInput, + ) (*mcp.CallToolResult, WorkloadValidateOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, WorkloadValidateOutput{}, err + } + + checked, err := check(ctx, d, in.Manifest) + if err != nil { + return nil, WorkloadValidateOutput{}, err + } + return nil, WorkloadValidateOutput{ + Valid: checked.errors == nil, + Errors: checked.errors, + Exists: checked.existing != nil, + Diff: checked.diff, + }, nil + } +} + +func workloadPlan(deps DepsFor) mcp.ToolHandlerFor[WorkloadPlanInput, WorkloadPlanOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, in WorkloadPlanInput, + ) (*mcp.CallToolResult, WorkloadPlanOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + // Resolved before any work, so a server that cannot mint a token says + // so rather than validating a manifest it could never let through. + key, err := d.planTokenKey() + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + + checked, err := check(ctx, d, in.Manifest) + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + if checked.errors != nil { + return nil, WorkloadPlanOutput{Valid: false, Errors: checked.errors}, nil + } + + network, err := planNetwork(ctx, d, checked.desired) + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + + manifest, err := workloadspec.MarshalYAML(checked.desired) + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + canonical, err := canonicalJSON(checked.desired) + if err != nil { + return nil, WorkloadPlanOutput{}, err + } + + action := actionCreate + if checked.existing != nil { + action = actionUpdate + } + expiry := time.Now().Add(planTokenTTL) + + return nil, WorkloadPlanOutput{ + Valid: true, + Manifest: string(manifest), + Action: action, + Diff: checked.diff, + Network: network, + PlanToken: mintPlanToken(key, canonical, d.Project, resourceVersionOf(checked.existing), expiry), + ExpiresAt: expiry.UTC().Format(time.RFC3339), + }, nil + } +} + +func workloadApply(deps DepsFor) mcp.ToolHandlerFor[WorkloadApplyInput, WorkloadApplyOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, in WorkloadApplyInput, + ) (*mcp.CallToolResult, WorkloadApplyOutput, error) { + d, err := deps(ctx) + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + key, err := d.planTokenKey() + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + w, err := d.writer() + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + + desired, ferr := decodeManifest(in.Manifest, d.Namespace) + if ferr != nil { + return nil, WorkloadApplyOutput{}, fmt.Errorf( + "this manifest could not be read, so nothing was created. %s: %s. Render the workload "+ + "again, plan it, and show the person who asked what came back", + ferr.Field, ferr.Message) + } + existing, err := getExisting(ctx, d, desired.Name) + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + canonical, err := canonicalJSON(desired) + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + + // The token is checked before anything is read further or written: a + // manifest nobody agreed to must not reach the server at all, not even + // as a dry run. + if err := verifyPlanToken( + key, in.PlanToken, canonical, d.Project, resourceVersionOf(existing), time.Now(), + ); err != nil { + return nil, WorkloadApplyOutput{}, err + } + + // Checked once more against the live server, because the plan may have + // been made minutes ago and quota, references and the catalogs all move + // underneath it. A rejection here writes nothing. + attempt := desired.DeepCopy() + if existing != nil { + attempt.ResourceVersion = existing.ResourceVersion + err = w.DryRunUpdate(ctx, attempt) + } else { + err = w.DryRunCreate(ctx, attempt) + } + if err != nil { + return nil, WorkloadApplyOutput{}, fmt.Errorf( + "the server rejected this workload when it was checked again just before creating it, so "+ + "nothing was created: %w. Something changed since the plan was made. Fix the manifest, "+ + "plan again, and show the person who asked what came back", err) + } + + // The network the interface names has to exist for instances to be + // published, and creating it is part of what the plan promised. + applied, err := applyNetwork(ctx, d, w, desired) + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + + action := actionCreate + if existing != nil { + action = actionUpdate + desired.ResourceVersion = existing.ResourceVersion + err = w.Update(ctx, desired) + } else { + err = w.Create(ctx, desired) + } + if err != nil { + return nil, WorkloadApplyOutput{}, err + } + + return nil, WorkloadApplyOutput{ + Action: action, + Workload: desired.Name, + Network: applied, + Next: fmt.Sprintf( + "call %s with name %q. The request was accepted, which is not the same as anything "+ + "running yet: instances appear, then start, and the first pull of a large image takes "+ + "a while", + ToolWorkloadDiagnose, desired.Name), + }, nil + } +} + +// ----------------------------------------------------------------- helpers + +// checked is the shared result of the validate step, which plan and apply both +// begin with. +type checked struct { + // desired is the manifest, normalized. Nil when errors is set. + desired *computev1alpha.Workload + // existing is the workload of that name today, or nil when there is none. + existing *computev1alpha.Workload + // errors is nil when the server accepted the manifest. + errors []FieldError + diff []string +} + +// check decodes a manifest and asks the server for its verdict, without +// persisting anything. A rejection is a result, not an error: the field paths +// are what the model has to act on. +func check(ctx context.Context, d ToolDeps, manifest string) (checked, error) { + w, err := d.writer() + if err != nil { + return checked{}, err + } + + desired, ferr := decodeManifest(manifest, d.Namespace) + if ferr != nil { + return checked{errors: []FieldError{*ferr}}, nil + } + + existing, err := getExisting(ctx, d, desired.Name) + if err != nil { + return checked{}, err + } + + attempt := desired.DeepCopy() + if existing != nil { + // An update is checked at the version that was read, so the check is + // of the change that would actually be made. + attempt.ResourceVersion = existing.ResourceVersion + err = w.DryRunUpdate(ctx, attempt) + } else { + err = w.DryRunCreate(ctx, attempt) + } + if err != nil { + return checked{existing: existing, errors: fieldErrors(err)}, nil + } + + out := checked{desired: desired, existing: existing} + if existing != nil { + out.diff = workloadspec.Diff(existing, desired) + } + return out, nil +} + +// planNetwork reports on the network the interface names. +func planNetwork(ctx context.Context, d ToolDeps, desired *computev1alpha.Workload) (*NetworkPlan, error) { + name := networkNameOf(desired) + if name == "" { + return nil, nil + } + + w, err := d.writer() + if err != nil { + return nil, err + } + if _, err := w.GetNetwork(ctx, d.Namespace, name); err != nil { + if !apierrors.IsNotFound(err) { + return nil, err + } + return &NetworkPlan{Name: name, WillCreate: true}, nil + } + return &NetworkPlan{Name: name, Exists: true}, nil +} + +// applyNetwork creates the network the interface names when it is still +// missing, mirroring what `datumctl compute deploy` does: a minimal network +// with automatic address management. +func applyNetwork( + ctx context.Context, d ToolDeps, w Writer, desired *computev1alpha.Workload, +) (NetworkApplied, error) { + name := networkNameOf(desired) + if name == "" { + return NetworkApplied{}, nil + } + + if _, err := w.GetNetwork(ctx, d.Namespace, name); err == nil { + return NetworkApplied{Name: name}, nil + } else if !apierrors.IsNotFound(err) { + return NetworkApplied{}, err + } + + network := &networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{Namespace: d.Namespace, Name: name}, + Spec: networkingv1alpha.NetworkSpec{ + IPAM: networkingv1alpha.NetworkIPAM{Mode: networkingv1alpha.NetworkIPAMModeAuto}, + }, + } + if err := w.CreateNetwork(ctx, network); err != nil { + return NetworkApplied{}, fmt.Errorf( + "the workload was not created: the network %q it attaches to is missing and could not be "+ + "created either: %w", name, err) + } + return NetworkApplied{Name: name, Created: true}, nil +} + +// networkNameOf returns the network the workload's interface attaches to. A +// rendered workload has exactly one interface; a hand-written one that has +// none is left to the server to reject. +func networkNameOf(w *computev1alpha.Workload) string { + interfaces := w.Spec.Template.Spec.NetworkInterfaces + if len(interfaces) == 0 { + return "" + } + return interfaces[0].Network.Name +} + +// getExisting returns the workload of that name, or nil when there is none. +func getExisting(ctx context.Context, d ToolDeps, name string) (*computev1alpha.Workload, error) { + if d.Reader == nil { + return nil, fmt.Errorf( + "this server was built without the ability to read the project's workloads, so this tool " + + "cannot answer. The person who asked did nothing wrong: whoever operates this server " + + "needs to configure it") + } + w, err := d.Reader.GetWorkload(ctx, d.Namespace, name) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return w, nil +} + +func resourceVersionOf(w *computev1alpha.Workload) string { + if w == nil { + return "" + } + return w.ResourceVersion +} + +// decodeManifest reads a manifest into a Workload and normalizes it: the +// namespace is forced to the project's, the type is stamped, and everything +// the server owns is dropped. What comes back is what the plan token is +// computed over, so two manifests that mean the same thing hash the same. +func decodeManifest(manifest, namespace string) (*computev1alpha.Workload, *FieldError) { + if strings.TrimSpace(manifest) == "" { + return nil, &FieldError{Field: fieldManifest, Message: "a workload manifest is required"} + } + + var w computev1alpha.Workload + // Strict, so a misspelled field is reported rather than silently dropped + // and then missing from a workload that was said to have it. + if err := sigsyaml.UnmarshalStrict([]byte(manifest), &w); err != nil { + return nil, &FieldError{ + Field: fieldManifest, + Message: fmt.Sprintf("this is not a readable Workload manifest: %v", err), + } + } + + if w.Kind != "" && w.Kind != "Workload" { + return nil, &FieldError{ + Field: "kind", + Message: fmt.Sprintf("these tools create Workloads, not %s", w.Kind), + } + } + if w.Name == "" { + return nil, &FieldError{Field: "metadata.name", Message: "a workload name is required"} + } + + w.TypeMeta = metav1.TypeMeta{ + APIVersion: computev1alpha.GroupVersion.String(), + Kind: "Workload", + } + // The project decides the namespace, never the manifest: a manifest that + // named another one would be asking to write somewhere this request does + // not reach. + w.Namespace = namespace + w.ResourceVersion = "" + w.UID = "" + w.Generation = 0 + w.CreationTimestamp = metav1.Time{} + w.ManagedFields = nil + w.Status = computev1alpha.WorkloadStatus{} + + return &w, nil +} + +// canonicalJSON renders the manifest the token is computed over. The workload +// is normalized first, and Go marshals struct fields in declaration order and +// map keys in sorted order, so the same manifest always produces the same +// bytes. +func canonicalJSON(w *computev1alpha.Workload) ([]byte, error) { + raw, err := json.Marshal(w) + if err != nil { + return nil, fmt.Errorf("normalizing the manifest: %w", err) + } + return raw, nil +} + +// fieldErrors turns a server rejection into field/message pairs. A structured +// rejection carries a cause per field; the whole message is returned alongside +// them, because it is the server's own wording and is what a person quotes +// when escalating. +func fieldErrors(err error) []FieldError { + var status *apierrors.StatusError + if !errors.As(err, &status) { + return []FieldError{{Message: err.Error()}} + } + + out := []FieldError{} + if details := status.ErrStatus.Details; details != nil { + for _, cause := range details.Causes { + out = append(out, FieldError{Field: cause.Field, Message: cause.Message}) + } + } + return append(out, FieldError{Message: status.ErrStatus.Message}) +} + +// writer returns the Writer for this call, or an error naming the wiring +// mistake, the same way discovery's does. +func (d ToolDeps) writer() (Writer, error) { + if d.Writer == nil { + return nil, fmt.Errorf( + "this server was built without the ability to create or change a workload, so this tool " + + "cannot answer. The person who asked did nothing wrong: whoever operates this server " + + "needs to configure it") + } + return d.Writer, nil +} + +// planTokenKey returns the key plan tokens are minted and checked with, or an +// error. Both halves are refused without one: a server that cannot check a +// token must not issue something that looks like one. +func (d ToolDeps) planTokenKey() ([]byte, error) { + if len(d.PlanTokenKey) == 0 { + return nil, fmt.Errorf( + "this server was built without a plan token key, so it cannot authorize creating or " + + "changing a workload. The person who asked did nothing wrong: whoever operates this " + + "server needs to configure it") + } + if d.Project == "" { + return nil, fmt.Errorf( + "this request did not say which project it is for, so a plan cannot be bound to one. The " + + "person who asked did nothing wrong: whoever operates the client that called this tool " + + "needs to configure it") + } + return d.PlanTokenKey, nil +} + +// ------------------------------------------------------------- plan tokens + +// mintPlanToken returns the token that authorizes applying exactly this +// manifest, in this project, against this version of the workload, until +// expiry. The form is base64(HMAC-SHA256(payload)) + "." + expiry in seconds: +// the expiry travels in the clear because it is also covered by the hash, so +// moving it invalidates the token. +func mintPlanToken(key, canonical []byte, project, resourceVersion string, expiry time.Time) string { + unix := expiry.Unix() + mac := planTokenMAC(key, canonical, project, resourceVersion, unix) + return base64.RawURLEncoding.EncodeToString(mac) + "." + strconv.FormatInt(unix, 10) +} + +// verifyPlanToken refuses anything that is not a token minted for exactly this +// manifest, project and workload version, and still inside its window. The +// refusals are what a person reads, so each one says what happened, that +// nothing was created, and what to do instead. +func verifyPlanToken( + key []byte, token string, canonical []byte, project, resourceVersion string, now time.Time, +) error { + encoded, expiryText, found := strings.Cut(token, ".") + if !found { + return fmt.Errorf( + "this is not a plan token in the form %s issues, so nothing was created. Call %s with the "+ + "manifest to apply and use the token it returns", + ToolWorkloadPlan, ToolWorkloadPlan) + } + unix, err := strconv.ParseInt(expiryText, 10, 64) + if err != nil { + return fmt.Errorf( + "this plan token does not carry a readable expiry, so nothing was created. Call %s again "+ + "and use the token it returns", ToolWorkloadPlan) + } + if expiry := time.Unix(unix, 0); now.After(expiry) { + return fmt.Errorf( + "this plan expired at %s and nothing was created. A plan is good for %s, so that what is "+ + "created is still what was agreed to. Call %s again, show the person who asked the "+ + "manifest and the diff it returns, and ask again before applying", + expiry.UTC().Format(time.RFC3339), planTokenTTL, ToolWorkloadPlan) + } + + presented, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + presented = nil + } + if !hmac.Equal(presented, planTokenMAC(key, canonical, project, resourceVersion, unix)) { + return fmt.Errorf( + "this plan token does not cover the manifest it was given, so nothing was created. That "+ + "happens when the manifest changed after the plan was made — one character is enough — "+ + "when the plan was made for a different project, or when the workload was changed by "+ + "someone else in the meantime. This refusal is the check working. Call %s again with "+ + "the manifest to apply, show the person who asked the manifest and the diff it returns, "+ + "and ask again", ToolWorkloadPlan) + } + return nil +} + +// planTokenMAC hashes the manifest together with everything the plan assumed, +// each part separated by a newline so no two payloads can be built out of the +// same bytes. +func planTokenMAC(key, canonical []byte, project, resourceVersion string, expiryUnix int64) []byte { + var payload bytes.Buffer + payload.Write(canonical) + payload.WriteByte('\n') + payload.WriteString(project) + payload.WriteByte('\n') + payload.WriteString(resourceVersion) + payload.WriteByte('\n') + payload.WriteString(strconv.FormatInt(expiryUnix, 10)) + + mac := hmac.New(sha256.New, key) + mac.Write(payload.Bytes()) + return mac.Sum(nil) +} + +// ---------------------------------------------------------------- rendering + +// toSpecInput converts the tool's input to workloadspec's. A straight mapping, +// kept explicit so the tool schema can be worded for a model without that +// wording leaking into the package the CLI also renders through. +func toSpecInput(in WorkloadRenderInput) workloadspec.Input { + out := workloadspec.Input{ + Name: in.Name, + Image: in.Image, + InstanceType: in.InstanceType, + Network: in.Network, + PublicIPv4: in.PublicIPv4, + Labels: in.Labels, + } + + for _, p := range in.Placements { + out.Placements = append(out.Placements, workloadspec.Placement{ + Name: p.Name, + CityCodes: p.CityCodes, + MinReplicas: p.MinReplicas, + }) + } + for _, p := range in.Ports { + out.Ports = append(out.Ports, workloadspec.Port{ + Name: p.Name, + Port: p.Port, + Protocol: corev1.Protocol(p.Protocol), + }) + } + for _, e := range in.Env { + out.Env = append(out.Env, workloadspec.EnvVar{ + Name: e.Name, + Value: e.Value, + ConfigMapKeyRef: toKeyRef(e.ConfigMapKeyRef), + SecretKeyRef: toKeyRef(e.SecretKeyRef), + }) + } + for _, m := range in.ConfigMounts { + out.ConfigMounts = append(out.ConfigMounts, workloadspec.Mount{ + Name: m.Name, + ConfigMap: m.ConfigMap, + Secret: m.Secret, + MountPath: m.MountPath, + }) + } + if in.VM != nil { + out.VM = &workloadspec.VMInput{ + SSHKeys: in.VM.SSHKeys, + BootImage: in.VM.BootImage, + } + } + + return out +} + +func toKeyRef(ref *RenderKeyRef) *workloadspec.KeyRef { + if ref == nil { + return nil + } + return &workloadspec.KeyRef{Name: ref.Name, Key: ref.Key} +} + +// renderNotes says what this manifest settled that a later render cannot +// correct, and which values were filled in for a caller who did not name them. +// +// It is written from the input as given, before defaults are applied, so +// "defaulted to" means the person did not choose it — which is the thing they +// need to be asked about while the workload can still be changed. +func renderNotes(in workloadspec.Input) []string { + notes := []string{ + "The instance's single network interface is settled by this manifest and cannot be changed " + + "once the workload exists: its name, the address families it carries, any extra addresses, " + + "and what becomes of those addresses when an instance goes away. Getting one of them wrong " + + "means creating a new workload, not editing this one.", + } + + if in.PublicIPv4 { + notes = append(notes, "A public IPv4 address was asked for, so the interface carries both IPv4 "+ + "and IPv6. Neither the address nor the families can be removed later.") + } else { + notes = append(notes, "The interface carries IPv6 only, which is the default. If this workload "+ + "has to answer on IPv4, say so before it is applied: IPv4 cannot be added afterwards.") + } + + notes = append(notes, "Addresses are given back when an instance goes away. Keeping one — an "+ + "address published in DNS, or allowed through someone's firewall — means editing this manifest "+ + "before the first apply.") + + if in.InstanceType == "" { + notes = append(notes, fmt.Sprintf( + "No instance type was given, so every instance is %s. Per-container CPU and memory are not "+ + "accepted: the instance type is what decides the size.", workloadspec.DefaultInstanceType)) + } + if in.Network == "" { + notes = append(notes, fmt.Sprintf( + "No network was named, so the interface attaches to %q. If the project does not have "+ + "one, %s says so and %s creates it alongside the workload.", + workloadspec.DefaultNetwork, ToolWorkloadPlan, ToolWorkloadApply)) + } + for _, p := range in.Placements { + if p.Name == "" { + notes = append(notes, fmt.Sprintf("A placement was not named, so it is called %q.", + workloadspec.DefaultPlacementName)) + } + if p.MinReplicas == 0 { + notes = append(notes, fmt.Sprintf( + "Placement %q did not say how many instances to run, so it runs %d. There is no "+ + "scaling to zero.", placementName(p), workloadspec.DefaultMinReplicas)) + } + } + if in.VM != nil && in.VM.BootImage == "" { + notes = append(notes, fmt.Sprintf( + "No boot image was given, so the machine boots %s, currently the only one accepted.", + workloadspec.DefaultBootImage)) + } + + return notes +} + +func placementName(p workloadspec.Placement) string { + if p.Name == "" { + return workloadspec.DefaultPlacementName + } + return p.Name +} diff --git a/internal/agent/write_test.go b/internal/agent/write_test.go new file mode 100644 index 00000000..39e1a8c1 --- /dev/null +++ b/internal/agent/write_test.go @@ -0,0 +1,945 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/workloadspec" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + // testProject is the project a plan is bound to. Plan tokens are only good + // in the project they were minted for, so the tests need two of them. + testProject = "acme-prod" + otherProject = "acme-staging" + + testImage = "ghcr.io/acme/api:1.4.2" + // deployedImage is what the fixture workload is already running, so an + // update has something to change. + deployedImage = "ghcr.io/acme/api:1.0.0" +) + +// testPlanKey stands in for PLAN_TOKEN_KEY. Long enough to be a real key, and +// obviously not one that was ever deployed. +var testPlanKey = []byte("test-plan-token-key-32-bytes-long!!") + +// fakeWriter records what would have reached the server, and can be told to +// reject a dry run the way the server itself would. The recording is the point: +// the strongest thing these tests assert is that nothing was written. +type fakeWriter struct { + // dryRunErr, when set, is what both dry-run checks return. + dryRunErr error + // writeErr, when set, is what Create and Update return. + writeErr error + // networks that already exist, by name. + networks map[string]bool + // networkGetErr, when set, fails the network read with something other + // than a not-found. + networkGetErr error + + dryRuns int + created []computev1alpha.Workload + updated []computev1alpha.Workload + createdNetworks []networkingv1alpha.Network +} + +var _ Writer = (*fakeWriter)(nil) + +func (w *fakeWriter) DryRunCreate(_ context.Context, _ *computev1alpha.Workload) error { + w.dryRuns++ + return w.dryRunErr +} + +func (w *fakeWriter) DryRunUpdate(_ context.Context, _ *computev1alpha.Workload) error { + w.dryRuns++ + return w.dryRunErr +} + +func (w *fakeWriter) Create(_ context.Context, workload *computev1alpha.Workload) error { + if w.writeErr != nil { + return w.writeErr + } + w.created = append(w.created, *workload.DeepCopy()) + return nil +} + +func (w *fakeWriter) Update(_ context.Context, workload *computev1alpha.Workload) error { + if w.writeErr != nil { + return w.writeErr + } + w.updated = append(w.updated, *workload.DeepCopy()) + return nil +} + +func (w *fakeWriter) GetNetwork( + _ context.Context, namespace, name string, +) (*networkingv1alpha.Network, error) { + if w.networkGetErr != nil { + return nil, w.networkGetErr + } + if !w.networks[name] { + return nil, apierrors.NewNotFound( + schema.GroupResource{Group: networkingv1alpha.GroupVersion.Group, Resource: "networks"}, name) + } + return &networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + }, nil +} + +func (w *fakeWriter) CreateNetwork(_ context.Context, n *networkingv1alpha.Network) error { + if w.networks == nil { + w.networks = map[string]bool{} + } + w.networks[n.Name] = true + w.createdNetworks = append(w.createdNetworks, *n.DeepCopy()) + return nil +} + +// wrote reports whether anything at all reached the server. +func (w *fakeWriter) wrote() bool { + return len(w.created) > 0 || len(w.updated) > 0 || len(w.createdNetworks) > 0 +} + +// writeReader answers "is this workload already there?" the way the API server +// does — a missing one is a not-found, not an error — because that distinction +// is what decides create versus update. +type writeReader struct { + workloads map[string]*computev1alpha.Workload + err error +} + +var _ Reader = (*writeReader)(nil) + +func (r *writeReader) ListWorkloads(context.Context, string) ([]computev1alpha.Workload, error) { + return nil, r.err +} + +func (r *writeReader) GetWorkload(_ context.Context, _, name string) (*computev1alpha.Workload, error) { + if r.err != nil { + return nil, r.err + } + if w, ok := r.workloads[name]; ok { + return w.DeepCopy(), nil + } + return nil, apierrors.NewNotFound( + schema.GroupResource{Group: computev1alpha.GroupVersion.Group, Resource: "workloads"}, name) +} + +func (r *writeReader) ListDeployments( + context.Context, string, string, +) ([]computev1alpha.WorkloadDeployment, error) { + return nil, r.err +} + +func (r *writeReader) ListInstances(context.Context, string, string) ([]computev1alpha.Instance, error) { + return nil, r.err +} + +// existingWorkload is the fixture workload as it is already deployed, at a +// known resource version, so a plan can be made against it and then +// invalidated by moving it. +func existingWorkload(image, resourceVersion string) *computev1alpha.Workload { + w, err := workloadspec.Render(workloadspec.Input{ + Name: wlAPIBackend, + Image: image, + Placements: []workloadspec.Placement{ + {CityCodes: []string{cityDFW}, MinReplicas: 1}, + }, + }) + if err != nil { + panic(err) + } + w.ResourceVersion = resourceVersion + return w +} + +// writeDeps supplies everything the write path needs, in testProject. +func writeDeps(r Reader, w Writer) DepsFor { + return writeDepsFor(testProject, r, w) +} + +func writeDepsFor(project string, r Reader, w Writer) DepsFor { + return func(context.Context) (ToolDeps, error) { + return ToolDeps{ + Reader: r, + Writer: w, + Namespace: testNamespace, + Project: project, + PlanTokenKey: testPlanKey, + }, nil + } +} + +// renderInput is the everyday case: one container, one city, one port. +func renderInput() WorkloadRenderInput { + return WorkloadRenderInput{ + Name: wlAPIBackend, + Image: testImage, + Placements: []RenderPlacement{{CityCodes: []string{cityDFW}, MinReplicas: 2}}, + Ports: []RenderPort{{Name: "http", Port: 8080}}, + } +} + +func mustRender(t *testing.T, deps DepsFor, in WorkloadRenderInput) string { + t.Helper() + _, out, err := workloadRender(deps)(context.Background(), nil, in) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + return out.Manifest +} + +func mustPlan(t *testing.T, deps DepsFor, manifest string) WorkloadPlanOutput { + t.Helper() + _, out, err := workloadPlan(deps)(context.Background(), nil, WorkloadPlanInput{Manifest: manifest}) + if err != nil { + t.Fatalf("compute_workload_plan: %v", err) + } + if !out.Valid { + t.Fatalf("compute_workload_plan rejected the manifest: %+v", out.Errors) + } + return out +} + +// rejection is a server rejection shaped the way the API server sends one: a +// message, and a cause naming the field. Parsing it back into field/message +// pairs is what lets the model quote the field path. +func rejection(field, message string) error { + return &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: 422, + Reason: metav1.StatusReasonInvalid, + Message: "Workload.compute.datumapis.com \"api-backend\" is invalid: " + field + ": " + message, + Details: &metav1.StatusDetails{ + Causes: []metav1.StatusCause{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field, + Message: message, + }}, + }, + }} +} + +// ------------------------------------------------------------------ render + +// TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled: the manifest is only +// half the answer. The notes carry the decisions that cannot be corrected by a +// later render, and a model that does not read them out lets a person agree to +// something they would have to recreate the workload to change. +func TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled(t *testing.T) { + deps := writeDeps(&writeReader{}, &fakeWriter{}) + + _, out, err := workloadRender(deps)(context.Background(), nil, renderInput()) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + + for _, want := range []string{ + "kind: Workload", + "name: " + wlAPIBackend, + testImage, + "minReplicas: 2", + "- " + cityDFW, + } { + if !strings.Contains(out.Manifest, want) { + t.Errorf("manifest is missing %q:\n%s", want, out.Manifest) + } + } + // Rendering reaches nothing, so the manifest has to be readable straight + // back into the workload the plan token would be computed over. + if _, ferr := decodeManifest(out.Manifest, testNamespace); ferr != nil { + t.Errorf("the rendered manifest does not read back: %+v", ferr) + } + + notes := strings.Join(out.Notes, "\n") + // The interface is settled at create, and the instance type and network + // were defaulted rather than chosen — both are things to say out loud + // while the workload can still be changed. + for _, want := range []string{ + "cannot be changed once the workload exists", + "IPv6 only", + workloadspec.DefaultInstanceType, + "\"" + workloadspec.DefaultNetwork + "\"", + } { + if !strings.Contains(notes, want) { + t.Errorf("notes do not mention %q:\n%s", want, notes) + } + } +} + +// TestWorkloadRenderReportsAPublicAddressAsFinal: asking for IPv4 fixes the +// address families for the life of the workload, so the note has to change +// with the input rather than always saying the same thing. +func TestWorkloadRenderReportsAPublicAddressAsFinal(t *testing.T) { + deps := writeDeps(&writeReader{}, &fakeWriter{}) + in := renderInput() + in.PublicIPv4 = true + + _, out, err := workloadRender(deps)(context.Background(), nil, in) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + notes := strings.Join(out.Notes, "\n") + if !strings.Contains(notes, "public IPv4 address was asked for") { + t.Errorf("notes do not report the public address as settled:\n%s", notes) + } + if strings.Contains(notes, "IPv6 only") { + t.Errorf("notes still claim IPv6 only after IPv4 was asked for:\n%s", notes) + } +} + +// TestWorkloadRenderRefusesAnIncompleteInput: a missing image is the caller's +// to supply, and rendering something plausible around a name nobody pushed is +// the failure this prevents. +func TestWorkloadRenderRefusesAnIncompleteInput(t *testing.T) { + deps := writeDeps(&writeReader{}, &fakeWriter{}) + in := renderInput() + in.Image = "" + + if _, _, err := workloadRender(deps)(context.Background(), nil, in); err == nil { + t.Error("compute_workload_render accepted an input with no image") + } +} + +// ---------------------------------------------------------------- validate + +// TestWorkloadValidateReportsARejectionAsFieldErrors: the server's answer is +// the whole value of this tool, so the field path it named has to survive as a +// field path rather than being flattened into prose. +func TestWorkloadValidateReportsARejectionAsFieldErrors(t *testing.T) { + const field = "spec.template.spec.volumes[1].name" + writer := &fakeWriter{dryRunErr: rejection(field, "volume must be attached at least 1 time")} + deps := writeDeps(&writeReader{}, writer) + manifest := mustRender(t, deps, renderInput()) + + _, out, err := workloadValidate(deps)(context.Background(), nil, + WorkloadValidateInput{Manifest: manifest}) + if err != nil { + t.Fatalf("compute_workload_validate: %v", err) + } + + if out.Valid { + t.Fatal("valid = true after the server rejected the manifest") + } + if out.Exists { + t.Error("exists = true for a workload that is not there") + } + fields := make([]string, 0, len(out.Errors)) + messages := make([]string, 0, len(out.Errors)) + for _, e := range out.Errors { + fields = append(fields, e.Field) + messages = append(messages, e.Message) + } + if !contains(fields, field) { + t.Errorf("errors do not name the field the server named: %+v", out.Errors) + } + // The whole message travels too: it is what a person quotes when the field + // path alone does not tell them what to change. + if !strings.Contains(strings.Join(messages, "\n"), "is invalid") { + t.Errorf("errors dropped the server's own message: %+v", out.Errors) + } + if writer.wrote() { + t.Error("compute_workload_validate wrote something") + } +} + +// TestWorkloadValidateReportsAnExistingWorkloadAsADiff: validate is where the +// model learns it is about to change something rather than create it, and the +// diff is what the person has to be shown. +func TestWorkloadValidateReportsAnExistingWorkloadAsADiff(t *testing.T) { + reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ + wlAPIBackend: existingWorkload(deployedImage, "7"), + }} + writer := &fakeWriter{} + deps := writeDeps(reader, writer) + manifest := mustRender(t, deps, renderInput()) + + _, out, err := workloadValidate(deps)(context.Background(), nil, + WorkloadValidateInput{Manifest: manifest}) + if err != nil { + t.Fatalf("compute_workload_validate: %v", err) + } + + if !out.Valid || !out.Exists { + t.Fatalf("valid = %v, exists = %v; want both true", out.Valid, out.Exists) + } + diff := strings.Join(out.Diff, "\n") + if !strings.Contains(diff, testImage) { + t.Errorf("diff does not report the image change: %q", diff) + } + if !strings.Contains(diff, "min replicas: 1 → 2") { + t.Errorf("diff does not report the replica change: %q", diff) + } + if writer.wrote() { + t.Error("compute_workload_validate wrote something") + } +} + +// TestWorkloadValidateRejectsAnUnreadableManifest: a manifest the model +// invented has to come back as something it can fix, not as a tool failure. +func TestWorkloadValidateRejectsAnUnreadableManifest(t *testing.T) { + deps := writeDeps(&writeReader{}, &fakeWriter{}) + + for name, manifest := range map[string]string{ + "empty": "", + "not yaml": "this is not: a: manifest:", + "no name": "apiVersion: compute.datumapis.com/v1alpha\nkind: Workload\nspec: {}\n", + "another kind": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config\n", + "typo": "kind: Workload\nmetadata:\n name: api\nspce: {}\n", + } { + t.Run(name, func(t *testing.T) { + _, out, err := workloadValidate(deps)(context.Background(), nil, + WorkloadValidateInput{Manifest: manifest}) + if err != nil { + t.Fatalf("compute_workload_validate: %v", err) + } + if out.Valid { + t.Errorf("valid = true for %s", name) + } + if len(out.Errors) == 0 { + t.Error("no errors reported, so there is nothing to fix") + } + }) + } +} + +// -------------------------------------------------------------------- plan + +// TestWorkloadPlanMintsATokenAndReportsAMissingNetwork: the network is a second +// object the apply would create, and a person agreeing to a workload has not +// agreed to that unless the plan says so. +func TestWorkloadPlanMintsATokenAndReportsAMissingNetwork(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + manifest := mustRender(t, deps, renderInput()) + + out := mustPlan(t, deps, manifest) + + if out.Action != actionCreate { + t.Errorf("action = %q, want %q", out.Action, actionCreate) + } + if out.Network == nil { + t.Fatal("plan did not report on the network") + } + if out.Network.Name != workloadspec.DefaultNetwork || out.Network.Exists || !out.Network.WillCreate { + t.Errorf("network = %+v, want the default network reported as missing and to be created", *out.Network) + } + if out.PlanToken == "" { + t.Fatal("plan minted no token") + } + // The manifest in the output is the one the token covers, which is why the + // description tells the model to show that one and not its own draft. + if _, ferr := decodeManifest(out.Manifest, testNamespace); ferr != nil { + t.Errorf("the planned manifest does not read back: %+v", ferr) + } + expires, err := time.Parse(time.RFC3339, out.ExpiresAt) + if err != nil { + t.Fatalf("expiresAt = %q, want RFC 3339: %v", out.ExpiresAt, err) + } + if until := time.Until(expires); until <= 0 || until > planTokenTTL { + t.Errorf("token expires in %s, want inside %s", until, planTokenTTL) + } + if writer.wrote() { + t.Error("compute_workload_plan wrote something") + } +} + +// TestWorkloadPlanReportsANetworkThatIsAlreadyThere is the other half: nothing +// extra is created, and the plan must not say it would be. +func TestWorkloadPlanReportsANetworkThatIsAlreadyThere(t *testing.T) { + writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} + deps := writeDeps(&writeReader{}, writer) + + out := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + if out.Network == nil || !out.Network.Exists || out.Network.WillCreate { + t.Errorf("network = %+v, want it reported as already there", out.Network) + } +} + +// TestWorkloadPlanMintsNoTokenForARejectedManifest: a token is authority to +// write. A manifest the server would refuse must never carry one, or a later +// apply spends it on a rejection. +func TestWorkloadPlanMintsNoTokenForARejectedManifest(t *testing.T) { + writer := &fakeWriter{dryRunErr: rejection("spec.template.spec.runtime.resources.instanceType", + "Unsupported value: \"datumcloud/d1-huge-64\"")} + deps := writeDeps(&writeReader{}, writer) + manifest := mustRender(t, deps, renderInput()) + + _, out, err := workloadPlan(deps)(context.Background(), nil, WorkloadPlanInput{Manifest: manifest}) + if err != nil { + t.Fatalf("compute_workload_plan: %v", err) + } + if out.Valid { + t.Fatal("valid = true after the server rejected the manifest") + } + if out.PlanToken != "" { + t.Error("plan minted a token for a manifest the server rejected") + } + if len(out.Errors) == 0 { + t.Error("no errors reported, so there is nothing to fix") + } +} + +// TestWorkloadPlanResolvesAnUpdate: the same manifest is a create or an update +// depending only on what is already there, and the model has to be told which. +func TestWorkloadPlanResolvesAnUpdate(t *testing.T) { + reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ + wlAPIBackend: existingWorkload(deployedImage, "7"), + }} + deps := writeDeps(reader, &fakeWriter{}) + + out := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + if out.Action != actionUpdate { + t.Errorf("action = %q, want %q", out.Action, actionUpdate) + } + if len(out.Diff) == 0 { + t.Error("an update was planned with no diff to show") + } +} + +// ------------------------------------------------------------------- apply + +// TestWorkloadApplyCreatesWhatWasPlanned covers the whole point of the split: +// the manifest that was planned, and only that one, reaches the server — along +// with the network the plan said would have to be created with it. +func TestWorkloadApplyCreatesWhatWasPlanned(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err != nil { + t.Fatalf("compute_workload_apply: %v", err) + } + + if out.Action != actionCreate || out.Workload != wlAPIBackend { + t.Errorf("apply reported %q of %q, want a create of %q", out.Action, out.Workload, wlAPIBackend) + } + if len(writer.created) != 1 { + t.Fatalf("created %d workloads, want exactly 1", len(writer.created)) + } + created := writer.created[0] + if created.Namespace != testNamespace { + t.Errorf("created in namespace %q, want the project's %q", created.Namespace, testNamespace) + } + if got := created.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image; got != testImage { + t.Errorf("created image = %q, want the planned %q", got, testImage) + } + // The plan said the network would be created, so it was. + if len(writer.createdNetworks) != 1 || writer.createdNetworks[0].Name != workloadspec.DefaultNetwork { + t.Fatalf("created networks = %+v, want the default network", writer.createdNetworks) + } + if got := writer.createdNetworks[0].Spec.IPAM.Mode; got != networkingv1alpha.NetworkIPAMModeAuto { + t.Errorf("network IPAM mode = %q, want %q", got, networkingv1alpha.NetworkIPAMModeAuto) + } + if !out.Network.Created { + t.Error("apply did not report that the network was created; it is a second object") + } + // A created workload is not a running one, and the next step has to say so. + if !strings.Contains(out.Next, ToolWorkloadDiagnose) { + t.Errorf("next = %q, want it to name %s", out.Next, ToolWorkloadDiagnose) + } +} + +// TestWorkloadApplyLeavesAnExistingNetworkAlone: creating one that is already +// there would fail, and reporting one that was not created as created would +// tell the person something untrue about their project. +func TestWorkloadApplyLeavesAnExistingNetworkAlone(t *testing.T) { + writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} + deps := writeDeps(&writeReader{}, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err != nil { + t.Fatalf("compute_workload_apply: %v", err) + } + if len(writer.createdNetworks) != 0 || out.Network.Created { + t.Errorf("apply created a network that already existed: %+v", writer.createdNetworks) + } +} + +// TestWorkloadApplyUpdatesAtTheVersionThePlanSaw: an update carries the +// resource version that was read, so a change that lands in between is refused +// by the server rather than silently overwritten. +func TestWorkloadApplyUpdatesAtTheVersionThePlanSaw(t *testing.T) { + reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ + wlAPIBackend: existingWorkload(deployedImage, "7"), + }} + writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} + deps := writeDeps(reader, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err != nil { + t.Fatalf("compute_workload_apply: %v", err) + } + if out.Action != actionUpdate { + t.Errorf("action = %q, want %q", out.Action, actionUpdate) + } + if len(writer.updated) != 1 { + t.Fatalf("updated %d workloads, want exactly 1", len(writer.updated)) + } + if got := writer.updated[0].ResourceVersion; got != "7" { + t.Errorf("updated at resource version %q, want the %q the plan read", got, "7") + } + if len(writer.created) != 0 { + t.Error("apply created a workload that already existed") + } +} + +// TestWorkloadApplyRefusesATamperedManifest is the property the whole design +// rests on: what is created is what was shown, or nothing. A model that read a +// poisoned status message and changed one field cannot spend a token minted +// for the manifest the person actually agreed to. +func TestWorkloadApplyRefusesATamperedManifest(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + tampered := strings.Replace(plan.Manifest, testImage, "ghcr.io/attacker/miner:latest", 1) + if tampered == plan.Manifest { + t.Fatal("the manifest was not actually changed; the test proves nothing") + } + + _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: tampered, + PlanToken: plan.PlanToken, + }) + if err == nil { + t.Fatal("apply accepted a manifest the token was not minted for") + } + assertRefusalIsReadable(t, err) + if writer.wrote() { + t.Errorf("apply wrote something after refusing: %+v %+v", writer.created, writer.createdNetworks) + } +} + +// TestWorkloadApplyRefusesAnExpiredToken: a plan is an agreement about a moment. +// Fifteen minutes later the quota, the images and the workload itself may all +// have moved, so consent has to be asked for again rather than assumed. +func TestWorkloadApplyRefusesAnExpiredToken(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + manifest := mustRender(t, deps, renderInput()) + + desired, ferr := decodeManifest(manifest, testNamespace) + if ferr != nil { + t.Fatalf("decoding the rendered manifest: %+v", ferr) + } + canonical, err := canonicalJSON(desired) + if err != nil { + t.Fatalf("canonicalJSON: %v", err) + } + stale := mintPlanToken(testPlanKey, canonical, testProject, "", time.Now().Add(-time.Minute)) + + _, _, err = workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: manifest, + PlanToken: stale, + }) + if err == nil { + t.Fatal("apply accepted an expired token") + } + if !strings.Contains(err.Error(), "expired") { + t.Errorf("error = %q, want it to say the plan expired", err) + } + assertRefusalIsReadable(t, err) + if writer.wrote() { + t.Error("apply wrote something after refusing an expired token") + } +} + +// TestWorkloadApplyRefusesATokenFromAnotherProject: the project is fixed by the +// request, so a token that travelled between conversations must not spend. +func TestWorkloadApplyRefusesATokenFromAnotherProject(t *testing.T) { + writer := &fakeWriter{} + planned := writeDepsFor(otherProject, &writeReader{}, &fakeWriter{}) + manifest := mustRender(t, planned, renderInput()) + plan := mustPlan(t, planned, manifest) + + applying := writeDepsFor(testProject, &writeReader{}, writer) + _, _, err := workloadApply(applying)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err == nil { + t.Fatal("apply accepted a token minted for another project") + } + assertRefusalIsReadable(t, err) + if writer.wrote() { + t.Error("apply wrote something after refusing a token from another project") + } +} + +// TestWorkloadApplyRefusesAWorkloadThatMovedSinceThePlan: someone else changed +// it in between, so the diff the person was shown is no longer the change that +// would be made. Re-plan and ask again. +func TestWorkloadApplyRefusesAWorkloadThatMovedSinceThePlan(t *testing.T) { + reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ + wlAPIBackend: existingWorkload(deployedImage, "7"), + }} + writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} + deps := writeDeps(reader, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + // Someone else edits the workload between the plan and the apply. + reader.workloads[wlAPIBackend] = existingWorkload("ghcr.io/acme/api:1.2.0", "8") + + _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err == nil { + t.Fatal("apply accepted a plan made against an older version of the workload") + } + assertRefusalIsReadable(t, err) + if writer.wrote() { + t.Error("apply wrote something after the workload moved underneath the plan") + } +} + +// TestWorkloadApplyWritesNothingWhenTheServerRejectsIt: the plan may be minutes +// old and quota, references and the catalogs all move. The check runs again, +// and a rejection stops the apply before the network is created too — a +// half-applied plan is worse than a refused one. +func TestWorkloadApplyWritesNothingWhenTheServerRejectsIt(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) + + // Accepted at plan time, refused now. + writer.dryRunErr = rejection("spec.template.spec.volumes[0].name", + "volume must be attached at least 1 time") + + _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: plan.Manifest, + PlanToken: plan.PlanToken, + }) + if err == nil { + t.Fatal("apply proceeded after the server rejected the workload") + } + if writer.wrote() { + t.Errorf("apply wrote something after a rejected check: workloads %+v, networks %+v", + writer.created, writer.createdNetworks) + } +} + +// TestWorkloadApplyRefusesAMalformedToken: a token the model made up, or one +// truncated in transit, is refused the same way — and the message says which +// tool issues real ones. +func TestWorkloadApplyRefusesAMalformedToken(t *testing.T) { + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + manifest := mustRender(t, deps, renderInput()) + + for name, token := range map[string]string{ + "empty": "", + "no expiry": "bm90LWEtdG9rZW4", + "bad expiry": "bm90LWEtdG9rZW4.soon", + "not base64": "!!!!.99999999999", + "wrong length": "AAAA.99999999999", + } { + t.Run(name, func(t *testing.T) { + _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ + Manifest: manifest, + PlanToken: token, + }) + if err == nil { + t.Fatalf("apply accepted a %s token", name) + } + assertRefusalIsReadable(t, err) + }) + } + if writer.wrote() { + t.Error("apply wrote something while refusing made-up tokens") + } +} + +// ------------------------------------------------------------ wiring, wire + +// TestWriteToolsFailWhenDepsAreUnavailable: an unauthenticated request must +// fail every write tool, render included. Rendering reads nothing, but it must +// not be a probe an unauthenticated caller can use either. +func TestWriteToolsFailWhenDepsAreUnavailable(t *testing.T) { + denied := func(context.Context) (ToolDeps, error) { + return ToolDeps{}, errors.New("no bearer token on the request") + } + ctx := context.Background() + + if _, _, err := workloadRender(denied)(ctx, nil, renderInput()); err == nil { + t.Error("compute_workload_render should fail without deps") + } + if _, _, err := workloadValidate(denied)(ctx, nil, WorkloadValidateInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_validate should fail without deps") + } + if _, _, err := workloadPlan(denied)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_plan should fail without deps") + } + if _, _, err := workloadApply(denied)(ctx, nil, WorkloadApplyInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_apply should fail without deps") + } +} + +// TestWriteToolsExplainAMissingWriter: a server built for diagnosis only has no +// Writer and no plan key. Both are wiring mistakes, and saying so beats a nil +// dereference in a handler or a token nothing can check. +func TestWriteToolsExplainAMissingWriter(t *testing.T) { + ctx := context.Background() + + diagnoseOnly := func(context.Context) (ToolDeps, error) { + return ToolDeps{Reader: &writeReader{}, Namespace: testNamespace, Project: testProject}, nil + } + _, _, err := workloadValidate(diagnoseOnly)(ctx, nil, WorkloadValidateInput{Manifest: "x"}) + if err == nil || !strings.Contains(err.Error(), "whoever operates this server") { + t.Errorf("compute_workload_validate error = %v, want it to name the wiring mistake", err) + } + + noKey := func(context.Context) (ToolDeps, error) { + return ToolDeps{ + Reader: &writeReader{}, Writer: &fakeWriter{}, + Namespace: testNamespace, Project: testProject, + }, nil + } + if _, _, err := workloadPlan(noKey)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_plan minted a token with no key to sign it") + } + if _, _, err := workloadApply(noKey)(ctx, nil, WorkloadApplyInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_apply accepted a token with no key to check it") + } + + // A request that never said which project it is for cannot bind a plan to + // one, and a token that binds to "" would spend anywhere. + noProject := func(context.Context) (ToolDeps, error) { + return ToolDeps{ + Reader: &writeReader{}, Writer: &fakeWriter{}, + Namespace: testNamespace, PlanTokenKey: testPlanKey, + }, nil + } + if _, _, err := workloadPlan(noProject)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { + t.Error("compute_workload_plan minted a token bound to no project") + } +} + +// TestPlanToApplyOverTheWire proves registration and the schemas, not just the +// handlers: a tool that is never wired into RegisterTools passes every test +// above and is uncallable in production, and an output the SDK cannot encode +// reaches the model as nothing at all. +func TestPlanToApplyOverTheWire(t *testing.T) { + ctx := context.Background() + writer := &fakeWriter{} + deps := writeDeps(&writeReader{}, writer) + + server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) + RegisterTools(server, deps) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connecting server: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connecting client: %v", err) + } + defer func() { _ = clientSession.Close() }() + + call := func(name string, args map[string]any, out any) { + t.Helper() + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("calling %s: %v", name, err) + } + if res.IsError { + t.Fatalf("%s returned an error result: %+v", name, res.Content) + } + // Round-tripped through the wire's JSON, so the output schema is + // exercised as the model would receive it. + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshalling %s output: %v", name, err) + } + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("decoding %s output: %v", name, err) + } + } + + var rendered WorkloadRenderOutput + call(ToolWorkloadRender, map[string]any{ + "name": wlAPIBackend, + "image": testImage, + "placements": []map[string]any{ + {"cityCodes": []string{cityDFW}, "minReplicas": 2}, + }, + }, &rendered) + if rendered.Manifest == "" { + t.Fatal("render returned no manifest over the wire") + } + + var planned WorkloadPlanOutput + call(ToolWorkloadPlan, map[string]any{"manifest": rendered.Manifest}, &planned) + if !planned.Valid || planned.PlanToken == "" { + t.Fatalf("plan over the wire returned %+v, want a token", planned) + } + + var applied WorkloadApplyOutput + call(ToolWorkloadApply, map[string]any{ + "manifest": planned.Manifest, + "planToken": planned.PlanToken, + }, &applied) + + if applied.Action != actionCreate || applied.Workload != wlAPIBackend { + t.Errorf("apply over the wire reported %+v, want a create of %q", applied, wlAPIBackend) + } + if len(writer.created) != 1 { + t.Fatalf("created %d workloads over the wire, want exactly 1", len(writer.created)) + } +} + +// assertRefusalIsReadable pins what every refusal owes the person reading it: +// that nothing happened, and what to do next. A refusal they cannot act on +// reads as a broken tool, and the next thing they try is the CLI. +func assertRefusalIsReadable(t *testing.T, err error) { + t.Helper() + msg := err.Error() + if !strings.Contains(msg, "nothing was created") { + t.Errorf("refusal = %q, want it to say plainly that nothing was created", msg) + } + if !strings.Contains(msg, ToolWorkloadPlan) { + t.Errorf("refusal = %q, want it to name %s as the way forward", msg, ToolWorkloadPlan) + } +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 1fb0ea1f..dbc96e3b 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -11,7 +11,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -22,6 +21,7 @@ import ( "go.datum.net/compute/internal/cmd/compute/build" "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/watch" + "go.datum.net/compute/internal/workloadspec" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -174,49 +174,32 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } } - // Build spec. - tcp := corev1.ProtocolTCP - container := computev1alpha.SandboxContainer{ - Name: "app", - Image: opts.image, + // Build spec. workloadspec owns the shape of a rendered manifest, so the + // same inputs produce the same workload here and anywhere else it is used. + in := workloadspec.Input{ + Name: workloadName, + Image: opts.image, + InstanceType: instanceType, + // TODO: "default" network name is a convention; confirm with platform team. + Network: workloadspec.DefaultNetwork, + // All cities go into one "default" placement. + Placements: []workloadspec.Placement{ + { + Name: workloadspec.DefaultPlacementName, + CityCodes: opts.cities, + MinReplicas: opts.min, + }, + }, } if opts.port > 0 { - container.Ports = []computev1alpha.NamedPort{ - {Name: "http", Port: opts.port, Protocol: &tcp}, - } - } - - // All cities go into one "default" placement. - placement := computev1alpha.WorkloadPlacement{ - Name: "default", - CityCodes: opts.cities, - ScaleSettings: computev1alpha.HorizontalScaleSettings{ - MinReplicas: opts.min, - InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, - }, + in.Ports = []workloadspec.Port{{Name: "http", Port: opts.port}} } - workload.Spec = computev1alpha.WorkloadSpec{ - Template: computev1alpha.InstanceTemplateSpec{ - Spec: computev1alpha.InstanceSpec{ - Runtime: computev1alpha.InstanceRuntimeSpec{ - Resources: computev1alpha.InstanceRuntimeResources{ - InstanceType: instanceType, - }, - Sandbox: &computev1alpha.SandboxRuntime{ - Containers: []computev1alpha.SandboxContainer{container}, - }, - }, - NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ - { - // TODO: "default" network name is a convention; confirm with platform team. - Network: networkingv1alpha.NetworkRef{Name: "default"}, - }, - }, - }, - }, - Placements: []computev1alpha.WorkloadPlacement{placement}, + rendered, err := workloadspec.Render(in) + if err != nil { + return err } + workload.Spec = rendered.Spec fmt.Fprintf(out, " Placement \"default\": cities=[%s], min=%d\n", strings.Join(opts.cities, ", "), opts.min) diff --git a/internal/cmd/compute/util/quota.go b/internal/cmd/compute/util/quota.go index 8acf99ff..51611e58 100644 --- a/internal/cmd/compute/util/quota.go +++ b/internal/cmd/compute/util/quota.go @@ -2,163 +2,31 @@ package util import ( "context" - "strings" - quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/client" + + "go.datum.net/compute/internal/quotaview" ) +// The quota reading lives in internal/quotaview so the CLI and the MCP server +// share one implementation — the numbers a person is shown and the numbers an +// assistant reports must not be able to drift apart. These aliases keep the +// existing call sites in this package's consumers working. + // QuotaRow holds display-ready quota data for one resource type. -type QuotaRow struct { - ResourceType string `json:"resourceType"` - DisplayName string `json:"displayName"` - Unit string `json:"unit"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` - Available int64 `json:"available"` -} +type QuotaRow = quotaview.QuotaRow -// QuotaMeta overrides display metadata for a resource type. When provided, -// DisplayName, Unit, and Divisor take precedence over ResourceRegistration values. -type QuotaMeta struct { - DisplayName string - Unit string - // Divisor converts the stored integer value to display units (e.g. 1000 for - // millicores → vCPUs). Zero is treated as 1. - Divisor int64 - // Order controls the position of this row in the returned slice (ascending). - // Rows without a meta entry sort after all meta rows, alphabetically. - Order int -} +// QuotaMeta overrides display metadata for a resource type. +type QuotaMeta = quotaview.QuotaMeta -// ListServiceQuota returns quota rows for AllowanceBuckets whose resource type -// begins with resourceTypePrefix (e.g. "compute.datumapis.com"). projectClient -// must target the project's virtual control plane; platformClient must target -// the platform API server (used to fetch ResourceRegistrations for display -// metadata when no override is provided in meta). -// -// meta may be nil. When an entry exists for a resource type, its DisplayName, -// Unit, and Divisor are used; otherwise the ResourceRegistration's displayUnit -// field is used and the divisor defaults to 1. +// ListServiceQuota returns quota rows for the project's quota whose resource +// type begins with resourceTypePrefix. See quotaview.ListServiceQuota. func ListServiceQuota( ctx context.Context, projectClient, platformClient client.Client, resourceTypePrefix string, meta map[string]QuotaMeta, - orderedTypes []string, // explicit display order; types not in this list follow alphabetically + orderedTypes []string, ) ([]QuotaRow, error) { - // Fetch AllowanceBuckets from the project VCP. - var bucketList quotav1alpha1.AllowanceBucketList - if err := projectClient.List(ctx, &bucketList, - client.InNamespace("milo-system"), - client.MatchingLabels{"quota.miloapis.com/consumer-kind": "Project"}, - ); err != nil { - return nil, err - } - - // Index buckets by resource type, filtering to the requested prefix. - bucketByType := make(map[string]*quotav1alpha1.AllowanceBucket) - for i := range bucketList.Items { - b := &bucketList.Items[i] - if strings.HasPrefix(b.Spec.ResourceType, resourceTypePrefix) { - bucketByType[b.Spec.ResourceType] = b - } - } - - if len(bucketByType) == 0 { - return nil, nil - } - - // Fetch ResourceRegistrations from the platform for display metadata fallback. - rrByType := make(map[string]*quotav1alpha1.ResourceRegistration) - if platformClient != nil { - var rrList quotav1alpha1.ResourceRegistrationList - if err := platformClient.List(ctx, &rrList); err == nil { - for i := range rrList.Items { - rr := &rrList.Items[i] - if strings.HasPrefix(rr.Spec.ResourceType, resourceTypePrefix) { - rrByType[rr.Spec.ResourceType] = rr - } - } - } - } - - // Build an ordered index: position in orderedTypes slice. - orderIndex := make(map[string]int, len(orderedTypes)) - for i, rt := range orderedTypes { - orderIndex[rt] = i - } - - // Build rows in explicit order first, then append remaining alphabetically. - rows := make([]QuotaRow, 0, len(bucketByType)) - seen := make(map[string]bool, len(bucketByType)) - - appendRow := func(rt string, b *quotav1alpha1.AllowanceBucket) { - if seen[rt] { - return - } - seen[rt] = true - - displayName := resourceTypeSuffix(rt) - unit := "units" - var divisor int64 = 1 - - if m, ok := meta[rt]; ok { - if m.DisplayName != "" { - displayName = m.DisplayName - } - if m.Unit != "" { - unit = m.Unit - } - if m.Divisor > 1 { - divisor = m.Divisor - } - } else if rr, ok := rrByType[rt]; ok && rr.Spec.DisplayUnit != "" && rr.Spec.DisplayUnit != "1" { - unit = rr.Spec.DisplayUnit - } - - rows = append(rows, QuotaRow{ - ResourceType: rt, - DisplayName: displayName, - Unit: unit, - Limit: b.Status.Limit / divisor, - Used: b.Status.Allocated / divisor, - Available: b.Status.Available / divisor, - }) - } - - for _, rt := range orderedTypes { - if b, ok := bucketByType[rt]; ok { - appendRow(rt, b) - } - } - // Append any buckets not covered by orderedTypes. - remaining := make([]string, 0) - for rt := range bucketByType { - if !seen[rt] { - remaining = append(remaining, rt) - } - } - // Stable alphabetical order for the tail. - for i := 0; i < len(remaining)-1; i++ { - for j := i + 1; j < len(remaining); j++ { - if remaining[i] > remaining[j] { - remaining[i], remaining[j] = remaining[j], remaining[i] - } - } - } - for _, rt := range remaining { - appendRow(rt, bucketByType[rt]) - } - - return rows, nil -} - -// resourceTypeSuffix derives a human-readable name from the last segment of a -// resource type string (e.g. "compute.datumapis.com/vcpus" → "vcpus"). -func resourceTypeSuffix(resourceType string) string { - if idx := strings.LastIndex(resourceType, "/"); idx >= 0 { - return resourceType[idx+1:] - } - return resourceType + return quotaview.ListServiceQuota(ctx, projectClient, platformClient, resourceTypePrefix, meta, orderedTypes) } diff --git a/internal/quotaview/quota.go b/internal/quotaview/quota.go new file mode 100644 index 00000000..0fced74f --- /dev/null +++ b/internal/quotaview/quota.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package quotaview reads a project's compute quota and renders it as display +// rows. +// +// It is deliberately free of cobra and of the datumctl plugin runtime: the same +// numbers are read by `datumctl compute quota` and by the MCP server's +// compute_quota_get tool, and there is one implementation so the two can never +// disagree about what a project has left. +package quotaview + +import ( + "context" + "sort" + "strings" + + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // ComputeResourceTypePrefix selects the resource types compute owns. + ComputeResourceTypePrefix = "compute.datumapis.com" + + // quotaNamespace is where a project's quota objects live inside its own + // control plane. + quotaNamespace = "milo-system" + + // consumerKindLabel and consumerKindProject select the quota held by the + // project itself, rather than by anything nested under it. + consumerKindLabel = "quota.miloapis.com/consumer-kind" + consumerKindProject = "Project" +) + +// QuotaRow holds display-ready quota data for one resource type. +type QuotaRow struct { + ResourceType string `json:"resourceType"` + DisplayName string `json:"displayName"` + Unit string `json:"unit"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` + Available int64 `json:"available"` +} + +// QuotaMeta overrides display metadata for a resource type. When provided, +// DisplayName, Unit, and Divisor take precedence over registered values. +type QuotaMeta struct { + DisplayName string + Unit string + // Divisor converts the stored integer value to display units (e.g. 1000 for + // millicores → vCPUs). Zero is treated as 1. + Divisor int64 + // Order controls the position of this row in the returned slice (ascending). + // Rows without a meta entry sort after all meta rows, alphabetically. + Order int +} + +// ComputeOrderedTypes is the order compute's resource types are displayed in: +// the things a person counts first, first. +var ComputeOrderedTypes = []string{ + "compute.datumapis.com/workloads", + "compute.datumapis.com/instances", + "compute.datumapis.com/vcpus", + "compute.datumapis.com/memory", +} + +// ComputeMeta supplies display overrides for compute's resource types. The +// live registrations declare a display unit of "1", which tells a reader +// nothing, so the units are named here instead. vCPUs are stored in +// millicores, hence the divisor. +var ComputeMeta = map[string]QuotaMeta{ + "compute.datumapis.com/workloads": {DisplayName: "Workloads", Unit: "workloads", Divisor: 1}, + "compute.datumapis.com/instances": {DisplayName: "Instances", Unit: "instances", Divisor: 1}, + "compute.datumapis.com/vcpus": {DisplayName: "vCPUs", Unit: "vCPUs", Divisor: 1000}, + "compute.datumapis.com/memory": {DisplayName: "Memory", Unit: "MiB", Divisor: 1}, +} + +// ListServiceQuota returns quota rows for the project's quota whose resource +// type begins with resourceTypePrefix (e.g. "compute.datumapis.com"). +// projectClient must target the project; platformClient must target the +// platform API server, and supplies display metadata when meta carries no +// override. +// +// platformClient may be nil. A caller that reads only as the person who asked +// holds no platform credential of its own, and display metadata is not worth +// failing a read over: without it, units fall back to the generic "units". +// +// meta may be nil. When an entry exists for a resource type, its DisplayName, +// Unit, and Divisor are used; otherwise the registered display unit is used and +// the divisor defaults to 1. +func ListServiceQuota( + ctx context.Context, + projectClient, platformClient client.Client, + resourceTypePrefix string, + meta map[string]QuotaMeta, + orderedTypes []string, // explicit display order; types not in this list follow alphabetically +) ([]QuotaRow, error) { + var bucketList quotav1alpha1.AllowanceBucketList + if err := projectClient.List(ctx, &bucketList, + client.InNamespace(quotaNamespace), + client.MatchingLabels{consumerKindLabel: consumerKindProject}, + ); err != nil { + return nil, err + } + + // Index by resource type, filtering to the requested prefix. + bucketByType := make(map[string]*quotav1alpha1.AllowanceBucket) + for i := range bucketList.Items { + b := &bucketList.Items[i] + if strings.HasPrefix(b.Spec.ResourceType, resourceTypePrefix) { + bucketByType[b.Spec.ResourceType] = b + } + } + + if len(bucketByType) == 0 { + return nil, nil + } + + // Display metadata fallback, best effort: a caller with no platform + // credential still gets numbers. + rrByType := make(map[string]*quotav1alpha1.ResourceRegistration) + if platformClient != nil { + var rrList quotav1alpha1.ResourceRegistrationList + if err := platformClient.List(ctx, &rrList); err == nil { + for i := range rrList.Items { + rr := &rrList.Items[i] + if strings.HasPrefix(rr.Spec.ResourceType, resourceTypePrefix) { + rrByType[rr.Spec.ResourceType] = rr + } + } + } + } + + rows := make([]QuotaRow, 0, len(bucketByType)) + seen := make(map[string]bool, len(bucketByType)) + + appendRow := func(rt string, b *quotav1alpha1.AllowanceBucket) { + if seen[rt] { + return + } + seen[rt] = true + + displayName := resourceTypeSuffix(rt) + unit := "units" + var divisor int64 = 1 + + if m, ok := meta[rt]; ok { + if m.DisplayName != "" { + displayName = m.DisplayName + } + if m.Unit != "" { + unit = m.Unit + } + if m.Divisor > 1 { + divisor = m.Divisor + } + } else if rr, ok := rrByType[rt]; ok && rr.Spec.DisplayUnit != "" && rr.Spec.DisplayUnit != "1" { + unit = rr.Spec.DisplayUnit + } + + rows = append(rows, QuotaRow{ + ResourceType: rt, + DisplayName: displayName, + Unit: unit, + Limit: b.Status.Limit / divisor, + Used: b.Status.Allocated / divisor, + Available: b.Status.Available / divisor, + }) + } + + for _, rt := range orderedTypes { + if b, ok := bucketByType[rt]; ok { + appendRow(rt, b) + } + } + + // Anything the caller did not order sorts alphabetically behind it, so the + // output stays reproducible when a new resource type appears. + remaining := make([]string, 0, len(bucketByType)) + for rt := range bucketByType { + if !seen[rt] { + remaining = append(remaining, rt) + } + } + sort.Strings(remaining) + for _, rt := range remaining { + appendRow(rt, bucketByType[rt]) + } + + return rows, nil +} + +// ListComputeQuota is ListServiceQuota with compute's own prefix, display +// metadata and order applied. +func ListComputeQuota( + ctx context.Context, projectClient, platformClient client.Client, +) ([]QuotaRow, error) { + return ListServiceQuota( + ctx, projectClient, platformClient, + ComputeResourceTypePrefix, ComputeMeta, ComputeOrderedTypes, + ) +} + +// resourceTypeSuffix derives a human-readable name from the last segment of a +// resource type string (e.g. "compute.datumapis.com/vcpus" → "vcpus"). +func resourceTypeSuffix(resourceType string) string { + if idx := strings.LastIndex(resourceType, "/"); idx >= 0 { + return resourceType[idx+1:] + } + return resourceType +} diff --git a/internal/quotaview/quota_test.go b/internal/quotaview/quota_test.go new file mode 100644 index 00000000..5197d2dd --- /dev/null +++ b/internal/quotaview/quota_test.go @@ -0,0 +1,122 @@ +package quotaview + +import ( + "context" + "testing" + + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := quotav1alpha1.AddToScheme(s); err != nil { + t.Fatalf("building scheme: %v", err) + } + return s +} + +func bucket(resourceType string, limit, allocated, available int64) *quotav1alpha1.AllowanceBucket { + return "av1alpha1.AllowanceBucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceType, + Namespace: quotaNamespace, + Labels: map[string]string{consumerKindLabel: consumerKindProject}, + }, + Spec: quotav1alpha1.AllowanceBucketSpec{ResourceType: resourceType}, + Status: quotav1alpha1.AllowanceBucketStatus{Limit: limit, Allocated: allocated, Available: available}, + } +} + +func projectClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + return fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(objs...).Build() +} + +// TestListComputeQuotaOrdersRowsAndConvertsUnits pins the two things a caller +// depends on: the explicit order, and vCPUs arriving as vCPUs rather than as +// the thousandths they are stored in. +func TestListComputeQuotaOrdersRowsAndConvertsUnits(t *testing.T) { + c := projectClient(t, + // Inserted out of display order, so the ordering is proven. + bucket("compute.datumapis.com/vcpus", 8000, 3000, 5000), + bucket("compute.datumapis.com/workloads", 10, 3, 7), + bucket("compute.datumapis.com/memory", 16384, 4096, 12288), + ) + + // No platform client: the server that reads as the person who asked has + // none, and the numbers must still arrive. + rows, err := ListComputeQuota(context.Background(), c, nil) + if err != nil { + t.Fatalf("ListComputeQuota: %v", err) + } + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3: %+v", len(rows), rows) + } + + wantOrder := []string{ + "compute.datumapis.com/workloads", + "compute.datumapis.com/vcpus", + "compute.datumapis.com/memory", + } + for i, want := range wantOrder { + if rows[i].ResourceType != want { + t.Errorf("rows[%d] = %q, want %q", i, rows[i].ResourceType, want) + } + } + + vcpus := rows[1] + if vcpus.Unit != "vCPUs" || vcpus.Limit != 8 || vcpus.Used != 3 || vcpus.Available != 5 { + t.Errorf("vCPU row = %+v, want 8/3/5 vCPUs (divided down from millicores)", vcpus) + } +} + +// TestListServiceQuotaIgnoresOtherServices keeps another service's quota out of +// compute's answer, and sorts whatever compute owns but did not order. +func TestListServiceQuotaIgnoresOtherServices(t *testing.T) { + c := projectClient(t, + bucket("networking.datumapis.com/networks", 5, 1, 4), + bucket("compute.datumapis.com/zzz-new", 2, 0, 2), + bucket("compute.datumapis.com/aaa-new", 2, 0, 2), + bucket("compute.datumapis.com/workloads", 10, 3, 7), + ) + + rows, err := ListComputeQuota(context.Background(), c, nil) + if err != nil { + t.Fatalf("ListComputeQuota: %v", err) + } + + want := []string{ + "compute.datumapis.com/workloads", // explicitly ordered, so first + "compute.datumapis.com/aaa-new", // the rest alphabetically, so a new + "compute.datumapis.com/zzz-new", // resource type lands reproducibly + } + if len(rows) != len(want) { + t.Fatalf("got %d rows, want %d: %+v", len(rows), len(want), rows) + } + for i, rt := range want { + if rows[i].ResourceType != rt { + t.Errorf("rows[%d] = %q, want %q", i, rows[i].ResourceType, rt) + } + } + // A type with no display metadata falls back to its last segment. + if rows[1].DisplayName != "aaa-new" || rows[1].Unit != "units" { + t.Errorf("unregistered row = %+v, want the suffix as its name and generic units", rows[1]) + } +} + +// TestListServiceQuotaReturnsNothingWhenNoQuotaIsConfigured distinguishes "no +// quota" from a failure: a project with none is not an error. +func TestListServiceQuotaReturnsNothingWhenNoQuotaIsConfigured(t *testing.T) { + rows, err := ListComputeQuota(context.Background(), projectClient(t), nil) + if err != nil { + t.Fatalf("ListComputeQuota: %v", err) + } + if len(rows) != 0 { + t.Errorf("rows = %+v, want none", rows) + } +} diff --git a/internal/validation/instance_validation.go b/internal/validation/instance_validation.go index f7867684..0c8ef8c9 100644 --- a/internal/validation/instance_validation.go +++ b/internal/validation/instance_validation.go @@ -3,6 +3,7 @@ package validation import ( "fmt" "path" + "slices" "strings" "golang.org/x/crypto/ssh" @@ -30,6 +31,19 @@ const ( defaultInstanceType = "datumcloud/d1-standard-2" ) +// SupportedInstanceTypes returns the instance types a Workload may ask for, in +// the order they should be offered. Validation is the single place that decides +// what is accepted, so anything that tells a customer what they may deploy — +// the CLI, the MCP tools — asks here rather than keeping a second list that can +// fall out of step with what the API will take. +// +// TODO(#137): this is a hand-maintained list of one. It should be read from a +// served instance-type catalog, alongside the sizing that currently lives in +// the instance controller. +func SupportedInstanceTypes() []string { + return []string{defaultInstanceType} +} + func validateInstanceTemplate( template computev1alpha.InstanceTemplateSpec, fieldPath *field.Path, @@ -911,8 +925,8 @@ func validateInstanceRuntimeResources(resources computev1alpha.InstanceRuntimeRe allErrs := field.ErrorList{} // TODO(jreese) look up available instance types - if resources.InstanceType != defaultInstanceType { - allErrs = append(allErrs, field.NotSupported(fieldPath, resources.InstanceType, []string{defaultInstanceType})) + if supported := SupportedInstanceTypes(); !slices.Contains(supported, resources.InstanceType) { + allErrs = append(allErrs, field.NotSupported(fieldPath, resources.InstanceType, supported)) } if resources.Requests != nil { diff --git a/internal/workloadspec/diff.go b/internal/workloadspec/diff.go new file mode 100644 index 00000000..10243c80 --- /dev/null +++ b/internal/workloadspec/diff.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package workloadspec + +import ( + "fmt" + "strings" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +// Diff summarizes what applying desired would change about existing, as lines +// meant to be shown to a person before they confirm an apply. An empty result +// means nothing Diff reports on changed — it covers the image and the +// placements, not the whole spec. +// +// Lines are ordered by the desired manifest, then by the existing one, so the +// same pair of manifests always produces the same output. +func Diff(existing, desired *computev1alpha.Workload) []string { + var lines []string + + oldImage := imageOf(existing) + newImage := imageOf(desired) + if oldImage != newImage { + lines = append(lines, fmt.Sprintf(" image: %s → %s", oldImage, newImage)) + } + + oldPlacements := placementsByName(existing) + + seen := make(map[string]struct{}, len(oldPlacements)) + for _, np := range placementsOf(desired) { + seen[np.Name] = struct{}{} + + op, ok := oldPlacements[np.Name] + if !ok { + lines = append(lines, fmt.Sprintf(" + new placement %q: cities=[%s]", + np.Name, strings.Join(np.CityCodes, ", "))) + continue + } + + if op.ScaleSettings.MinReplicas != np.ScaleSettings.MinReplicas { + lines = append(lines, fmt.Sprintf(" placement %q min replicas: %d → %d", + np.Name, op.ScaleSettings.MinReplicas, np.ScaleSettings.MinReplicas)) + } + } + + for _, op := range placementsOf(existing) { + if _, ok := seen[op.Name]; !ok { + lines = append(lines, fmt.Sprintf(" - removed placement %q", op.Name)) + } + } + + return lines +} + +// imageOf returns the first container image found in a workload, or the empty +// string when there is none (a nil workload, or a VM runtime). +func imageOf(w *computev1alpha.Workload) string { + if w == nil { + return "" + } + sandbox := w.Spec.Template.Spec.Runtime.Sandbox + if sandbox != nil && len(sandbox.Containers) > 0 { + return sandbox.Containers[0].Image + } + return "" +} + +func placementsOf(w *computev1alpha.Workload) []computev1alpha.WorkloadPlacement { + if w == nil { + return nil + } + return w.Spec.Placements +} + +func placementsByName(w *computev1alpha.Workload) map[string]computev1alpha.WorkloadPlacement { + placements := placementsOf(w) + byName := make(map[string]computev1alpha.WorkloadPlacement, len(placements)) + for _, p := range placements { + byName[p.Name] = p + } + return byName +} diff --git a/internal/workloadspec/diff_test.go b/internal/workloadspec/diff_test.go new file mode 100644 index 00000000..83ed5502 --- /dev/null +++ b/internal/workloadspec/diff_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package workloadspec + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +func TestDiff(t *testing.T) { + base := func(tweaks ...func(*Input)) *computev1alpha.Workload { + t.Helper() + w, err := Render(validInput(tweaks...)) + if err != nil { + t.Fatalf("Render() error: %v", err) + } + return w + } + + cases := map[string]struct { + existing *computev1alpha.Workload + desired *computev1alpha.Workload + want []string + }{ + "no changes": { + existing: base(), + desired: base(), + want: nil, + }, + "image change": { + existing: base(), + desired: base(func(in *Input) { in.Image = "ghcr.io/acme/api:2.0.0" }), + want: []string{" image: ghcr.io/acme/api:1.4.2 → ghcr.io/acme/api:2.0.0"}, + }, + "replica change": { + existing: base(), + desired: base(func(in *Input) { in.Placements[0].MinReplicas = 5 }), + want: []string{` placement "us" min replicas: 2 → 5`}, + }, + "added and removed placements are reported in manifest order": { + existing: base(), + desired: base(func(in *Input) { + in.Placements = []Placement{ + {Name: "eu", CityCodes: []string{"AMS", "FRA"}, MinReplicas: 1}, + } + }), + want: []string{ + ` + new placement "eu": cities=[AMS, FRA]`, + ` - removed placement "us"`, + }, + }, + "creating from nothing": { + existing: nil, + desired: base(), + want: []string{ + " image: → ghcr.io/acme/api:1.4.2", + ` + new placement "us": cities=[DFW]`, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if delta := cmp.Diff(tc.want, Diff(tc.existing, tc.desired)); delta != "" { + t.Errorf("Diff() mismatch (-want +got):\n%s", delta) + } + }) + } +} diff --git a/internal/workloadspec/render.go b/internal/workloadspec/render.go new file mode 100644 index 00000000..ef1a9710 --- /dev/null +++ b/internal/workloadspec/render.go @@ -0,0 +1,916 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package workloadspec turns a small, flat description of a deployment into a +// complete compute Workload manifest. +// +// The package is pure: it performs no I/O, reads no configuration, and has no +// dependency on cobra, the datumctl plugin runtime, or anything under +// internal/cmd. Render is a total function of its Input, so the same Input +// always yields the same manifest. That makes it usable both from the CLI's +// flag path and from a tool-call surface that only ever renders and returns +// YAML. +// +// # Relationship to the admission webhook +// +// Render never emits a manifest that the Workload admission webhook +// (internal/validation) would reject for structural reasons: a runtime is +// always present and is exactly one of sandbox or virtualMachine, every +// declared volume is attached at least once, a VM always carries the +// compute.datumapis.com/ssh-keys template annotation and a bootable first +// volume attachment, exactly one network interface is emitted, and scale +// settings stay inside the accepted range. Inputs that cannot satisfy those +// rules are reported as a field.ErrorList rather than rendered. +// +// Render does not police values the platform's catalogs own — the instance +// type and the boot image are passed through and left to the server, which is +// authoritative and whose accepted set changes without this package changing. +// Today the server accepts only DefaultInstanceType and DefaultBootImage. +// +// # Create-time-only decisions +// +// Several fields of a network interface are immutable once the workload +// exists, so Render's choices for them cannot be corrected by a later render +// of a changed Input — the workload has to be recreated instead: +// +// - name: left unset, so the API server defaults it to "eth0". The guest is +// configured against this name and the interface's address claim is named +// after it. +// - ipFamilies: left unset (the API server defaults it to IPv6 only) unless +// PublicIPv4 is requested, in which case [IPv4, IPv6] is emitted so the +// interface also holds an IPv4 address inside its network. Every family +// listed must be satisfiable by the network or the interface is never +// published. +// - addresses: emitted only for PublicIPv4, as a single public-ipv4 class +// request. +// - reclaimPolicy: left unset, so the API server defaults it to Delete and +// addresses are returned to IPAM when the instance slot goes away. Callers +// that publish an address in DNS want Retain, which means editing the +// rendered manifest before the first apply. +package workloadspec + +import ( + "encoding/json" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/sets" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + sigsyaml "sigs.k8s.io/yaml" + + computev1alpha "go.datum.net/compute/api/v1alpha" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + // DefaultInstanceType is the instance type used when Input.InstanceType is + // empty. It is currently the only type the admission webhook accepts. + DefaultInstanceType = "datumcloud/d1-standard-2" + + // DefaultNetwork is the network attached when Input.Network is empty. + DefaultNetwork = "default" + + // DefaultPlacementName is the name given to a placement that does not name + // itself. + DefaultPlacementName = "default" + + // DefaultMinReplicas is the replica floor used when a placement leaves + // MinReplicas at zero. Scale-to-zero is not supported, so zero is read as + // "unset" rather than as a request for no instances. + DefaultMinReplicas int32 = 1 + + // DefaultBootImage is the image a VM's boot disk is populated from when + // VMInput.BootImage is empty. It is currently the only image the admission + // webhook accepts. + DefaultBootImage = "datumcloud/ubuntu-2204-lts" + + // Namespace is the namespace every rendered workload lives in. Project + // control planes serve a single namespace. + Namespace = "default" + + // ContainerName is the name given to the sandbox container. A rendered + // sandbox always has exactly one container. + ContainerName = "app" + + // BootVolumeName is the name of the disk volume a VM boots from. It is + // always the VM's first volume attachment, which is what the webhook + // requires of a bootable volume. + BootVolumeName = "boot" + + // PublicIPv4Class is the IPAM class requested for a public IPv4 address. + PublicIPv4Class = "public-ipv4" + + // diskTypePDStandard is the only disk type the platform currently offers. + diskTypePDStandard = "pd-standard" + + // anyIPv4CIDR is the peer an exposed port is opened to. Exposing a port + // without opening it would leave the port unreachable, so the two travel + // together. + anyIPv4CIDR = "0.0.0.0/0" +) + +// Input is the flat description a manifest is rendered from. Every field +// except Name, Image, and Placements has a usable zero value. +type Input struct { + // Name of the workload. Required. + Name string + + // Image is the fully qualified container image the sandbox runs. Required + // unless VM is set, in which case it must be empty — a VM boots from a + // disk image, not a container image. + Image string + + // InstanceType selects the shape of each instance. Defaults to + // DefaultInstanceType. + InstanceType string + + // Network is the name of the network the instance's single interface + // attaches to. Defaults to DefaultNetwork. + Network string + + // Placements says where instances run and how many. At least one is + // required. + Placements []Placement + + // Ports are the named ports the workload serves. Each also opens an + // ingress network policy rule for that port from anyIPv4CIDR, because a + // declared port that nothing is allowed to reach is not useful. + Ports []Port + + // Env are environment variables set on the sandbox container. Ignored for + // a VM, which has no container to set them on. + Env []EnvVar + + // ConfigMounts project a ConfigMap or Secret into the instance's + // filesystem. Each becomes a volume plus an attachment on the container + // (sandbox) or on the VM. + ConfigMounts []Mount + + // PublicIPv4 asks for a public IPv4 address in front of the interface's + // private addressing. See the package doc: this also fixes ipFamilies at + // [IPv4, IPv6] for the life of the workload. + PublicIPv4 bool + + // Labels are applied both to the workload and to the instance template, so + // they land on the instances the workload creates. Template labels take + // part in the template hash, so changing them rolls the instances. + Labels map[string]string + + // VM, when set, renders a virtual machine runtime instead of a sandbox. + VM *VMInput +} + +// Placement is one group of city codes scaled together. +type Placement struct { + // Name of the placement. Must be a DNS label. Defaults to + // DefaultPlacementName. + Name string + + // CityCodes the placement deploys to, such as DFW. At least one is + // required. The set of valid codes is owned by the platform and is not + // checked here. + CityCodes []string + + // MinReplicas is the number of instances per placement. Defaults to + // DefaultMinReplicas; must not exceed 1000. + MinReplicas int32 +} + +// Port is a named port the workload serves. +type Port struct { + // Name of the port, referenced by other platform features. Must be a valid + // IANA service name (a DNS label of at most 15 characters containing a + // letter). Required. + Name string + + // Port number, 1 to 65535. Required. + Port int32 + + // Protocol defaults to TCP. + Protocol corev1.Protocol +} + +// EnvVar is one environment variable. Exactly one of Value, ConfigMapKeyRef, +// or SecretKeyRef may be set; all three unset yields an empty value. +type EnvVar struct { + Name string + Value string + ConfigMapKeyRef *KeyRef + SecretKeyRef *KeyRef +} + +// KeyRef selects one key of a ConfigMap or Secret in the workload's namespace. +type KeyRef struct { + Name string + Key string +} + +// Mount projects a ConfigMap or Secret into the instance's filesystem. +// Exactly one of ConfigMap or Secret must be set. +type Mount struct { + // Name of the generated volume. Must be a DNS label. Defaults to the name + // of the referenced ConfigMap or Secret, so a ConfigMap and a Secret of + // the same name need one of them named explicitly. + Name string + + // ConfigMap is the name of the ConfigMap to project. + ConfigMap string + + // Secret is the name of the Secret to project. + Secret string + + // MountPath is the absolute path the volume appears at inside the guest. + // Required, and unique across mounts. + MountPath string +} + +// VMInput describes a virtual machine runtime. +type VMInput struct { + // SSHKeys are the keys authorized to log in, one per entry, each in + // "username:ssh-public-key" form. At least one is required: a VM with no + // key is unreachable and the webhook rejects it. + SSHKeys []string + + // BootImage the boot disk is populated from. Defaults to + // DefaultBootImage. + BootImage string +} + +// Defaults returns an Input pre-filled with the values the CLI advertises: the +// default instance type and network, and a single placement named "default" +// with one replica. The caller still has to supply Name, Image, and the +// placement's CityCodes. +func Defaults() Input { + return Input{ + InstanceType: DefaultInstanceType, + Network: DefaultNetwork, + Placements: []Placement{ + { + Name: DefaultPlacementName, + MinReplicas: DefaultMinReplicas, + }, + }, + } +} + +// Render builds a complete Workload from in. It returns the aggregate of a +// field.ErrorList when the input is missing something required or describes a +// manifest the admission webhook would structurally reject; the returned +// workload is nil in that case. +func Render(in Input) (*computev1alpha.Workload, error) { + in = withDefaults(in) + volumes := plannedVolumes(in) + + if errs := validate(in, volumes); len(errs) > 0 { + return nil, errs.ToAggregate() + } + + workload := &computev1alpha.Workload{ + TypeMeta: metav1.TypeMeta{ + APIVersion: computev1alpha.GroupVersion.String(), + Kind: "Workload", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: in.Name, + Namespace: Namespace, + Labels: copyLabels(in.Labels), + }, + Spec: computev1alpha.WorkloadSpec{ + Template: buildTemplate(in, volumes), + Placements: buildPlacements(in.Placements), + }, + } + + return workload, nil +} + +// plannedVolume pairs a rendered volume with the attachment that carries it +// into the runtime, so the two can never drift apart: every volume the spec +// declares must be attached at least once. +type plannedVolume struct { + volume computev1alpha.InstanceVolume + attachment computev1alpha.VolumeAttachment +} + +// plannedVolumes derives the instance's volumes from the input. The VM boot +// disk, when present, is always first: the webhook requires the first volume +// attachment of a VM to be a bootable one. +func plannedVolumes(in Input) []plannedVolume { + planned := make([]plannedVolume, 0, len(in.ConfigMounts)+1) + + if in.VM != nil { + planned = append(planned, plannedVolume{ + volume: computev1alpha.InstanceVolume{ + Name: BootVolumeName, + VolumeSource: computev1alpha.VolumeSource{ + Disk: &computev1alpha.DiskTemplateVolumeSource{ + Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ + Spec: computev1alpha.DiskSpec{ + Type: diskTypePDStandard, + Populator: &computev1alpha.DiskPopulator{ + Image: &computev1alpha.ImageDiskPopulator{ + Name: in.VM.BootImage, + }, + }, + }, + }, + }, + }, + }, + // No mount path: the boot disk is attached as the boot device. + attachment: computev1alpha.VolumeAttachment{Name: BootVolumeName}, + }) + } + + for _, m := range in.ConfigMounts { + name := mountVolumeName(m) + + var source computev1alpha.VolumeSource + switch { + case m.ConfigMap != "": + // A configMap volume names its source with `name`, while a secret + // volume names it with `secretName`. + source.ConfigMap = &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: m.ConfigMap}, + } + case m.Secret != "": + source.Secret = &corev1.SecretVolumeSource{SecretName: m.Secret} + default: + // Rejected by validate; skip so rendering stays total. + continue + } + + mountPath := m.MountPath + planned = append(planned, plannedVolume{ + volume: computev1alpha.InstanceVolume{Name: name, VolumeSource: source}, + attachment: computev1alpha.VolumeAttachment{Name: name, MountPath: &mountPath}, + }) + } + + return planned +} + +func mountVolumeName(m Mount) string { + switch { + case m.Name != "": + return m.Name + case m.ConfigMap != "": + return m.ConfigMap + default: + return m.Secret + } +} + +func buildTemplate(in Input, volumes []plannedVolume) computev1alpha.InstanceTemplateSpec { + template := computev1alpha.InstanceTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: copyLabels(in.Labels), + }, + Spec: computev1alpha.InstanceSpec{ + Runtime: computev1alpha.InstanceRuntimeSpec{ + // `requests` is left unset: adjustments to an instance type's + // resources are rejected as not implemented. + Resources: computev1alpha.InstanceRuntimeResources{ + InstanceType: in.InstanceType, + }, + }, + NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ + buildNetworkInterface(in), + }, + }, + } + + if in.VM != nil { + template.Annotations = map[string]string{ + computev1alpha.SSHKeysAnnotation: strings.Join(in.VM.SSHKeys, "\n"), + } + template.Spec.Runtime.VirtualMachine = &computev1alpha.VirtualMachineRuntime{ + VolumeAttachments: attachments(volumes), + Ports: buildPorts(in.Ports), + } + } else { + template.Spec.Runtime.Sandbox = &computev1alpha.SandboxRuntime{ + Containers: []computev1alpha.SandboxContainer{buildContainer(in, volumes)}, + } + } + + for _, v := range volumes { + template.Spec.Volumes = append(template.Spec.Volumes, v.volume) + } + + return template +} + +func buildContainer(in Input, volumes []plannedVolume) computev1alpha.SandboxContainer { + return computev1alpha.SandboxContainer{ + Name: ContainerName, + Image: in.Image, + // `resources` is left unset: per-container resource requirements are + // rejected as not implemented, and the instance type carries the shape. + Env: buildEnv(in.Env), + Ports: buildPorts(in.Ports), + VolumeAttachments: attachments(volumes), + } +} + +func attachments(volumes []plannedVolume) []computev1alpha.VolumeAttachment { + if len(volumes) == 0 { + return nil + } + out := make([]computev1alpha.VolumeAttachment, 0, len(volumes)) + for _, v := range volumes { + out = append(out, v.attachment) + } + return out +} + +func buildNetworkInterface(in Input) computev1alpha.InstanceNetworkInterface { + iface := computev1alpha.InstanceNetworkInterface{ + Network: networkingv1alpha.NetworkRef{Name: in.Network}, + } + + if in.PublicIPv4 { + iface.IPFamilies = []networkingv1alpha.IPFamily{ + networkingv1alpha.IPv4Protocol, + networkingv1alpha.IPv6Protocol, + } + iface.Addresses = []computev1alpha.InstanceNetworkInterfaceAddressRequest{ + {Class: PublicIPv4Class}, + } + } + + if ingress := buildIngressRules(in.Ports); len(ingress) > 0 { + iface.NetworkPolicy = &computev1alpha.InstanceNetworkInterfaceNetworkPolicy{ + Ingress: ingress, + } + } + + return iface +} + +func buildIngressRules(ports []Port) []networkingv1alpha.NetworkPolicyIngressRule { + if len(ports) == 0 { + return nil + } + + rules := make([]networkingv1alpha.NetworkPolicyIngressRule, 0, len(ports)) + for _, p := range ports { + protocol := protocolOrDefault(p.Protocol) + port := intstr.FromInt32(p.Port) + rules = append(rules, networkingv1alpha.NetworkPolicyIngressRule{ + Ports: []networkingv1alpha.NetworkPolicyPort{ + {Protocol: &protocol, Port: &port}, + }, + From: []networkingv1alpha.NetworkPolicyPeer{ + {IPBlock: &networkingv1alpha.IPBlock{CIDR: anyIPv4CIDR}}, + }, + }) + } + return rules +} + +func buildPorts(ports []Port) []computev1alpha.NamedPort { + if len(ports) == 0 { + return nil + } + + out := make([]computev1alpha.NamedPort, 0, len(ports)) + for _, p := range ports { + protocol := protocolOrDefault(p.Protocol) + out = append(out, computev1alpha.NamedPort{ + Name: p.Name, + Port: p.Port, + Protocol: &protocol, + }) + } + return out +} + +func buildEnv(env []EnvVar) []corev1.EnvVar { + if len(env) == 0 { + return nil + } + + out := make([]corev1.EnvVar, 0, len(env)) + for _, e := range env { + v := corev1.EnvVar{Name: e.Name} + switch { + case e.ConfigMapKeyRef != nil: + v.ValueFrom = &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: e.ConfigMapKeyRef.Name}, + Key: e.ConfigMapKeyRef.Key, + }, + } + case e.SecretKeyRef != nil: + v.ValueFrom = &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: e.SecretKeyRef.Name}, + Key: e.SecretKeyRef.Key, + }, + } + default: + v.Value = e.Value + } + out = append(out, v) + } + return out +} + +func buildPlacements(placements []Placement) []computev1alpha.WorkloadPlacement { + out := make([]computev1alpha.WorkloadPlacement, 0, len(placements)) + for _, p := range placements { + out = append(out, computev1alpha.WorkloadPlacement{ + Name: p.Name, + CityCodes: p.CityCodes, + ScaleSettings: computev1alpha.HorizontalScaleSettings{ + MinReplicas: p.MinReplicas, + // maxReplicas is left unset: it requires scaling metrics, which + // this input does not describe. + InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, + }, + }) + } + return out +} + +func protocolOrDefault(p corev1.Protocol) corev1.Protocol { + if p == "" { + return corev1.ProtocolTCP + } + return p +} + +func copyLabels(labels map[string]string) map[string]string { + if len(labels) == 0 { + return nil + } + out := make(map[string]string, len(labels)) + for k, v := range labels { + out[k] = v + } + return out +} + +// withDefaults returns a copy of in with every defaultable field filled in. It +// is idempotent, so calling it twice is harmless. +func withDefaults(in Input) Input { + if in.InstanceType == "" { + in.InstanceType = DefaultInstanceType + } + if in.Network == "" { + in.Network = DefaultNetwork + } + + placements := make([]Placement, len(in.Placements)) + copy(placements, in.Placements) + for i := range placements { + if placements[i].Name == "" { + placements[i].Name = DefaultPlacementName + } + if placements[i].MinReplicas == 0 { + placements[i].MinReplicas = DefaultMinReplicas + } + } + in.Placements = placements + + if in.VM != nil { + vm := *in.VM + if vm.BootImage == "" { + vm.BootImage = DefaultBootImage + } + in.VM = &vm + } + + return in +} + +// MarshalYAML renders a workload as the YAML a user would commit. Status and +// the null creationTimestamp the object meta always carries are dropped, since +// neither is input to an apply. +func MarshalYAML(w *computev1alpha.Workload) ([]byte, error) { + if w == nil { + return nil, fmt.Errorf("workload is nil") + } + + raw, err := json.Marshal(w) + if err != nil { + return nil, fmt.Errorf("marshalling workload: %w", err) + } + + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("normalizing workload: %w", err) + } + delete(doc, "status") + pruneNulls(doc) + + data, err := sigsyaml.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("marshalling workload: %w", err) + } + return data, nil +} + +// pruneNulls removes explicit nulls, which the Kubernetes object meta emits for +// creationTimestamp at every level of a manifest. +func pruneNulls(node any) { + switch n := node.(type) { + case map[string]any: + for k, v := range n { + if v == nil { + delete(n, k) + continue + } + pruneNulls(v) + } + case []any: + for _, v := range n { + pruneNulls(v) + } + } +} + +func validate(in Input, volumes []plannedVolume) field.ErrorList { + allErrs := validateName(in.Name) + + allErrs = append(allErrs, validateRuntime(in)...) + allErrs = append(allErrs, validateNetwork(in.Network)...) + allErrs = append(allErrs, validatePlacements(in.Placements)...) + allErrs = append(allErrs, validatePorts(in.Ports)...) + allErrs = append(allErrs, validateEnv(in.Env)...) + allErrs = append(allErrs, validateMounts(in.ConfigMounts, volumes)...) + + return allErrs +} + +func validateName(name string) field.ErrorList { + allErrs := field.ErrorList{} + namePath := field.NewPath("name") + + if name == "" { + return append(allErrs, field.Required(namePath, "a workload name is required")) + } + for _, msg := range apimachineryvalidation.NameIsDNSSubdomain(name, false) { + allErrs = append(allErrs, field.Invalid(namePath, name, msg)) + } + return allErrs +} + +// validateRuntime enforces the "exactly one of sandbox or virtualMachine" +// rule at the input level, where the caller can still act on it. +func validateRuntime(in Input) field.ErrorList { + allErrs := field.ErrorList{} + + if in.VM == nil { + if in.Image == "" { + allErrs = append(allErrs, field.Required(field.NewPath("image"), + "a container image is required for a sandbox workload; set vm to render a virtual machine instead")) + } + return allErrs + } + + if in.Image != "" { + allErrs = append(allErrs, field.Forbidden(field.NewPath("image"), + "a virtual machine boots from vm.bootImage, not from a container image")) + } + if len(in.Env) > 0 { + allErrs = append(allErrs, field.Forbidden(field.NewPath("env"), + "a virtual machine has no container to set environment variables on")) + } + + return append(allErrs, validateSSHKeys(in.VM.SSHKeys)...) +} + +// validateSSHKeys mirrors the webhook's parsing of the ssh-keys annotation: +// one "username:key" pair per line, with a parseable public key. +func validateSSHKeys(keys []string) field.ErrorList { + allErrs := field.ErrorList{} + keysPath := field.NewPath("vm", "sshKeys") + + if len(keys) == 0 { + return append(allErrs, field.Required(keysPath, + "a virtual machine requires at least one 'username:ssh-public-key' entry")) + } + + for i, k := range keys { + keyPath := keysPath.Index(i) + + user, key, found := strings.Cut(k, ":") + if !found { + allErrs = append(allErrs, field.Invalid(keyPath, k, "must be in the format 'username:key'")) + continue + } + if user == "" { + allErrs = append(allErrs, field.Required(keyPath, "must provide a username")) + } + if strings.Contains(k, "\n") { + allErrs = append(allErrs, field.Invalid(keyPath, k, "must not contain a newline; provide one entry per key")) + continue + } + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)); err != nil { + allErrs = append(allErrs, field.Invalid(keyPath, key, "must be a valid SSH public key")) + } + } + + return allErrs +} + +func validateNetwork(network string) field.ErrorList { + networkPath := field.NewPath("network") + + msgs := apimachineryvalidation.NameIsDNSLabel(network, false) + allErrs := make(field.ErrorList, 0, len(msgs)) + for _, msg := range msgs { + allErrs = append(allErrs, field.Invalid(networkPath, network, msg)) + } + return allErrs +} + +func validatePlacements(placements []Placement) field.ErrorList { + allErrs := field.ErrorList{} + placementsPath := field.NewPath("placements") + + if len(placements) == 0 { + return append(allErrs, field.Required(placementsPath, "at least one placement is required")) + } + + names := sets.Set[string]{} + for i, p := range placements { + path := placementsPath.Index(i) + + namePath := path.Child("name") + for _, msg := range apimachineryvalidation.NameIsDNSLabel(p.Name, false) { + allErrs = append(allErrs, field.Invalid(namePath, p.Name, msg)) + } + if names.Has(p.Name) { + allErrs = append(allErrs, field.Duplicate(namePath, p.Name)) + } else { + names.Insert(p.Name) + } + + if len(p.CityCodes) == 0 { + allErrs = append(allErrs, field.Required(path.Child("cityCodes"), + "at least one city code is required")) + } + + minPath := path.Child("minReplicas") + if p.MinReplicas < 0 { + allErrs = append(allErrs, field.Invalid(minPath, p.MinReplicas, "must be greater than 0")) + } else if p.MinReplicas > 1000 { + allErrs = append(allErrs, field.Invalid(minPath, p.MinReplicas, "must be less than or equal to 1000")) + } + } + + return allErrs +} + +func validatePorts(ports []Port) field.ErrorList { + allErrs := field.ErrorList{} + portsPath := field.NewPath("ports") + + names := sets.Set[string]{} + for i, p := range ports { + path := portsPath.Index(i) + + namePath := path.Child("name") + if p.Name == "" { + allErrs = append(allErrs, field.Required(namePath, "")) + } else { + for _, msg := range utilvalidation.IsValidPortName(p.Name) { + allErrs = append(allErrs, field.Invalid(namePath, p.Name, msg)) + } + if names.Has(p.Name) { + allErrs = append(allErrs, field.Duplicate(namePath, p.Name)) + } else { + names.Insert(p.Name) + } + } + + for _, msg := range utilvalidation.IsValidPortNum(int(p.Port)) { + allErrs = append(allErrs, field.Invalid(path.Child("port"), p.Port, msg)) + } + + switch p.Protocol { + case "", corev1.ProtocolTCP, corev1.ProtocolUDP, corev1.ProtocolSCTP: + default: + allErrs = append(allErrs, field.NotSupported(path.Child("protocol"), p.Protocol, + []string{string(corev1.ProtocolTCP), string(corev1.ProtocolUDP), string(corev1.ProtocolSCTP)})) + } + } + + return allErrs +} + +func validateEnv(env []EnvVar) field.ErrorList { + allErrs := field.ErrorList{} + envPath := field.NewPath("env") + + names := sets.Set[string]{} + for i, e := range env { + path := envPath.Index(i) + + namePath := path.Child("name") + if e.Name == "" { + allErrs = append(allErrs, field.Required(namePath, "")) + } else { + for _, msg := range utilvalidation.IsCIdentifier(e.Name) { + allErrs = append(allErrs, field.Invalid(namePath, e.Name, msg)) + } + if names.Has(e.Name) { + allErrs = append(allErrs, field.Duplicate(namePath, e.Name)) + } else { + names.Insert(e.Name) + } + } + + sources := 0 + if e.Value != "" { + sources++ + } + if e.ConfigMapKeyRef != nil { + sources++ + allErrs = append(allErrs, validateKeyRef(*e.ConfigMapKeyRef, path.Child("configMapKeyRef"))...) + } + if e.SecretKeyRef != nil { + sources++ + allErrs = append(allErrs, validateKeyRef(*e.SecretKeyRef, path.Child("secretKeyRef"))...) + } + if sources > 1 { + allErrs = append(allErrs, field.Forbidden(path, + "may not specify more than one of value, configMapKeyRef, or secretKeyRef")) + } + } + + return allErrs +} + +func validateKeyRef(ref KeyRef, path *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + namePath := path.Child("name") + if ref.Name == "" { + allErrs = append(allErrs, field.Required(namePath, "")) + } else { + for _, msg := range apimachineryvalidation.NameIsDNSSubdomain(ref.Name, false) { + allErrs = append(allErrs, field.Invalid(namePath, ref.Name, msg)) + } + } + + if ref.Key == "" { + allErrs = append(allErrs, field.Required(path.Child("key"), "")) + } + + return allErrs +} + +func validateMounts(mounts []Mount, volumes []plannedVolume) field.ErrorList { + allErrs := field.ErrorList{} + mountsPath := field.NewPath("configMounts") + + names := sets.Set[string]{} + // The boot volume claims its name before any mount can. + for _, v := range volumes { + if v.volume.Disk != nil { + names.Insert(v.volume.Name) + } + } + + paths := sets.Set[string]{} + for i, m := range mounts { + path := mountsPath.Index(i) + + if (m.ConfigMap == "") == (m.Secret == "") { + allErrs = append(allErrs, field.Required(path, "must specify exactly one of configMap or secret")) + } + + namePath := path.Child("name") + name := mountVolumeName(m) + if name != "" { + for _, msg := range apimachineryvalidation.NameIsDNSLabel(name, false) { + allErrs = append(allErrs, field.Invalid(namePath, name, msg)) + } + if names.Has(name) { + allErrs = append(allErrs, field.Duplicate(namePath, name)) + } else { + names.Insert(name) + } + } + + mountPath := path.Child("mountPath") + if m.MountPath == "" { + allErrs = append(allErrs, field.Required(mountPath, "")) + } else if paths.Has(m.MountPath) { + allErrs = append(allErrs, field.Duplicate(mountPath, m.MountPath)) + } else { + paths.Insert(m.MountPath) + } + } + + return allErrs +} diff --git a/internal/workloadspec/render_test.go b/internal/workloadspec/render_test.go new file mode 100644 index 00000000..a15ff448 --- /dev/null +++ b/internal/workloadspec/render_test.go @@ -0,0 +1,715 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package workloadspec + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/validation" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +const ( + testSSHKey = "user:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILPbDbsv9fgEnam9iJ5b51Na/WieeiKCJRC0+m7fRwPk vscode@42aafaf8293e" + testCityCode = "DFW" + testImage = "ghcr.io/acme/api:1.4.2" + testWorkload = "api" + testPlacement = "us" + testMountPath = "/etc/app" + testCredsPath = "/etc/creds" + testConfigMap = "app-config" + testSecretName = "db-creds" + testPortName = "http" + testEnvLiteral = "LOG_LEVEL" + testEnvFromSecre = "DB_PASSWORD" + testSharedName = "shared" + testSSHKeysPath = "vm.sshKeys[0]" + testEnvValue = "debug" + testSecretKey = "password" + testLabelValue = "frontend" +) + +// validInput is the smallest Input that renders: a sandbox in one placement. +func validInput(tweaks ...func(*Input)) Input { + in := Input{ + Name: testWorkload, + Image: testImage, + Placements: []Placement{ + {Name: testPlacement, CityCodes: []string{testCityCode}, MinReplicas: 2}, + }, + } + for _, tweak := range tweaks { + tweak(&in) + } + return in +} + +func mustRender(t *testing.T, in Input) *computev1alpha.Workload { + t.Helper() + + w, err := Render(in) + if err != nil { + t.Fatalf("Render() error: %v", err) + } + return w +} + +// vmInput turns a sandbox input into the equivalent VM input. +func vmInput(tweaks ...func(*Input)) Input { + return validInput(append([]func(*Input){func(in *Input) { + in.Image = "" + in.VM = &VMInput{SSHKeys: []string{testSSHKey}} + }}, tweaks...)...) +} + +func TestRenderMinimalSandbox(t *testing.T) { + w := mustRender(t, validInput()) + + if got, want := w.APIVersion, computev1alpha.GroupVersion.String(); got != want { + t.Errorf("apiVersion = %q, want %q", got, want) + } + if got, want := w.Kind, "Workload"; got != want { + t.Errorf("kind = %q, want %q", got, want) + } + if got, want := w.Namespace, Namespace; got != want { + t.Errorf("namespace = %q, want %q", got, want) + } + + spec := w.Spec.Template.Spec + if got, want := spec.Runtime.Resources.InstanceType, DefaultInstanceType; got != want { + t.Errorf("instanceType = %q, want %q", got, want) + } + if spec.Runtime.Resources.Requests != nil { + t.Error("runtime.resources.requests must stay unset: the webhook rejects it as not implemented") + } + if spec.Runtime.VirtualMachine != nil { + t.Error("virtualMachine set on a sandbox render") + } + + containers := spec.Runtime.Sandbox.Containers + if len(containers) != 1 { + t.Fatalf("containers = %d, want 1", len(containers)) + } + if got, want := containers[0].Image, testImage; got != want { + t.Errorf("image = %q, want %q", got, want) + } + if containers[0].Resources != nil { + t.Error("containers[0].resources must stay unset: the webhook rejects it as not implemented") + } + + placements := w.Spec.Placements + if len(placements) != 1 { + t.Fatalf("placements = %d, want 1", len(placements)) + } + if got, want := placements[0].ScaleSettings.MinReplicas, int32(2); got != want { + t.Errorf("minReplicas = %d, want %d", got, want) + } + if got, want := placements[0].ScaleSettings.InstanceManagementPolicy, + computev1alpha.OrderedReadyInstanceManagementPolicyType; got != want { + t.Errorf("instanceManagementPolicy = %q, want %q", got, want) + } +} + +func TestRenderNetworkInterfaceLeavesImmutableFieldsDefaulted(t *testing.T) { + spec := mustRender(t, validInput()).Spec.Template.Spec + + if len(spec.NetworkInterfaces) != 1 { + t.Fatalf("networkInterfaces = %d, want exactly 1", len(spec.NetworkInterfaces)) + } + + iface := spec.NetworkInterfaces[0] + if got, want := iface.Network.Name, DefaultNetwork; got != want { + t.Errorf("network = %q, want %q", got, want) + } + if iface.Name != "" || iface.IPFamilies != nil || iface.Addresses != nil || iface.ReclaimPolicy != "" { + t.Errorf("interface should leave create-time-only fields to the API server, got %+v", iface) + } + if iface.NetworkPolicy != nil { + t.Error("no ports were requested, so no network policy should be rendered") + } +} + +func TestRenderDefaults(t *testing.T) { + w := mustRender(t, Input{ + Name: testWorkload, + Image: testImage, + Placements: []Placement{{CityCodes: []string{testCityCode}}}, + }) + + p := w.Spec.Placements[0] + if got, want := p.Name, DefaultPlacementName; got != want { + t.Errorf("placement name = %q, want %q", got, want) + } + if got, want := p.ScaleSettings.MinReplicas, DefaultMinReplicas; got != want { + t.Errorf("minReplicas = %d, want %d", got, want) + } + if got, want := w.Spec.Template.Spec.NetworkInterfaces[0].Network.Name, DefaultNetwork; got != want { + t.Errorf("network = %q, want %q", got, want) + } +} + +func TestRenderPortsOpenIngress(t *testing.T) { + spec := mustRender(t, validInput(func(in *Input) { + in.Ports = []Port{ + {Name: testPortName, Port: 8080}, + {Name: "dns", Port: 53, Protocol: corev1.ProtocolUDP}, + } + })).Spec.Template.Spec + + ports := spec.Runtime.Sandbox.Containers[0].Ports + if len(ports) != 2 { + t.Fatalf("container ports = %d, want 2", len(ports)) + } + if got, want := *ports[0].Protocol, corev1.ProtocolTCP; got != want { + t.Errorf("ports[0].protocol = %q, want %q (the default)", got, want) + } + if got, want := *ports[1].Protocol, corev1.ProtocolUDP; got != want { + t.Errorf("ports[1].protocol = %q, want %q", got, want) + } + + policy := spec.NetworkInterfaces[0].NetworkPolicy + if policy == nil { + t.Fatal("declared ports must open matching ingress rules") + } + if len(policy.Ingress) != 2 { + t.Fatalf("ingress rules = %d, want 2", len(policy.Ingress)) + } + if got, want := policy.Ingress[0].Ports[0].Port.IntValue(), 8080; got != want { + t.Errorf("ingress[0] port = %d, want %d", got, want) + } + if got, want := policy.Ingress[0].From[0].IPBlock.CIDR, anyIPv4CIDR; got != want { + t.Errorf("ingress[0] cidr = %q, want %q", got, want) + } +} + +func TestRenderEnv(t *testing.T) { + spec := mustRender(t, validInput(func(in *Input) { + in.Env = []EnvVar{ + {Name: testEnvLiteral, Value: testEnvValue}, + {Name: testEnvFromSecre, SecretKeyRef: &KeyRef{Name: testSecretName, Key: testSecretKey}}, + {Name: "REGION", ConfigMapKeyRef: &KeyRef{Name: testConfigMap, Key: "region"}}, + } + })).Spec.Template.Spec + + want := []corev1.EnvVar{ + {Name: testEnvLiteral, Value: testEnvValue}, + { + Name: testEnvFromSecre, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: testSecretName}, + Key: testSecretKey, + }, + }, + }, + { + Name: "REGION", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: testConfigMap}, + Key: "region", + }, + }, + }, + } + if delta := cmp.Diff(want, spec.Runtime.Sandbox.Containers[0].Env); delta != "" { + t.Errorf("env mismatch (-want +got):\n%s", delta) + } +} + +func TestRenderConfigMountsAreAttached(t *testing.T) { + spec := mustRender(t, validInput(func(in *Input) { + in.ConfigMounts = []Mount{ + {ConfigMap: testConfigMap, MountPath: testMountPath}, + {Secret: testSecretName, MountPath: testCredsPath}, + } + })).Spec.Template.Spec + + if len(spec.Volumes) != 2 { + t.Fatalf("volumes = %d, want 2", len(spec.Volumes)) + } + // A configMap volume names its source with `name`, a secret volume with + // `secretName`. + if got, want := spec.Volumes[0].ConfigMap.Name, testConfigMap; got != want { + t.Errorf("configMap volume name = %q, want %q", got, want) + } + if got, want := spec.Volumes[1].Secret.SecretName, testSecretName; got != want { + t.Errorf("secret volume secretName = %q, want %q", got, want) + } + + attachments := spec.Runtime.Sandbox.Containers[0].VolumeAttachments + if len(attachments) != 2 { + t.Fatalf("volumeAttachments = %d, want 2", len(attachments)) + } + for i, a := range attachments { + if a.Name != spec.Volumes[i].Name { + t.Errorf("attachment %d = %q, does not match volume %q", i, a.Name, spec.Volumes[i].Name) + } + if a.MountPath == nil { + t.Fatalf("attachment %d has no mount path", i) + } + } + if got, want := *attachments[0].MountPath, testMountPath; got != want { + t.Errorf("mountPath = %q, want %q", got, want) + } +} + +func TestRenderExplicitMountNameResolvesCollision(t *testing.T) { + volumes := mustRender(t, validInput(func(in *Input) { + in.ConfigMounts = []Mount{ + {ConfigMap: testSharedName, MountPath: testMountPath}, + {Name: testSharedName + "-secret", Secret: testSharedName, MountPath: testCredsPath}, + } + })).Spec.Template.Spec.Volumes + + if got, want := volumes[0].Name, testSharedName; got != want { + t.Errorf("volumes[0].name = %q, want %q", got, want) + } + if got, want := volumes[1].Name, testSharedName+"-secret"; got != want { + t.Errorf("volumes[1].name = %q, want %q", got, want) + } +} + +func TestRenderPublicIPv4(t *testing.T) { + iface := mustRender(t, validInput(func(in *Input) { + in.PublicIPv4 = true + })).Spec.Template.Spec.NetworkInterfaces[0] + + wantFamilies := []networkingv1alpha.IPFamily{ + networkingv1alpha.IPv4Protocol, + networkingv1alpha.IPv6Protocol, + } + if delta := cmp.Diff(wantFamilies, iface.IPFamilies); delta != "" { + t.Errorf("ipFamilies mismatch (-want +got):\n%s", delta) + } + + wantAddresses := []computev1alpha.InstanceNetworkInterfaceAddressRequest{{Class: PublicIPv4Class}} + if delta := cmp.Diff(wantAddresses, iface.Addresses); delta != "" { + t.Errorf("addresses mismatch (-want +got):\n%s", delta) + } +} + +func TestRenderLabelsReachTheInstanceTemplate(t *testing.T) { + w := mustRender(t, validInput(func(in *Input) { + in.Labels = map[string]string{"tier": testLabelValue} + })) + + if got, want := w.Labels["tier"], testLabelValue; got != want { + t.Errorf("workload label = %q, want %q", got, want) + } + if got, want := w.Spec.Template.Labels["tier"], testLabelValue; got != want { + t.Errorf("template label = %q, want %q", got, want) + } +} + +func TestRenderVM(t *testing.T) { + template := mustRender(t, vmInput(func(in *Input) { + in.Ports = []Port{{Name: "ssh", Port: 22}} + in.ConfigMounts = []Mount{{Secret: testSecretName, MountPath: testCredsPath}} + })).Spec.Template + + if got, want := template.Annotations[computev1alpha.SSHKeysAnnotation], testSSHKey; got != want { + t.Errorf("ssh-keys annotation = %q, want %q", got, want) + } + + spec := template.Spec + if spec.Runtime.Sandbox != nil { + t.Error("sandbox set on a VM render") + } + vm := spec.Runtime.VirtualMachine + if vm == nil { + t.Fatal("virtualMachine not rendered") + } + if len(vm.Ports) != 1 { + t.Errorf("vm ports = %d, want 1", len(vm.Ports)) + } + + // The webhook requires the first attachment to be a bootable volume: a + // disk with an image populator. + if len(vm.VolumeAttachments) != 2 { + t.Fatalf("volumeAttachments = %d, want 2", len(vm.VolumeAttachments)) + } + boot := vm.VolumeAttachments[0] + if got, want := boot.Name, BootVolumeName; got != want { + t.Errorf("first attachment = %q, want %q", got, want) + } + if boot.MountPath != nil { + t.Error("the boot disk must be attached as a device, not mounted") + } + + bootVolume := spec.Volumes[0] + if bootVolume.Name != BootVolumeName { + t.Fatalf("volumes[0] = %q, want %q", bootVolume.Name, BootVolumeName) + } + if got, want := bootVolume.Disk.Template.Spec.Type, diskTypePDStandard; got != want { + t.Errorf("boot disk type = %q, want %q", got, want) + } + if got, want := bootVolume.Disk.Template.Spec.Populator.Image.Name, DefaultBootImage; got != want { + t.Errorf("boot image = %q, want %q", got, want) + } + // The image populator carries the size, so a storage request would be + // redundant. + if bootVolume.Disk.Template.Spec.Resources != nil { + t.Error("boot disk should take its size from the image populator") + } +} + +func TestRenderErrors(t *testing.T) { + cases := map[string]struct { + input Input + wantPath string + }{ + "no name": { + input: validInput(func(in *Input) { in.Name = "" }), + wantPath: "name", + }, + "invalid name": { + input: validInput(func(in *Input) { in.Name = "Not A Name" }), + wantPath: "name", + }, + "no image for a sandbox": { + input: validInput(func(in *Input) { in.Image = "" }), + wantPath: "image", + }, + "image on a vm": { + input: vmInput(func(in *Input) { in.Image = testImage }), + wantPath: "image", + }, + "no placements": { + input: validInput(func(in *Input) { in.Placements = nil }), + wantPath: "placements", + }, + "placement without city codes": { + input: validInput(func(in *Input) { in.Placements = []Placement{{Name: testPlacement}} }), + wantPath: "placements[0].cityCodes", + }, + "placement name is not a DNS label": { + input: validInput(func(in *Input) { in.Placements[0].Name = "US East" }), + wantPath: "placements[0].name", + }, + "too many replicas": { + input: validInput(func(in *Input) { in.Placements[0].MinReplicas = 1001 }), + wantPath: "placements[0].minReplicas", + }, + "negative replicas": { + input: validInput(func(in *Input) { in.Placements[0].MinReplicas = -1 }), + wantPath: "placements[0].minReplicas", + }, + "vm without ssh keys": { + input: vmInput(func(in *Input) { in.VM.SSHKeys = nil }), + wantPath: "vm.sshKeys", + }, + "ssh key without a username": { + input: vmInput(func(in *Input) { + _, key, _ := strings.Cut(testSSHKey, ":") + in.VM.SSHKeys = []string{":" + key} + }), + wantPath: testSSHKeysPath, + }, + "ssh key without a username separator": { + input: vmInput(func(in *Input) { in.VM.SSHKeys = []string{"ssh-ed25519 AAAA"} }), + wantPath: testSSHKeysPath, + }, + "unparseable ssh key": { + input: vmInput(func(in *Input) { in.VM.SSHKeys = []string{"user:not-a-key"} }), + wantPath: testSSHKeysPath, + }, + "duplicate port name": { + input: validInput(func(in *Input) { + in.Ports = []Port{{Name: testPortName, Port: 80}, {Name: testPortName, Port: 8080}} + }), + wantPath: "ports[1].name", + }, + "port out of range": { + input: validInput(func(in *Input) { in.Ports = []Port{{Name: testPortName, Port: 70000}} }), + wantPath: "ports[0].port", + }, + "mount with neither configMap nor secret": { + input: validInput(func(in *Input) { + in.ConfigMounts = []Mount{{Name: "cfg", MountPath: testMountPath}} + }), + wantPath: "configMounts[0]", + }, + "mount without a mount path": { + input: validInput(func(in *Input) { in.ConfigMounts = []Mount{{ConfigMap: testConfigMap}} }), + wantPath: "configMounts[0].mountPath", + }, + "colliding volume names": { + input: validInput(func(in *Input) { + in.ConfigMounts = []Mount{ + {ConfigMap: testSharedName, MountPath: testMountPath}, + {Secret: testSharedName, MountPath: testCredsPath}, + } + }), + wantPath: "configMounts[1].name", + }, + "duplicate mount paths": { + input: validInput(func(in *Input) { + in.ConfigMounts = []Mount{ + {ConfigMap: testConfigMap, MountPath: testMountPath}, + {Secret: testSecretName, MountPath: testMountPath}, + } + }), + wantPath: "configMounts[1].mountPath", + }, + "env var with two sources": { + input: validInput(func(in *Input) { + in.Env = []EnvVar{{ + Name: testEnvLiteral, + Value: "literal", + SecretKeyRef: &KeyRef{Name: testSecretName, Key: "k"}, + }} + }), + wantPath: "env[0]", + }, + "env on a vm": { + input: vmInput(func(in *Input) { in.Env = []EnvVar{{Name: testEnvLiteral, Value: "b"}} }), + wantPath: "env", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w, err := Render(tc.input) + if err == nil { + t.Fatalf("Render() succeeded, want an error mentioning %q", tc.wantPath) + } + if w != nil { + t.Error("Render() returned a workload alongside an error") + } + if !strings.Contains(err.Error(), tc.wantPath) { + t.Errorf("error %q does not mention %q", err.Error(), tc.wantPath) + } + }) + } +} + +// TestRenderedManifestsPassAdmission runs rendered manifests through the real +// admission validation, so this package is held to the webhook's rules rather +// than to a copy of them. +func TestRenderedManifestsPassAdmission(t *testing.T) { + inputs := map[string]Input{ + "minimal sandbox": validInput(), + "sandbox with ports, env, mounts and a public address": validInput(func(in *Input) { + in.Ports = []Port{{Name: testPortName, Port: 8080}} + in.Env = []EnvVar{ + {Name: testEnvLiteral, Value: testEnvValue}, + {Name: testEnvFromSecre, SecretKeyRef: &KeyRef{Name: testSecretName, Key: testSecretKey}}, + } + in.ConfigMounts = []Mount{ + {ConfigMap: testConfigMap, MountPath: testMountPath}, + {Secret: testSecretName, MountPath: testCredsPath}, + } + in.PublicIPv4 = true + in.Labels = map[string]string{"tier": testLabelValue} + }), + "vm with ssh keys, a boot disk and mounts": vmInput(func(in *Input) { + in.Ports = []Port{{Name: "ssh", Port: 22}} + in.ConfigMounts = []Mount{{Secret: testSecretName, MountPath: testCredsPath}} + }), + "multiple placements at the replica limits": validInput(func(in *Input) { + in.Placements = []Placement{ + {Name: testPlacement, CityCodes: []string{testCityCode}, MinReplicas: 1}, + {Name: testPlacement + "-east", CityCodes: []string{testCityCode}, MinReplicas: 1000}, + } + }), + } + + for name, in := range inputs { + t.Run(name, func(t *testing.T) { + w := mustRender(t, in) + + opts := validation.WorkloadValidationOptions{ + Context: context.Background(), + Client: allowAllClient(t), + Workload: w, + ValidCityCodes: []string{testCityCode}, + } + + if errs := validation.ValidateWorkloadCreate(w, opts); len(errs) > 0 { + t.Errorf("rendered manifest rejected by admission validation: %v", errs) + } + }) + } +} + +// allowAllClient returns a client that approves every SubjectAccessReview, the +// way the validation package's own tests stub authorization. +func allowAllClient(t *testing.T) client.Client { + t.Helper() + + scheme := k8sruntime.NewScheme() + utilruntime.Must(computev1alpha.AddToScheme(scheme)) + utilruntime.Must(networkingv1alpha.AddToScheme(scheme)) + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if sar, ok := obj.(*authorizationv1.SubjectAccessReview); ok { + // The fake client only accepts a create without a name when + // it can generate one. + sar.GenerateName = "sar-" + sar.Status.Allowed = true + } + return c.Create(ctx, obj, opts...) + }, + }). + WithObjects(&networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{Namespace: Namespace, Name: DefaultNetwork}, + }). + Build() +} + +// TestDeployFromFlagsParity pins the manifest the CLI's flag path produces to +// the one it produced before spec building moved into this package. The +// literal below is the previous deployFromFlags construction, verbatim. +func TestDeployFromFlagsParity(t *testing.T) { + const ( + instanceType = DefaultInstanceType + minReplicas = int32(2) + port = int32(8080) + ) + cities := []string{testCityCode, "IAD"} + + previous := func(withPort bool) computev1alpha.WorkloadSpec { + tcp := corev1.ProtocolTCP + container := computev1alpha.SandboxContainer{ + Name: "app", + Image: testImage, + } + if withPort { + container.Ports = []computev1alpha.NamedPort{ + {Name: testPortName, Port: port, Protocol: &tcp}, + } + } + + return computev1alpha.WorkloadSpec{ + Template: computev1alpha.InstanceTemplateSpec{ + Spec: computev1alpha.InstanceSpec{ + Runtime: computev1alpha.InstanceRuntimeSpec{ + Resources: computev1alpha.InstanceRuntimeResources{ + InstanceType: instanceType, + }, + Sandbox: &computev1alpha.SandboxRuntime{ + Containers: []computev1alpha.SandboxContainer{container}, + }, + }, + NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ + {Network: networkingv1alpha.NetworkRef{Name: "default"}}, + }, + }, + }, + Placements: []computev1alpha.WorkloadPlacement{{ + Name: "default", + CityCodes: cities, + ScaleSettings: computev1alpha.HorizontalScaleSettings{ + MinReplicas: minReplicas, + InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, + }, + }}, + } + } + + // What deployFromFlags builds now. + in := Input{ + Name: testWorkload, + Image: testImage, + InstanceType: instanceType, + Network: DefaultNetwork, + Placements: []Placement{{ + Name: DefaultPlacementName, + CityCodes: cities, + MinReplicas: minReplicas, + }}, + } + + t.Run("without a port", func(t *testing.T) { + got := mustRender(t, in).Spec + if delta := cmp.Diff(previous(false), got); delta != "" { + t.Errorf("spec differs from the pre-refactor manifest (-want +got):\n%s", delta) + } + }) + + // With a port the only intended difference is the ingress rule that makes + // the port reachable, which the flag path did not emit before. + t.Run("with a port", func(t *testing.T) { + withPort := in + withPort.Ports = []Port{{Name: testPortName, Port: port}} + + got := mustRender(t, withPort).Spec + if got.Template.Spec.NetworkInterfaces[0].NetworkPolicy == nil { + t.Fatal("expected an ingress rule for the exposed port") + } + + got = *got.DeepCopy() + got.Template.Spec.NetworkInterfaces[0].NetworkPolicy = nil + if delta := cmp.Diff(previous(true), got); delta != "" { + t.Errorf("spec differs from the pre-refactor manifest beyond the network policy (-want +got):\n%s", delta) + } + }) +} + +func TestDefaults(t *testing.T) { + d := Defaults() + if d.InstanceType != DefaultInstanceType || d.Network != DefaultNetwork { + t.Errorf("Defaults() = %+v, want the advertised instance type and network", d) + } + if len(d.Placements) != 1 || d.Placements[0].Name != DefaultPlacementName || + d.Placements[0].MinReplicas != DefaultMinReplicas { + t.Errorf("Defaults().Placements = %+v, want one default placement with one replica", d.Placements) + } + + // Defaults() is a starting point, not a renderable input on its own. + if _, err := Render(d); err == nil { + t.Error("Render(Defaults()) succeeded, want errors for the fields the caller must supply") + } +} + +func TestMarshalYAML(t *testing.T) { + w := mustRender(t, validInput(func(in *Input) { + in.Ports = []Port{{Name: testPortName, Port: 8080}} + })) + + data, err := MarshalYAML(w) + if err != nil { + t.Fatalf("MarshalYAML() error: %v", err) + } + out := string(data) + + for _, want := range []string{ + "apiVersion: compute.datumapis.com/v1alpha", + "kind: Workload", + "name: " + testWorkload, + "instanceType: " + DefaultInstanceType, + } { + if !strings.Contains(out, want) { + t.Errorf("rendered YAML is missing %q:\n%s", want, out) + } + } + + for _, unwanted := range []string{"status:", "creationTimestamp"} { + if strings.Contains(out, unwanted) { + t.Errorf("rendered YAML should not contain %q:\n%s", unwanted, out) + } + } + + if _, err := MarshalYAML(nil); err == nil { + t.Error("MarshalYAML(nil) succeeded, want an error") + } +} From dce6567a6a6fbf966acc21113ab22ba45eee32e6 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 9 Sep 2026 20:17:24 -0500 Subject: [PATCH 2/5] feat(agent): resolve placeable locations from ServiceAvailability compute_locations_list now reads compute's own ServiceAvailability records and the Locations they name, never LocationBindings. A control plane that does not serve those kinds fails loudly instead of answering with an empty list. Adds ServiceAvailability as a manager location source without changing the default. Co-Authored-By: Claude Fable 5.1 --- cmd/compute-mcp/main.go | 34 ++---- docs/agent/README.md | 2 +- docs/agent/llms-full.txt | 4 +- docs/agent/skills/workload-create.md | 2 +- internal/agent/discovery.go | 46 +++++--- internal/agent/discovery_test.go | 137 +++++++++++++++++++++++ internal/config/config.go | 8 +- internal/locations/locations.go | 134 ++++++++++++++++++++-- internal/locations/locations_test.go | 160 +++++++++++++++++++++++++++ 9 files changed, 473 insertions(+), 54 deletions(-) diff --git a/cmd/compute-mcp/main.go b/cmd/compute-mcp/main.go index 966c46e0..2bce6b33 100644 --- a/cmd/compute-mcp/main.go +++ b/cmd/compute-mcp/main.go @@ -55,10 +55,10 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/agent" - "go.datum.net/compute/internal/locations" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" + servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" ) const ( @@ -102,13 +102,6 @@ var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") - // locationSource selects which API group the discovery tools read a - // project's locations from. Deployment configuration, resolved once at - // startup and read by every request, mirroring how the manager takes it - // from its own config. The zero value reads the group every deployment - // serves today. - locationSource locations.Source - // planTokenKey signs the plan tokens compute_workload_plan mints and // compute_workload_apply checks. Deployment configuration, resolved once at // startup: every request reads it, and a key that differs between replicas @@ -117,25 +110,24 @@ var ( ) // The scheme carries every group a tool reads: compute's own objects for the -// diagnosis walk, plus networks, locations and quota for discovery. A group -// missing here fails at the first read with a scheme error, which says nothing -// about which tool wanted it. +// diagnosis walk, plus networks, quota, and — for the locations a project may +// place at — compute's service availability records and the Locations they +// name. A group missing here fails at the first read with a scheme error, which +// says nothing about which tool wanted it. func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(computev1alpha.AddToScheme(scheme)) utilruntime.Must(networkingv1alpha.AddToScheme(scheme)) utilruntime.Must(locationsv1alpha1.AddToScheme(scheme)) utilruntime.Must(quotav1alpha1.AddToScheme(scheme)) + utilruntime.Must(servicesv1alpha1.AddToScheme(scheme)) } func main() { - var addr, locationSourceFlag string + var addr string flag.StringVar(&addr, "addr", envOr("COMPUTE_MCP_ADDR", ":8080"), "address to serve MCP on") - flag.StringVar(&locationSourceFlag, "location-source", envOr("LOCATION_SOURCE", ""), - fmt.Sprintf("API group to read a project's locations from: %q or %q (default %q)", - locations.SourceNetworkServices, locations.SourceLocations, locations.SourceNetworkServices)) opts := zap.Options{Development: true} opts.BindFlags(flag.CommandLine) @@ -143,16 +135,6 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - // Resolved at startup rather than per request: a misspelled source is a - // deployment mistake, and it should stop the process rather than turn - // every compute_locations_list call into an error a customer sees. - resolvedSource, err := locations.Source(locationSourceFlag).Resolve() - if err != nil { - setupLog.Error(err, "refusing to start") - os.Exit(1) - } - locationSource = resolvedSource - key, err := resolvePlanTokenKey(os.Getenv(planTokenKeyEnv)) if err != nil { setupLog.Error(err, "refusing to start") @@ -339,7 +321,7 @@ func depsFromRequest(r *http.Request, baseConfig *rest.Config) agent.DepsFor { // being fetched with an identity the caller does not have. return agent.ToolDeps{ Reader: agent.NewClientReader(c), - Discoverer: agent.NewClientDiscoverer(c, locationSource), + Discoverer: agent.NewClientDiscoverer(c), Writer: agent.NewClientWriter(c), Namespace: resourceNamespace, // The project a plan is bound to is the header's, the same one the diff --git a/docs/agent/README.md b/docs/agent/README.md index 967c1e28..60e0dfbc 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -27,7 +27,7 @@ over Streamable HTTP: | Tools | Names | |---|---| | Diagnosis, read-only | `compute_workloads_list`, `compute_workloads_get`, `compute_instances_list`, `compute_workload_diagnose`, `compute_reason_explain` | -| Discovery, read-only | `compute_locations_list`, `compute_networks_list`, `compute_quota_get`, `compute_instance_types_list` — what a project may place, attach to, afford, and ask for | +| Discovery, read-only | `compute_locations_list`, `compute_networks_list`, `compute_quota_get`, `compute_instance_types_list` — what a project may place, attach to, afford, and ask for. The locations come from compute's own availability records, so the list is where compute is offered and this project can use it. | | Planning, writes nothing | `compute_workload_render` (inputs to a manifest, pure), `compute_workload_validate` (the server's verdict on that manifest without creating it) | | Mutating | `compute_workload_plan`, `compute_workload_apply` | diff --git a/docs/agent/llms-full.txt b/docs/agent/llms-full.txt index 64747467..faf7c437 100644 --- a/docs/agent/llms-full.txt +++ b/docs/agent/llms-full.txt @@ -230,7 +230,9 @@ there, and this section deliberately does not restate them. compute_reason_explain any reason, explained, classified, and — when transient — the window it should clear inside compute_locations_list the city codes this project may place a workload - in + in, taken from compute's availability records — + a city absent from it is one compute is not + offered in compute_networks_list the networks an interface may attach to compute_quota_get how much compute the project is allowed, and what is left diff --git a/docs/agent/skills/workload-create.md b/docs/agent/skills/workload-create.md index a8fac61f..2ba18ded 100644 --- a/docs/agent/skills/workload-create.md +++ b/docs/agent/skills/workload-create.md @@ -34,7 +34,7 @@ different answer: | Check | Tool | If it fails | |---|---|---| | Compute is enabled for the project | `compute_locations_list` | Nothing can be placed. Datum's to enable — the user runs `datumctl compute access request`, and approval is a manual step on Datum's side. | -| Somewhere to run it | `compute_locations_list` | The city codes it returns are the only ones a placement may name. An empty list means nothing is available to this project yet; that is Datum's, not something the user can add. | +| Somewhere to run it | `compute_locations_list` | The city codes it returns are the only ones a placement may name; they come from compute's own availability records, so a city missing from the list is one compute is not offered in. An empty list means nothing is available to this project yet; that is Datum's, not something the user can add. | | A network | `compute_networks_list` | `default` by convention. If it is missing, `compute_workload_plan` says so and `compute_workload_apply` creates it alongside the workload — say so when you show the plan, because it is a second object being created. | | Quota | `compute_quota_get` | Quota is granted by Datum and cannot be self-served. A project with none can still create a workload; its instances then sit at `QuotaGranted=False` with `QuotaNoBudget` and never start. | diff --git a/internal/agent/discovery.go b/internal/agent/discovery.go index 9991a252..42362763 100644 --- a/internal/agent/discovery.go +++ b/internal/agent/discovery.go @@ -4,6 +4,7 @@ package agent import ( "context" + "errors" "fmt" "sort" @@ -64,24 +65,38 @@ type ClientDiscoverer struct { // credential of its own; the numbers are read from the project either way, // and only the unit labels fall back to a generic form without it. PlatformClient client.Client - - // Source selects which API group locations are read from. The zero value - // reads the group every deployment serves today, matching the manager's - // own default. - Source locations.Source } var _ Discoverer = (*ClientDiscoverer)(nil) -// NewClientDiscoverer returns a Discoverer backed by c, reading locations from -// source. -func NewClientDiscoverer(c client.Client, source locations.Source) *ClientDiscoverer { - return &ClientDiscoverer{Client: c, Source: source} +// NewClientDiscoverer returns a Discoverer backed by c. +func NewClientDiscoverer(c client.Client) *ClientDiscoverer { + return &ClientDiscoverer{Client: c} } +// ListPlacementLocations reads compute's own availability records. There is no +// choice of source here: what an assistant needs is where compute is offered +// and this project can use it, and only the availability records say that. The +// manager still reads placement per its own configuration; this is the answer a +// customer is given, and it is the same one wherever they ask. func (d *ClientDiscoverer) ListPlacementLocations(ctx context.Context) ([]locations.PlacementLocation, error) { - found, err := locations.ListPlacementLocations(ctx, d.Client, d.Source) + found, err := locations.ListPlacementLocations(ctx, d.Client, locations.SourceServiceAvailability) if err != nil { + // A project that cannot answer the question at all must not be + // reported as a project with nowhere to run: the first is a deployment + // to fix, the second is a wait, and an assistant told the wrong one + // sends the customer to argue with the wrong people. The kind travels + // as evidence inside the wrapped error, the sentence does not lean on + // it, and the blame is placed where the fix is. + if errors.Is(err, locations.ErrAvailabilityNotServed) { + return nil, fmt.Errorf( + "compute could not read where it is offered from this project, because the service "+ + "that publishes availability is not reachable here. This is a problem with how "+ + "Datum is deployed for this project, not with the workload and not with the "+ + "person who asked: nothing in a workload can be changed to fix it, and "+ + "re-authenticating will not help. Underlying detail, for whoever operates "+ + "Datum: %w", err) + } return nil, fmt.Errorf("listing the locations this project may place at: %w", err) } return found, nil @@ -184,11 +199,12 @@ func RegisterDiscoveryTools(s *mcp.Server, deps DepsFor) { mcp.AddTool(s, &mcp.Tool{ Name: ToolLocationsList, Title: "List locations", - Description: "List the locations this project may place a Workload in, each with its city code " + - "(e.g. \"DFW\") and the attributes it declares. These are the only places this project may " + - "place a workload: a location missing from this list either does not offer compute at all " + - "or this project is not entitled to it, and a placement naming it will never come up. Call " + - "this before writing a Workload's placements rather than guessing a city. Read-only.", + Description: "List the locations where compute is offered and this project can use it, each with " + + "its city code (e.g. \"DFW\") and the attributes it declares. The list is derived from " + + "compute's own availability records, so it is where compute is actually running, not where " + + "it might be: a location missing from this list is one compute is not offered in, and a " + + "placement naming it will never come up. Call this before writing a Workload's placements " + + "rather than guessing a city. Read-only.", }, locationsList(deps)) mcp.AddTool(s, &mcp.Tool{ diff --git a/internal/agent/discovery_test.go b/internal/agent/discovery_test.go index fcd61888..00cea794 100644 --- a/internal/agent/discovery_test.go +++ b/internal/agent/discovery_test.go @@ -8,11 +8,19 @@ import ( "testing" "github.com/modelcontextprotocol/go-sdk/mcp" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "go.datum.net/compute/internal/locations" "go.datum.net/compute/internal/quotaview" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" ) // fakeDiscoverer serves canned entitlement facts so the discovery tools can be @@ -355,3 +363,132 @@ func TestDiscoveryToolsAnswerOverTheWire(t *testing.T) { t.Errorf("locations[0].cityCode = %q, want the city the location declares", out.Locations[0].CityCode) } } + +// TestClientDiscovererReadsComputeAvailability covers the one Discoverer that +// talks to a control plane. The tools above run against a fake, so nothing else +// proves that the locations an assistant is shown are the ones compute reports +// itself available at — not every location the platform has, and not another +// service's. +func TestClientDiscovererReadsComputeAvailability(t *testing.T) { + scheme := runtime.NewScheme() + if err := locationsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering locations: %v", err) + } + if err := servicesv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering service availability: %v", err) + } + + location := func(name, cityCode string) *locationsv1alpha1.Location { + return &locationsv1alpha1.Location{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: locationsv1alpha1.LocationSpec{ + LocationClassRef: locationsv1alpha1.LocationClassReference{Name: "datum-managed"}, + Topology: map[string]string{locations.TopologyCityCodeKey: cityCode}, + }, + } + } + availability := func(name, service, at string, status metav1.ConditionStatus) *servicesv1alpha1.ServiceAvailability { + return &servicesv1alpha1.ServiceAvailability{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: servicesv1alpha1.ServiceAvailabilitySpec{ + ServiceRef: servicesv1alpha1.ServiceRef{Name: service}, + LocationRef: servicesv1alpha1.LocationRef{Name: at}, + }, + Status: servicesv1alpha1.ServiceAvailabilityStatus{ + Conditions: []metav1.Condition{{ + Type: "Available", + Status: status, + Reason: "Reported", + LastTransitionTime: metav1.Now(), + }}, + }, + } + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects( + location("us-south-dfw", cityDFW), + location("eu-west-ams", cityAMS), + location("us-east-iad", "IAD"), + availability("compute-dfw", "compute", "us-south-dfw", metav1.ConditionTrue), + // Compute is not up here yet, so it is not somewhere to place. + availability("compute-ams", "compute", "eu-west-ams", metav1.ConditionFalse), + // Another service is available at IAD. Compute is not, and the + // project's control plane carries every service's records. + availability("dns-iad", "dns", "us-east-iad", metav1.ConditionTrue), + ). + Build() + + found, err := NewClientDiscoverer(cl).ListPlacementLocations(context.Background()) + if err != nil { + t.Fatalf("ListPlacementLocations: %v", err) + } + if len(found) != 1 { + t.Fatalf("got %d locations %+v, want only the one compute is available at", len(found), found) + } + if found[0].Name != "us-south-dfw" { + t.Errorf("location = %q, want us-south-dfw", found[0].Name) + } + if code, ok := found[0].CityCode(); !ok || code != cityDFW { + t.Errorf("cityCode = %q (declared %v), want %q from the location itself", code, ok, cityDFW) + } +} + +// TestLocationsListBlamesTheDeploymentWhenAvailabilityIsNotServed is the case +// the empty list must never be given for. A project that cannot be asked where +// compute is offered has to say so: told "no locations", a customer waits for +// Datum to add one, and nobody ever looks at the deployment that is actually +// broken. +func TestLocationsListBlamesTheDeploymentWhenAvailabilityIsNotServed(t *testing.T) { + scheme := runtime.NewScheme() + if err := locationsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering locations: %v", err) + } + if err := servicesv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("registering service availability: %v", err) + } + + // Nothing here serves the availability records, the way a project the + // service was never installed for behaves. + notServed := interceptor.Funcs{ + List: func( + ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption, + ) error { + if _, ok := list.(*servicesv1alpha1.ServiceAvailabilityList); ok { + return &apimeta.NoKindMatchError{GroupKind: schema.GroupKind{Kind: "ServiceAvailability"}} + } + return c.List(ctx, list, opts...) + }, + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(notServed).Build() + disc := NewClientDiscoverer(cl) + + found, err := disc.ListPlacementLocations(context.Background()) + if err == nil { + t.Fatalf("ListPlacementLocations returned %+v and no error; a kind nobody serves must not "+ + "read as a project with nowhere to run", found) + } + if !errors.Is(err, locations.ErrAvailabilityNotServed) { + t.Errorf("error = %v, want it to stay identifiable as the availability read failing", err) + } + + // The tool has to relay it, not swallow it into an empty list. + _, out, err := locationsList(discoveryDeps(disc))(context.Background(), nil, LocationsListInput{}) + if err == nil { + t.Fatalf("%s returned %+v and no error", ToolLocationsList, out) + } + + // What the customer is told: the deployment is at fault, they are not, and + // nothing in the wording is Datum's internal vocabulary. + msg := err.Error() + for _, want := range []string{"deployed", "not with the person who asked", "re-authenticating will not help"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q does not say %q; the customer must not be sent to fix their workload", msg, want) + } + } + terms := append(internalVocabulary(), customerFacingOnly()...) + // "ServiceAvailability" travels as an identifier, as a reason code does. + checkCopy(t, ToolLocationsList+" not-served error", msg, terms, "ServiceAvailability") +} diff --git a/internal/config/config.go b/internal/config/config.go index 4b832b50..bb1dfc82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,9 +46,11 @@ type WorkloadOperator struct { // LocationSource names the API group locations are read from. Use // "NetworkServices" for networking.datumapis.com LocationBindings and - // ServingLocations, or "Locations" for the dedicated locations.miloapis.com - // service. It governs reads only; nothing about what compute writes changes - // with it. Defaults to "NetworkServices". + // ServingLocations, "Locations" for the dedicated locations.miloapis.com + // service, or "ServiceAvailability" to take the placeable locations from + // compute's own services.miloapis.com availability records and their + // Locations. It governs reads only; nothing about what compute writes + // changes with it. Defaults to "NetworkServices". LocationSource locations.Source `json:"locationSource,omitempty"` } diff --git a/internal/locations/locations.go b/internal/locations/locations.go index 09c9026a..cefc1f0b 100644 --- a/internal/locations/locations.go +++ b/internal/locations/locations.go @@ -7,10 +7,15 @@ // locations service. Which one is read is selected per deployment by Source, // so a control plane that has not been migrated keeps reading the types it // already has. +// +// Placement can also be read from the service catalog: a ServiceAvailability +// records that a service is deployed and operational at a Location, which is +// the fact "may this project place compute here?" actually rests on. package locations import ( "context" + "errors" "fmt" "strings" @@ -22,6 +27,7 @@ import ( networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" ) const ( @@ -31,6 +37,16 @@ const ( // ServingLocationTopologyLabel is the cluster label a cell carries to claim // the location it serves. ServingLocationTopologyLabel = locationsv1alpha1.ServingLocationTopologyLabel + + // DefaultServiceName is the service whose availability records name the + // locations a project may place at. Compute reads its own. + DefaultServiceName = "compute" + + // conditionAvailable is the ServiceAvailability condition that reports the + // service deployed and validated at the location. The service catalog keeps + // the constant inside its controller package, so the string is repeated + // here rather than importing an internal package. + conditionAvailable = "Available" ) // Source names the API group locations are read from. @@ -44,6 +60,15 @@ const ( // SourceLocations reads locations.miloapis.com Locations and // ServingLocations, served by the locations service. SourceLocations Source = "Locations" + + // SourceServiceAvailability reads services.miloapis.com + // ServiceAvailability records: a location is placeable when the service + // reports itself available there. The topology still comes from the + // locations.miloapis.com Location each record points at, so this source + // reads both kinds. Serving locations are unaffected — availability is a + // statement about a location, not about a cell — and are read from the + // locations service. + SourceServiceAvailability Source = "ServiceAvailability" ) // Resolve reports which source to read. An unset source reads network @@ -54,11 +79,28 @@ func (s Source) Resolve() (Source, error) { return SourceNetworkServices, nil case SourceLocations: return SourceLocations, nil + case SourceServiceAvailability: + return SourceServiceAvailability, nil default: - return "", fmt.Errorf("unknown location source %q, want %q or %q", s, SourceNetworkServices, SourceLocations) + return "", fmt.Errorf("unknown location source %q, want %q, %q or %q", + s, SourceNetworkServices, SourceLocations, SourceServiceAvailability) } } +// ErrAvailabilityNotServed reports that a project does not serve one of the two +// kinds SourceServiceAvailability reads, so where compute is offered cannot be +// answered at all. +// +// This does NOT degrade to no locations, where the other sources do. An empty +// list is a real answer — compute is offered nowhere this project may use — +// and returning it for a kind nobody is serving tells a customer their project +// has no locations when the truth is that nothing looked. The two are opposite +// actions: one waits for Datum to add a location, the other is a deployment +// that needs fixing, so they must never arrive as the same answer. +// +// Wrapped with the kind that was missing, and matched with errors.Is. +var ErrAvailabilityNotServed = errors.New("where compute is offered cannot be read from this project") + // PlacementLocation is a location a project may place workloads at. type PlacementLocation struct { Name string @@ -83,14 +125,26 @@ func (l ServingLocation) CityCode() string { } // ListPlacementLocations returns the locations a project may place workloads -// at, read from the project's control plane. +// at, read from the project's control plane. Availability records are read for +// DefaultServiceName; ListPlacementLocationsForService names another service. func ListPlacementLocations(ctx context.Context, c client.Client, source Source) ([]PlacementLocation, error) { + return ListPlacementLocationsForService(ctx, c, source, DefaultServiceName) +} + +// ListPlacementLocationsForService is ListPlacementLocations for a named +// service. The name is only read by SourceServiceAvailability, which is the +// only source that knows which service a location is offered for; the other +// two sources have already been filtered to one service by whoever wrote them. +func ListPlacementLocationsForService( + ctx context.Context, c client.Client, source Source, serviceName string, +) ([]PlacementLocation, error) { resolved, err := source.Resolve() if err != nil { return nil, err } - if resolved == SourceNetworkServices { + switch resolved { + case SourceNetworkServices: var bindings networkingv1alpha.LocationBindingList if err := c.List(ctx, &bindings); err != nil { return nil, fmt.Errorf("failed to list location bindings: %w", err) @@ -104,6 +158,9 @@ func ListPlacementLocations(ctx context.Context, c client.Client, source Source) }) } return found, nil + + case SourceServiceAvailability: + return listAvailableLocations(ctx, c, serviceName) } var list locationsv1alpha1.LocationList @@ -124,6 +181,65 @@ func ListPlacementLocations(ctx context.Context, c client.Client, source Source) return found, nil } +// listAvailableLocations returns the locations serviceName reports itself +// available at, with the topology of the Location each record names. +// +// A control plane serves availability records for every service, so filtering +// on the service is what makes the answer compute's rather than the platform's. +// +// The Locations are listed once and indexed rather than fetched one at a time: +// a record per service per location makes the per-record read the expensive +// shape. A record naming a Location that is not there is skipped, not failed — +// the two objects are written by different services, and a project that can +// read one but not the other must still see the locations it can. A KIND that +// is not served is the opposite case and fails: see ErrAvailabilityNotServed. +func listAvailableLocations(ctx context.Context, c client.Client, serviceName string) ([]PlacementLocation, error) { + var availability servicesv1alpha1.ServiceAvailabilityList + if err := c.List(ctx, &availability); err != nil { + if kindNotInstalled(err) { + return nil, fmt.Errorf("%w: %s is not served here: %w", + ErrAvailabilityNotServed, "ServiceAvailability", err) + } + return nil, fmt.Errorf("failed to list service availability: %w", err) + } + + var list locationsv1alpha1.LocationList + if err := c.List(ctx, &list); err != nil { + if kindNotInstalled(err) { + return nil, fmt.Errorf("%w: %s is not served here: %w", + ErrAvailabilityNotServed, "Location", err) + } + return nil, fmt.Errorf("failed to list locations: %w", err) + } + + byName := make(map[string]*locationsv1alpha1.Location, len(list.Items)) + for i := range list.Items { + byName[list.Items[i].Name] = &list.Items[i] + } + + found := make([]PlacementLocation, 0, len(availability.Items)) + seen := sets.Set[string]{} + for i := range availability.Items { + record := &availability.Items[i] + if record.Spec.ServiceRef.Name != serviceName { + continue + } + if !apimeta.IsStatusConditionTrue(record.Status.Conditions, conditionAvailable) { + continue + } + location, ok := byName[record.Spec.LocationRef.Name] + if !ok || seen.Has(location.Name) { + continue + } + seen.Insert(location.Name) + found = append(found, PlacementLocation{ + Name: location.Name, + Topology: location.Spec.Topology, + }) + } + return found, nil +} + // ListServingLocations returns the locations delivered to a cell. func ListServingLocations(ctx context.Context, c client.Client, source Source) ([]ServingLocation, error) { resolved, err := source.Resolve() @@ -166,7 +282,8 @@ func ListServingLocations(ctx context.Context, c client.Client, source Source) ( } // ServingLocationObject returns the object a controller watches to learn that -// a cell has been told where it sits. +// a cell has been told where it sits. Availability records say nothing about +// cells, so that source watches the locations service's kind. func ServingLocationObject(source Source) (client.Object, error) { resolved, err := source.Resolve() if err != nil { @@ -226,11 +343,14 @@ func crdName(gvk schema.GroupVersionKind) string { return fmt.Sprintf("%ss.%s", strings.ToLower(gvk.Kind), gvk.Group) } +// otherSource names a source that watches a different ServingLocation kind, so +// the error can suggest one worth trying. Only network services serves its own +// kind; every other source reads the locations service's. func otherSource(source Source) Source { - if source == SourceLocations { - return SourceNetworkServices + if source == SourceNetworkServices { + return SourceLocations } - return SourceLocations + return SourceNetworkServices } // CityCodes returns the cities the given locations serve. diff --git a/internal/locations/locations_test.go b/internal/locations/locations_test.go index e32a732b..80119200 100644 --- a/internal/locations/locations_test.go +++ b/internal/locations/locations_test.go @@ -12,12 +12,15 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" + servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" ) const ( @@ -31,6 +34,7 @@ func testScheme(t *testing.T) *runtime.Scheme { s := runtime.NewScheme() require.NoError(t, networkingv1alpha.AddToScheme(s)) require.NoError(t, locationsv1alpha1.AddToScheme(s)) + require.NoError(t, servicesv1alpha1.AddToScheme(s)) return s } @@ -54,6 +58,29 @@ func newLocation(name, cityCode string) *locationsv1alpha1.Location { } } +// newAvailability records serviceName as available, or not, at location. +func newAvailability(name, serviceName, location string, available bool) *servicesv1alpha1.ServiceAvailability { + status := metav1.ConditionFalse + if available { + status = metav1.ConditionTrue + } + return &servicesv1alpha1.ServiceAvailability{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: servicesv1alpha1.ServiceAvailabilitySpec{ + ServiceRef: servicesv1alpha1.ServiceRef{Name: serviceName}, + LocationRef: servicesv1alpha1.LocationRef{Name: location}, + }, + Status: servicesv1alpha1.ServiceAvailabilityStatus{ + Conditions: []metav1.Condition{{ + Type: conditionAvailable, + Status: status, + Reason: "Reported", + LastTransitionTime: metav1.Now(), + }}, + }, + } +} + // TestTopologyKeysAgreeAcrossSources guards the migration's central assumption: // a city code means the same thing whichever source served it. If the two // groups ever disagree, switching sources would silently repoint every @@ -76,6 +103,7 @@ func TestSourceResolve(t *testing.T) { {source: "", want: SourceNetworkServices, wantOK: true}, {source: SourceNetworkServices, want: SourceNetworkServices, wantOK: true}, {source: SourceLocations, want: SourceLocations, wantOK: true}, + {source: SourceServiceAvailability, want: SourceServiceAvailability, wantOK: true}, {source: "Nonsense"}, } { resolved, err := tc.source.Resolve() @@ -130,6 +158,138 @@ func TestListPlacementLocations_Locations(t *testing.T) { assert.ElementsMatch(t, []string{testCityCode, testOtherCityCode}, CityCodes(found).UnsortedList()) } +// TestListPlacementLocations_ServiceAvailability covers the four shapes a +// control plane actually serves: compute available, compute not available, a +// location another service is available at, and a record whose Location is not +// there. Only the first is placeable. +func TestListPlacementLocations_ServiceAvailability(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + newLocation("dfw", testCityCode), + newLocation("ord", testOtherCityCode), + newLocation("lhr", "LHR"), + newAvailability("compute-dfw", DefaultServiceName, "dfw", true), + // Deployed but not yet validated: not somewhere to place. + newAvailability("compute-ord", DefaultServiceName, "ord", false), + // Another service is available at lhr; compute is not offered + // there, and a control plane serves every service's records. + newAvailability("dns-lhr", "dns", "lhr", true), + // A record whose Location is gone is skipped, not failed: it + // carries no topology to place against. + newAvailability("compute-nowhere", DefaultServiceName, "atl", true), + ). + Build() + + found, err := ListPlacementLocations(context.Background(), cl, SourceServiceAvailability) + require.NoError(t, err) + require.Len(t, found, 1) + assert.Equal(t, "dfw", found[0].Name) + assert.Equal(t, []string{testCityCode}, CityCodes(found).UnsortedList()) +} + +// TestListPlacementLocations_ServiceAvailabilityNamesTheService proves the +// filter is the service's name and not "whatever is available": the same +// control plane answers differently for a different service. +func TestListPlacementLocations_ServiceAvailabilityNamesTheService(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + newLocation("dfw", testCityCode), + newLocation("ord", testOtherCityCode), + newAvailability("compute-dfw", DefaultServiceName, "dfw", true), + newAvailability("dns-ord", "dns", "ord", true), + ). + Build() + + ctx := context.Background() + + found, err := ListPlacementLocationsForService(ctx, cl, SourceServiceAvailability, "dns") + require.NoError(t, err) + require.Len(t, found, 1) + assert.Equal(t, "ord", found[0].Name) + + found, err = ListPlacementLocationsForService(ctx, cl, SourceServiceAvailability, DefaultServiceName) + require.NoError(t, err) + require.Len(t, found, 1) + assert.Equal(t, "dfw", found[0].Name) +} + +// TestListPlacementLocations_ServiceAvailabilityIgnoresLocationBindings keeps +// the sources apart: an availability read must never fall back to the bindings +// a control plane happens to still carry. +func TestListPlacementLocations_ServiceAvailabilityIgnoresLocationBindings(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(newBinding("lhr", "LHR"), newLocation("lhr", "LHR")). + Build() + + found, err := ListPlacementLocations(context.Background(), cl, SourceServiceAvailability) + require.NoError(t, err) + assert.Empty(t, found) +} + +// TestListPlacementLocations_ServiceAvailabilityFailsWhenNotServed is the +// difference between "compute is offered nowhere" and "nothing looked". Either +// kind missing must fail, and fail identifiably, rather than answer with an +// empty list that reads exactly like a project waiting on Datum. +func TestListPlacementLocations_ServiceAvailabilityFailsWhenNotServed(t *testing.T) { + t.Parallel() + + // The list object carries no GVK until the client fills it in, so the kind + // to withhold is matched on the Go type the caller asked for. + noMatchFor := func(kinds ...string) interceptor.Funcs { + missing := sets.New(kinds...) + return interceptor.Funcs{ + List: func( + ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption, + ) error { + var kind string + switch list.(type) { + case *servicesv1alpha1.ServiceAvailabilityList: + kind = "ServiceAvailability" + case *locationsv1alpha1.LocationList: + kind = "Location" + } + if kind != "" && missing.Has(kind) { + return &apimeta.NoKindMatchError{ + GroupKind: schema.GroupKind{Kind: kind}, + } + } + return c.List(ctx, list, opts...) + }, + } + } + + for name, missing := range map[string][]string{ + "availability records are not served": {"ServiceAvailability"}, + "locations are not served": {"Location"}, + "neither is served": {"ServiceAvailability", "Location"}, + } { + t.Run(name, func(t *testing.T) { + cl := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + newLocation("dfw", testCityCode), + newAvailability("compute-dfw", DefaultServiceName, "dfw", true), + ). + WithInterceptorFuncs(noMatchFor(missing...)). + Build() + + found, err := ListPlacementLocations(context.Background(), cl, SourceServiceAvailability) + require.Error(t, err, "a kind nobody serves must never read as no locations") + assert.ErrorIs(t, err, ErrAvailabilityNotServed) + assert.Empty(t, found) + }) + } +} + func TestListPlacementLocations_UnknownSource(t *testing.T) { t.Parallel() From 301fc9234423a14c0ca5a901c7f4066eb167b178 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 10 Sep 2026 09:32:59 -0500 Subject: [PATCH 3/5] chore(lint): name repeated resource-type, unit, and kind literals Co-Authored-By: Claude Fable 5.1 --- internal/agent/discovery_test.go | 15 +++++++++------ internal/locations/locations_test.go | 16 +++++++++++----- internal/quotaview/quota.go | 26 ++++++++++++++++++-------- internal/quotaview/quota_test.go | 22 +++++++++++----------- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/internal/agent/discovery_test.go b/internal/agent/discovery_test.go index 00cea794..642733a8 100644 --- a/internal/agent/discovery_test.go +++ b/internal/agent/discovery_test.go @@ -53,7 +53,7 @@ func fixtureDiscoverer() *fakeDiscoverer { return &fakeDiscoverer{ // Deliberately out of alphabetical order, so the sort is proven. locations: []locations.PlacementLocation{ - {Name: "us-south-dfw", Topology: map[string]string{locations.TopologyCityCodeKey: cityDFW}}, + {Name: locDFW, Topology: map[string]string{locations.TopologyCityCodeKey: cityDFW}}, {Name: "eu-west-ams", Topology: map[string]string{locations.TopologyCityCodeKey: cityAMS}}, {Name: "no-city", Topology: map[string]string{"topology.datum.net/region": "unknown"}}, }, @@ -108,7 +108,7 @@ func TestLocationsListReportsCityCodesAndSortsByName(t *testing.T) { t.Fatalf("got %d locations, want 3", len(out.Locations)) } - wantOrder := []string{"eu-west-ams", "no-city", "us-south-dfw"} + wantOrder := []string{"eu-west-ams", "no-city", locDFW} for i, want := range wantOrder { if got := out.Locations[i].Name; got != want { t.Errorf("locations[%d] = %q, want %q (the list must be sorted by name)", i, got, want) @@ -119,7 +119,7 @@ func TestLocationsListReportsCityCodesAndSortsByName(t *testing.T) { for _, l := range out.Locations { byName[l.Name] = l } - if got := byName["us-south-dfw"].CityCode; got != cityDFW { + if got := byName[locDFW].CityCode; got != cityDFW { t.Errorf("us-south-dfw cityCode = %q, want %q", got, cityDFW) } // A location with no city is reported rather than dropped: a placement that @@ -408,10 +408,10 @@ func TestClientDiscovererReadsComputeAvailability(t *testing.T) { cl := fake.NewClientBuilder(). WithScheme(scheme). WithObjects( - location("us-south-dfw", cityDFW), + location(locDFW, cityDFW), location("eu-west-ams", cityAMS), location("us-east-iad", "IAD"), - availability("compute-dfw", "compute", "us-south-dfw", metav1.ConditionTrue), + availability("compute-dfw", "compute", locDFW, metav1.ConditionTrue), // Compute is not up here yet, so it is not somewhere to place. availability("compute-ams", "compute", "eu-west-ams", metav1.ConditionFalse), // Another service is available at IAD. Compute is not, and the @@ -427,7 +427,7 @@ func TestClientDiscovererReadsComputeAvailability(t *testing.T) { if len(found) != 1 { t.Fatalf("got %d locations %+v, want only the one compute is available at", len(found), found) } - if found[0].Name != "us-south-dfw" { + if found[0].Name != locDFW { t.Errorf("location = %q, want us-south-dfw", found[0].Name) } if code, ok := found[0].CityCode(); !ok || code != cityDFW { @@ -492,3 +492,6 @@ func TestLocationsListBlamesTheDeploymentWhenAvailabilityIsNotServed(t *testing. // "ServiceAvailability" travels as an identifier, as a reason code does. checkCopy(t, ToolLocationsList+" not-served error", msg, terms, "ServiceAvailability") } + +// locDFW is the location name the discovery tests place in DFW. +const locDFW = "us-south-dfw" diff --git a/internal/locations/locations_test.go b/internal/locations/locations_test.go index 80119200..4d675c7c 100644 --- a/internal/locations/locations_test.go +++ b/internal/locations/locations_test.go @@ -253,9 +253,9 @@ func TestListPlacementLocations_ServiceAvailabilityFailsWhenNotServed(t *testing var kind string switch list.(type) { case *servicesv1alpha1.ServiceAvailabilityList: - kind = "ServiceAvailability" + kind = kindServiceAvailability case *locationsv1alpha1.LocationList: - kind = "Location" + kind = kindLocation } if kind != "" && missing.Has(kind) { return &apimeta.NoKindMatchError{ @@ -268,9 +268,9 @@ func TestListPlacementLocations_ServiceAvailabilityFailsWhenNotServed(t *testing } for name, missing := range map[string][]string{ - "availability records are not served": {"ServiceAvailability"}, - "locations are not served": {"Location"}, - "neither is served": {"ServiceAvailability", "Location"}, + "availability records are not served": {kindServiceAvailability}, + "locations are not served": {kindLocation}, + "neither is served": {kindServiceAvailability, kindLocation}, } { t.Run(name, func(t *testing.T) { cl := fake.NewClientBuilder(). @@ -432,3 +432,9 @@ func TestServingLocationGVK(t *testing.T) { _, err = ServingLocationGVK("Nonsense") require.Error(t, err) } + +// Kinds the not-served tests withhold from the fake client. +const ( + kindServiceAvailability = "ServiceAvailability" + kindLocation = "Location" +) diff --git a/internal/quotaview/quota.go b/internal/quotaview/quota.go index 0fced74f..0f01a488 100644 --- a/internal/quotaview/quota.go +++ b/internal/quotaview/quota.go @@ -55,13 +55,23 @@ type QuotaMeta struct { Order int } +// Compute's quota resource types, as registered with the platform. +const ( + ResourceTypeWorkloads = "compute.datumapis.com/workloads" + ResourceTypeInstances = "compute.datumapis.com/instances" + ResourceTypeVCPUs = "compute.datumapis.com/vcpus" + ResourceTypeMemory = "compute.datumapis.com/memory" + + unitVCPUs = "vCPUs" +) + // ComputeOrderedTypes is the order compute's resource types are displayed in: // the things a person counts first, first. var ComputeOrderedTypes = []string{ - "compute.datumapis.com/workloads", - "compute.datumapis.com/instances", - "compute.datumapis.com/vcpus", - "compute.datumapis.com/memory", + ResourceTypeWorkloads, + ResourceTypeInstances, + ResourceTypeVCPUs, + ResourceTypeMemory, } // ComputeMeta supplies display overrides for compute's resource types. The @@ -69,10 +79,10 @@ var ComputeOrderedTypes = []string{ // nothing, so the units are named here instead. vCPUs are stored in // millicores, hence the divisor. var ComputeMeta = map[string]QuotaMeta{ - "compute.datumapis.com/workloads": {DisplayName: "Workloads", Unit: "workloads", Divisor: 1}, - "compute.datumapis.com/instances": {DisplayName: "Instances", Unit: "instances", Divisor: 1}, - "compute.datumapis.com/vcpus": {DisplayName: "vCPUs", Unit: "vCPUs", Divisor: 1000}, - "compute.datumapis.com/memory": {DisplayName: "Memory", Unit: "MiB", Divisor: 1}, + ResourceTypeWorkloads: {DisplayName: "Workloads", Unit: "workloads", Divisor: 1}, + ResourceTypeInstances: {DisplayName: "Instances", Unit: "instances", Divisor: 1}, + ResourceTypeVCPUs: {DisplayName: unitVCPUs, Unit: unitVCPUs, Divisor: 1000}, + ResourceTypeMemory: {DisplayName: "Memory", Unit: "MiB", Divisor: 1}, } // ListServiceQuota returns quota rows for the project's quota whose resource diff --git a/internal/quotaview/quota_test.go b/internal/quotaview/quota_test.go index 5197d2dd..61b78179 100644 --- a/internal/quotaview/quota_test.go +++ b/internal/quotaview/quota_test.go @@ -43,9 +43,9 @@ func projectClient(t *testing.T, objs ...client.Object) client.Client { func TestListComputeQuotaOrdersRowsAndConvertsUnits(t *testing.T) { c := projectClient(t, // Inserted out of display order, so the ordering is proven. - bucket("compute.datumapis.com/vcpus", 8000, 3000, 5000), - bucket("compute.datumapis.com/workloads", 10, 3, 7), - bucket("compute.datumapis.com/memory", 16384, 4096, 12288), + bucket(ResourceTypeVCPUs, 8000, 3000, 5000), + bucket(ResourceTypeWorkloads, 10, 3, 7), + bucket(ResourceTypeMemory, 16384, 4096, 12288), ) // No platform client: the server that reads as the person who asked has @@ -59,9 +59,9 @@ func TestListComputeQuotaOrdersRowsAndConvertsUnits(t *testing.T) { } wantOrder := []string{ - "compute.datumapis.com/workloads", - "compute.datumapis.com/vcpus", - "compute.datumapis.com/memory", + ResourceTypeWorkloads, + ResourceTypeVCPUs, + ResourceTypeMemory, } for i, want := range wantOrder { if rows[i].ResourceType != want { @@ -70,7 +70,7 @@ func TestListComputeQuotaOrdersRowsAndConvertsUnits(t *testing.T) { } vcpus := rows[1] - if vcpus.Unit != "vCPUs" || vcpus.Limit != 8 || vcpus.Used != 3 || vcpus.Available != 5 { + if vcpus.Unit != unitVCPUs || vcpus.Limit != 8 || vcpus.Used != 3 || vcpus.Available != 5 { t.Errorf("vCPU row = %+v, want 8/3/5 vCPUs (divided down from millicores)", vcpus) } } @@ -82,7 +82,7 @@ func TestListServiceQuotaIgnoresOtherServices(t *testing.T) { bucket("networking.datumapis.com/networks", 5, 1, 4), bucket("compute.datumapis.com/zzz-new", 2, 0, 2), bucket("compute.datumapis.com/aaa-new", 2, 0, 2), - bucket("compute.datumapis.com/workloads", 10, 3, 7), + bucket(ResourceTypeWorkloads, 10, 3, 7), ) rows, err := ListComputeQuota(context.Background(), c, nil) @@ -91,9 +91,9 @@ func TestListServiceQuotaIgnoresOtherServices(t *testing.T) { } want := []string{ - "compute.datumapis.com/workloads", // explicitly ordered, so first - "compute.datumapis.com/aaa-new", // the rest alphabetically, so a new - "compute.datumapis.com/zzz-new", // resource type lands reproducibly + ResourceTypeWorkloads, // explicitly ordered, so first + "compute.datumapis.com/aaa-new", // the rest alphabetically, so a new + "compute.datumapis.com/zzz-new", // resource type lands reproducibly } if len(rows) != len(want) { t.Fatalf("got %d rows, want %d: %+v", len(rows), len(want), rows) From d06fbcff270b433a7eaf9e91029bb8bdb07b2a07 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 14 Sep 2026 12:54:14 -0500 Subject: [PATCH 4/5] refactor(agent): rely on the assistant's base tools for workload creation The assistant now gives every project turn generic tools for locations, quota, listing resources, and a token-bound validate/plan/apply path that acts as the caller. Compute no longer re-implements them: its MCP server keeps only what is compute-specific, rendering a Workload manifest the admission webhook accepts and the instance type catalog, alongside the existing diagnosis tools. This drops the compute locations, networks, quota, validate, plan and apply tools, the plan token key, the extra scheme registrations, the workload diff, the shared quota view (the CLI keeps its own), and the availability-only location listing. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/compute-mcp/main.go | 116 +-- cmd/compute-mcp/main_test.go | 110 +-- internal/agent/discovery.go | 406 ---------- internal/agent/discovery_test.go | 521 ------------- internal/agent/instancetypes.go | 86 +++ internal/agent/instancetypes_test.go | 54 ++ internal/agent/render.go | 329 ++++++++ internal/agent/render_test.go | 237 ++++++ internal/agent/tools.go | 58 +- internal/agent/tools_test.go | 54 +- internal/agent/write.go | 1069 -------------------------- internal/agent/write_test.go | 1004 ------------------------ internal/cmd/compute/util/quota.go | 160 +++- internal/locations/locations.go | 62 -- internal/locations/locations_test.go | 134 ---- internal/quotaview/quota.go | 221 ------ internal/quotaview/quota_test.go | 122 --- internal/workloadspec/diff.go | 104 --- internal/workloadspec/diff_test.go | 83 -- 19 files changed, 899 insertions(+), 4031 deletions(-) delete mode 100644 internal/agent/discovery.go delete mode 100644 internal/agent/discovery_test.go create mode 100644 internal/agent/instancetypes.go create mode 100644 internal/agent/instancetypes_test.go create mode 100644 internal/agent/render.go create mode 100644 internal/agent/render_test.go delete mode 100644 internal/agent/write.go delete mode 100644 internal/agent/write_test.go delete mode 100644 internal/quotaview/quota.go delete mode 100644 internal/quotaview/quota_test.go delete mode 100644 internal/workloadspec/diff.go delete mode 100644 internal/workloadspec/diff_test.go diff --git a/cmd/compute-mcp/main.go b/cmd/compute-mcp/main.go index 2bce6b33..74835e06 100644 --- a/cmd/compute-mcp/main.go +++ b/cmd/compute-mcp/main.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -// Command compute-mcp serves compute's tools over MCP, alongside the knowledge -// and skills an assistant reads before calling them: +// Command compute-mcp serves compute's read-only diagnostic tools over MCP, +// alongside the knowledge and skills an assistant reads before calling them: // // POST /mcp Streamable HTTP MCP, stateless // GET /llms-full.txt Knowledge: the compute resource model @@ -12,26 +12,19 @@ // Only /mcp takes a credential; see docs.go for why the documents do not. // // The server holds no credential of its own for the project control plane: it -// reads and writes through a client built from the caller's own bearer token. -// So a tool call can never see or create more than the person who asked, the -// platform's RBAC stays the single enforcement point, and there is no -// impersonation privilege here to escalate with. +// reads through a client built from the caller's own bearer token. So a tool +// call can never see more than the person who asked, the platform's RBAC stays +// the single enforcement point, and there is no impersonation privilege here to +// escalate with. // // The project a request reads is taken from a header, never from a tool // argument: arguments are chosen by the model, and a model that could name its // own project would be one prompt-injection away from another tenant's // workloads. The header is set by the already-authenticated caller. -// -// Two of the published tools can change something — compute_workload_plan and -// compute_workload_apply — and apply only ever creates the manifest a plan -// token was minted for. Those tokens are signed with PLAN_TOKEN_KEY; see -// resolvePlanTokenKey for what a deployment owes it. package main import ( "context" - "crypto/rand" - "encoding/base64" "errors" "flag" "fmt" @@ -55,10 +48,6 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/agent" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" - locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" - quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" - servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" ) const ( @@ -81,16 +70,6 @@ const ( misconfiguredClientNote = "The person who asked did nothing wrong and re-authenticating will not " + "help: this is a configuration problem for whoever operates that client" - // planTokenKeyEnv names the environment variable holding the key plan - // tokens are signed with. Base64 or raw, at least minPlanTokenKeyLen bytes - // either way. - planTokenKeyEnv = "PLAN_TOKEN_KEY" - - // minPlanTokenKeyLen is the shortest key accepted. HMAC-SHA256's block - // structure gets nothing from a key longer than its 32-byte output, and a - // shorter one is a weaker signature than the scheme is meant to have. - minPlanTokenKeyLen = 32 - // resourceNamespace is where compute's objects live inside a project's // control plane: the project routes to the control plane, and within it // everything is in "default". Mirrors util.ResourceNamespace, not imported @@ -101,26 +80,11 @@ const ( var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") - - // planTokenKey signs the plan tokens compute_workload_plan mints and - // compute_workload_apply checks. Deployment configuration, resolved once at - // startup: every request reads it, and a key that differs between replicas - // means a plan minted by one is refused by another. - planTokenKey []byte ) -// The scheme carries every group a tool reads: compute's own objects for the -// diagnosis walk, plus networks, quota, and — for the locations a project may -// place at — compute's service availability records and the Locations they -// name. A group missing here fails at the first read with a scheme error, which -// says nothing about which tool wanted it. func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(computev1alpha.AddToScheme(scheme)) - utilruntime.Must(networkingv1alpha.AddToScheme(scheme)) - utilruntime.Must(locationsv1alpha1.AddToScheme(scheme)) - utilruntime.Must(quotav1alpha1.AddToScheme(scheme)) - utilruntime.Must(servicesv1alpha1.AddToScheme(scheme)) } func main() { @@ -135,13 +99,6 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - key, err := resolvePlanTokenKey(os.Getenv(planTokenKeyEnv)) - if err != nil { - setupLog.Error(err, "refusing to start") - os.Exit(1) - } - planTokenKey = key - // GetConfig resolves the --kubeconfig flag that controller-runtime // registers, then KUBECONFIG, then in-cluster config, then ~/.kube/config. // Only the endpoint and CA are used; see clientForToken. @@ -162,44 +119,6 @@ func main() { } } -// resolvePlanTokenKey returns the key plan tokens are signed with, given the -// environment's value for it. -// -// A missing key is not fatal: the server generates one and runs. A plan token -// is only ever checked by the process that minted it, and one process holding -// a key nobody else knows is exactly what the scheme needs. What it costs is -// that a plan minted by one replica is refused by another, and a restart -// refuses every token outstanding — so the warning says that plainly rather -// than letting an operator discover it as intermittent refusals under a load -// balancer. -func resolvePlanTokenKey(configured string) ([]byte, error) { - if configured = strings.TrimSpace(configured); configured != "" { - // Base64 first, since a key generated with `openssl rand -base64 32` - // is 44 printable characters that would otherwise pass the raw check - // while carrying only 32 bytes of the entropy it was meant to have. - if decoded, err := base64.StdEncoding.DecodeString(configured); err == nil && - len(decoded) >= minPlanTokenKeyLen { - return decoded, nil - } - if len(configured) >= minPlanTokenKeyLen { - return []byte(configured), nil - } - return nil, fmt.Errorf( - "%s is too short: it must be at least %d bytes, either raw or base64-encoded. Generate one "+ - "with: openssl rand -base64 32", planTokenKeyEnv, minPlanTokenKeyLen) - } - - key := make([]byte, minPlanTokenKeyLen) - if _, err := rand.Read(key); err != nil { - return nil, fmt.Errorf("generating a plan token key: %w", err) - } - setupLog.Info("no plan token key configured, generated one for this process", - "warning", "plan tokens will not validate across replicas or survive a restart; set "+ - planTokenKeyEnv+" to the same value on every replica", - "env", planTokenKeyEnv) - return key, nil -} - // checkControlPlaneEndpoint refuses to start when the configuration resolved to // the API server of the cluster this process runs in. // @@ -254,9 +173,8 @@ func run(addr string, baseConfig *rest.Config) error { agent.RegisterTools(s, depsFromRequest(r, baseConfig)) return s }, - // Stateless: no tool needs session state — what a plan settled travels - // in the token it returns, not in memory here — and it keeps the - // server robust against client crashes. + // Stateless: no session state is needed for read-only tools, and it + // keeps the server robust against client crashes. &mcp.StreamableHTTPOptions{Stateless: true}, ) @@ -313,23 +231,7 @@ func depsFromRequest(r *http.Request, baseConfig *rest.Config) agent.DepsFor { if err != nil { return agent.ToolDeps{}, err } - // One client serves all three: the reads a tool makes are the reads the - // person who asked could make themselves, and a workload it creates is - // one they could have created themselves, whichever tool does it. The - // Discoverer is given no platform client — this server holds no - // credential of its own, so quota display units fall back rather than - // being fetched with an identity the caller does not have. - return agent.ToolDeps{ - Reader: agent.NewClientReader(c), - Discoverer: agent.NewClientDiscoverer(c), - Writer: agent.NewClientWriter(c), - Namespace: resourceNamespace, - // The project a plan is bound to is the header's, the same one the - // client above addresses, so a token minted for one project can - // never be spent in another. - Project: project, - PlanTokenKey: planTokenKey, - }, nil + return agent.ToolDeps{Reader: agent.NewClientReader(c), Namespace: resourceNamespace}, nil } } diff --git a/cmd/compute-mcp/main_test.go b/cmd/compute-mcp/main_test.go index 811ceee6..34052149 100644 --- a/cmd/compute-mcp/main_test.go +++ b/cmd/compute-mcp/main_test.go @@ -4,7 +4,6 @@ package main import ( "context" - "encoding/base64" "net/http" "net/http/httptest" "regexp" @@ -20,19 +19,13 @@ import ( "go.datum.net/compute/internal/agent" ) -const ( - testToken = "caller-token" - // testHost stands in for the control plane a deployment is pointed at, and - // testProjectName for the project a request names in its header. - testHost = "https://api.datum.example" - testProjectName = "my-project" -) +const testToken = "caller-token" func baseConfig() *rest.Config { // Shaped like an in-cluster config: endpoint and CA, plus a server identity // that must not survive into a caller's read. return &rest.Config{ - Host: testHost, + Host: "https://api.datum.example", BearerToken: "server-service-account-token", BearerTokenFile: "/var/run/secrets/kubernetes.io/serviceaccount/token", TLSClientConfig: rest.TLSClientConfig{CAFile: "/var/run/secrets/ca.crt"}, @@ -44,7 +37,7 @@ func baseConfig() *rest.Config { // internal/referenceddata and the datumctl plugin perform. The namespace within // that control plane is "default", never the project name. func TestClientConfigAddressesProjectControlPlane(t *testing.T) { - cfg, err := clientConfig(baseConfig(), testToken, testProjectName) + cfg, err := clientConfig(baseConfig(), testToken, "my-project") if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -62,7 +55,7 @@ func TestClientConfigAddressesProjectControlPlane(t *testing.T) { // whole design rests on: the server must never read as itself. func TestClientConfigCarriesOnlyTheCallerCredential(t *testing.T) { base := baseConfig() - cfg, err := clientConfig(base, testToken, testProjectName) + cfg, err := clientConfig(base, testToken, "my-project") if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -80,7 +73,7 @@ func TestClientConfigCarriesOnlyTheCallerCredential(t *testing.T) { t.Errorf("CAFile = %q, want the base config's %q", cfg.CAFile, base.CAFile) } // The base config must be left alone; it is shared by every request. - if base.BearerToken != "server-service-account-token" || base.Host != testHost { + if base.BearerToken != "server-service-account-token" || base.Host != "https://api.datum.example" { t.Error("clientConfig mutated the shared base config") } } @@ -112,8 +105,8 @@ func TestDepsFromRequestRequiresCredentials(t *testing.T) { project string want string }{ - {name: "no token", project: testProjectName, want: "no credentials"}, - {name: "wrong scheme", auth: "Basic abc", project: testProjectName, want: "no credentials"}, + {name: "no token", project: "my-project", want: "no credentials"}, + {name: "wrong scheme", auth: "Basic abc", project: "my-project", want: "no credentials"}, {name: "no project", auth: "Bearer " + testToken, want: "no project"}, {name: "invalid project", auth: "Bearer " + testToken, project: "a/b", want: "invalid project"}, } @@ -269,7 +262,7 @@ func TestReadsGoThroughTheProjectControlPlane(t *testing.T) { })) defer api.Close() - cfg, err := clientConfig(&rest.Config{Host: api.URL}, testToken, testProjectName) + cfg, err := clientConfig(&rest.Config{Host: api.URL}, testToken, "my-project") if err != nil { t.Fatalf("clientConfig: %v", err) } @@ -406,90 +399,3 @@ func TestGuardNamesNoEnvironment(t *testing.T) { t.Errorf("localClusterEndpoint() = %q, want %q", got, want) } } - -// TestResolvePlanTokenKey covers the key plan tokens are signed with. A key -// too short to be one is a deployment mistake worth refusing to start over; a -// missing one is not, because a single process signing with a key only it -// knows is a working configuration — it just cannot survive a second replica. -func TestResolvePlanTokenKey(t *testing.T) { - raw := strings.Repeat("k", minPlanTokenKeyLen) - encoded := base64.StdEncoding.EncodeToString([]byte(raw)) - - t.Run("base64", func(t *testing.T) { - got, err := resolvePlanTokenKey(encoded) - if err != nil { - t.Fatalf("resolvePlanTokenKey: %v", err) - } - // Decoded, not taken as the 44 printable characters it is written as: - // the operator generated 32 bytes of entropy and that is what signs. - if string(got) != raw { - t.Errorf("key = %q, want the decoded %q", got, raw) - } - }) - - t.Run("raw", func(t *testing.T) { - got, err := resolvePlanTokenKey(raw) - if err != nil { - t.Fatalf("resolvePlanTokenKey: %v", err) - } - if string(got) != raw { - t.Errorf("key = %q, want %q", got, raw) - } - }) - - t.Run("too short", func(t *testing.T) { - if _, err := resolvePlanTokenKey("hunter2"); err == nil { - t.Error("accepted a key too short to sign with") - } else if !strings.Contains(err.Error(), planTokenKeyEnv) { - t.Errorf("error = %q, want it to name the setting to fix", err) - } - }) - - t.Run("unset", func(t *testing.T) { - first, err := resolvePlanTokenKey("") - if err != nil { - t.Fatalf("resolvePlanTokenKey: %v", err) - } - if len(first) < minPlanTokenKeyLen { - t.Errorf("generated a %d-byte key, want at least %d", len(first), minPlanTokenKeyLen) - } - second, err := resolvePlanTokenKey("") - if err != nil { - t.Fatalf("resolvePlanTokenKey: %v", err) - } - if string(first) == string(second) { - t.Error("generated the same key twice; it must be random per process") - } - }) -} - -// TestDepsBindThePlanToTheHeadersProject: a plan token is only good in the -// project it was minted for, and that project is the header's — the same one -// the client addresses. If these two could ever differ, a token minted in one -// tenant would spend in another. -func TestDepsBindThePlanToTheHeadersProject(t *testing.T) { - planTokenKey = []byte(strings.Repeat("k", minPlanTokenKeyLen)) - t.Cleanup(func() { planTokenKey = nil }) - - r := httptest.NewRequest(http.MethodPost, "/mcp", nil) - r.Header.Set("Authorization", "Bearer "+testToken) - r.Header.Set(projectHeader, testProjectName) - - // No CA file: this builds a real client, and the point here is what the - // deps carry, not what they can reach. - deps, err := depsFromRequest(r, &rest.Config{Host: testHost})(context.Background()) - if err != nil { - t.Fatalf("depsFromRequest: %v", err) - } - if deps.Project != testProjectName { - t.Errorf("Project = %q, want the header's %q", deps.Project, testProjectName) - } - if string(deps.PlanTokenKey) != string(planTokenKey) { - t.Error("PlanTokenKey did not reach the tools; no plan could be minted") - } - // The writer runs as the caller, like every other tool: one client, built - // from the bearer token on this request. - if deps.Writer == nil { - t.Error("no Writer on the deps; the write tools would report themselves unconfigured") - } -} diff --git a/internal/agent/discovery.go b/internal/agent/discovery.go deleted file mode 100644 index 585e725a..00000000 --- a/internal/agent/discovery.go +++ /dev/null @@ -1,406 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -package agent - -import ( - "context" - "errors" - "fmt" - "sort" - - "github.com/modelcontextprotocol/go-sdk/mcp" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "go.datum.net/compute/internal/locations" - "go.datum.net/compute/internal/quotaview" - "go.datum.net/compute/pkg/instancetype" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -// The discovery tools answer what a project MAY deploy, where the diagnostic -// tools answer what it HAS deployed. -// -// They exist because an assistant asked to write a Workload otherwise invents -// the three fields it cannot guess — a location, a network, an instance type — -// and invents them plausibly. A workload naming a location the project is not -// entitled to, or a size the API will not take, fails after the customer has -// been told it was written correctly. Every one of these is read from the -// project itself, so the answer is what that project will actually accept. -// -// All four are read-only. -const ( - ToolLocationsList = "compute_locations_list" - ToolNetworksList = "compute_networks_list" - ToolQuotaGet = "compute_quota_get" - ToolInstanceTypesList = "compute_instance_types_list" -) - -// Discoverer reads what a project is entitled to deploy. -// -// Separate from Reader rather than folded into it: Reader is scoped to the -// compute objects a diagnosis walks, and these three reads reach other API -// groups entirely. Keeping them apart means a caller that only diagnoses need -// not be given the wiring for a locations or quota read it will never do. -// -// Like Reader, whoever constructs one decides the identity its reads run under. -type Discoverer interface { - // ListPlacementLocations returns the locations the project may place - // workloads at. Not namespaced: entitlement is a property of the project. - ListPlacementLocations(ctx context.Context) ([]locations.PlacementLocation, error) - // ListNetworks returns the Networks in the namespace. - ListNetworks(ctx context.Context, namespace string) ([]networkingv1alpha.Network, error) - // GetQuota returns the project's compute quota, one row per resource type. - GetQuota(ctx context.Context) ([]quotaview.QuotaRow, error) -} - -// ClientDiscoverer implements Discoverer against a controller-runtime client. -type ClientDiscoverer struct { - // Client reads the project, with whatever credentials it carries. - Client client.Client - - // PlatformClient supplies display metadata for quota rows, and may be nil. - // A server that reads only as the person who asked holds no platform - // credential of its own; the numbers are read from the project either way, - // and only the unit labels fall back to a generic form without it. - PlatformClient client.Client -} - -var _ Discoverer = (*ClientDiscoverer)(nil) - -// NewClientDiscoverer returns a Discoverer backed by c. -func NewClientDiscoverer(c client.Client) *ClientDiscoverer { - return &ClientDiscoverer{Client: c} -} - -// ListPlacementLocations reads compute's own availability records. There is no -// choice of source here: what an assistant needs is where compute is offered -// and this project can use it, and only the availability records say that. The -// manager still reads placement per its own configuration; this is the answer a -// customer is given, and it is the same one wherever they ask. -func (d *ClientDiscoverer) ListPlacementLocations(ctx context.Context) ([]locations.PlacementLocation, error) { - found, err := locations.ListAvailableLocations(ctx, d.Client) - if err != nil { - // A project that cannot answer the question at all must not be - // reported as a project with nowhere to run: the first is a deployment - // to fix, the second is a wait, and an assistant told the wrong one - // sends the customer to argue with the wrong people. The kind travels - // as evidence inside the wrapped error, the sentence does not lean on - // it, and the blame is placed where the fix is. - if errors.Is(err, locations.ErrAvailabilityNotServed) { - return nil, fmt.Errorf( - "compute could not read where it is offered from this project, because the service "+ - "that publishes availability is not reachable here. This is a problem with how "+ - "Datum is deployed for this project, not with the workload and not with the "+ - "person who asked: nothing in a workload can be changed to fix it, and "+ - "re-authenticating will not help. Underlying detail, for whoever operates "+ - "Datum: %w", err) - } - return nil, fmt.Errorf("listing the locations this project may place at: %w", err) - } - return found, nil -} - -func (d *ClientDiscoverer) ListNetworks(ctx context.Context, namespace string) ([]networkingv1alpha.Network, error) { - var list networkingv1alpha.NetworkList - if err := d.Client.List(ctx, &list, client.InNamespace(namespace)); err != nil { - return nil, fmt.Errorf("listing networks in %s: %w", namespace, err) - } - return list.Items, nil -} - -func (d *ClientDiscoverer) GetQuota(ctx context.Context) ([]quotaview.QuotaRow, error) { - rows, err := quotaview.ListComputeQuota(ctx, d.Client, d.PlatformClient) - if err != nil { - return nil, fmt.Errorf("reading this project's compute quota: %w", err) - } - return rows, nil -} - -// ---------------------------------------------------------------- I/O types - -// LocationView is one location a project may place at. -type LocationView struct { - Name string `json:"name"` - // CityCode is the city the location serves, e.g. "DFW". Empty when the - // location declares none, which is worth reporting rather than hiding: a - // placement that names a city cannot be satisfied by such a location. - CityCode string `json:"cityCode,omitempty"` - // DisplayName is the human-readable label, when the location carries one. - DisplayName string `json:"displayName,omitempty"` - // Topology is the full set of attributes the location declares, city code - // included, so a placement can be matched on more than the city once more - // attributes are published. A locationSelector is matched against exactly - // these keys. - Topology map[string]string `json:"topology,omitempty"` - // Placeable reports whether a placement naming this location will actually - // be scheduled: compute is offered here and the location itself is - // serving. Every location in this list is one compute is offered at, so a - // false here is a location that is not ready yet rather than one the - // project may not use. - Placeable bool `json:"placeable"` - // Ready reports whether the location itself is serving. - Ready bool `json:"ready"` -} - -// NetworkView is one network a workload's instances can attach to. -type NetworkView struct { - Name string `json:"name"` - IPFamilies []string `json:"ipFamilies,omitempty"` - // Ready reports whether the network holds everything it needs to be used. - Ready bool `json:"ready"` - // Reason says why, when it is not ready. - Reason string `json:"reason,omitempty"` -} - -// InstanceTypeView is one instance type a Workload may ask for. -type InstanceTypeView struct { - Name string `json:"name"` - // VCPU is how many virtual CPUs the type provides. Fractional, because the - // size is stored in thousandths and a future type need not be a whole one. - VCPU float64 `json:"vcpu"` - // MemoryMiB is the RAM the type provides, in mebibytes. - MemoryMiB int64 `json:"memoryMiB"` - // Default marks the type to use when the customer expressed no preference. - Default bool `json:"default"` -} - -// LocationsListInput takes no arguments: the project is fixed by the request. -type LocationsListInput struct{} - -// LocationsListOutput is every location the project may place at. -type LocationsListOutput struct { - Locations []LocationView `json:"locations"` -} - -// NetworksListInput takes no arguments. -type NetworksListInput struct{} - -// NetworksListOutput is every network in the project. -type NetworksListOutput struct { - Networks []NetworkView `json:"networks"` -} - -// QuotaGetInput takes no arguments. -type QuotaGetInput struct{} - -// QuotaGetOutput is the project's compute quota, one row per resource type. -// The rows are quotaview's own, so what an assistant reports and what -// `datumctl compute quota` prints cannot drift apart. -type QuotaGetOutput struct { - Resources []quotaview.QuotaRow `json:"resources"` -} - -// InstanceTypesListInput takes no arguments. -type InstanceTypesListInput struct{} - -// InstanceTypesListOutput is the catalog of instance types. -type InstanceTypesListOutput struct { - InstanceTypes []InstanceTypeView `json:"instanceTypes"` -} - -// ------------------------------------------------------------ registration - -// RegisterDiscoveryTools adds the tools that answer what a project may deploy. -// Called by RegisterTools; separate so the set can be read on its own. -func RegisterDiscoveryTools(s *mcp.Server, deps DepsFor) { - mcp.AddTool(s, &mcp.Tool{ - Name: ToolLocationsList, - Title: "List locations", - Description: "List the locations where compute is offered and this project can use it, each with " + - "its name, its city code (e.g. \"DFW\"), the attributes it declares, and whether it is " + - "ready to take instances. The list is derived from compute's own availability records, so " + - "it is where compute is actually running, not where it might be: a location missing from " + - "this list is one compute is not offered in, and a placement naming it will never come up. " + - "A placement either names these locations verbatim or selects them by the attributes shown " + - "here, which is how to place in every location of a city or a region. Call this before " + - "writing a Workload's placements rather than guessing a name. Read-only.", - }, locationsList(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolNetworksList, - Title: "List networks", - Description: "List the networks in this project, each with its IP families and whether it is ready " + - "to use. Every Workload attaches its instances to a network by name, so a draft needs one; " + - "\"default\" is the conventional name and is what a project normally has. A network that is " + - "not ready will hold new instances back, and its reason says what it is waiting on. Read-only.", - }, networksList(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolQuotaGet, - Title: "Get compute quota", - Description: "Report this project's compute quota: for each resource type — workloads, instances, " + - "vCPUs, memory — the limit, how much is already in use, and how much is left. Call it before " + - "proposing a replica count or an instance size, so the workload fits in what the project has, " + - "and call it when something reports QuotaExceeded to see how much room there actually is. " + - "Read-only.", - }, quotaGet(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolInstanceTypesList, - Title: "List instance types", - Description: "List the instance types a Workload may ask for, with the vCPU and memory each one " + - "provides and which is the default. Only these names are accepted — a Workload naming any " + - "other is rejected the moment it is submitted, so never invent a size. Read-only.", - }, instanceTypesList(deps)) -} - -// ---------------------------------------------------------------- handlers - -func locationsList(deps DepsFor) mcp.ToolHandlerFor[LocationsListInput, LocationsListOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, _ LocationsListInput, - ) (*mcp.CallToolResult, LocationsListOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, LocationsListOutput{}, err - } - disc, err := d.discoverer() - if err != nil { - return nil, LocationsListOutput{}, err - } - - found, err := disc.ListPlacementLocations(ctx) - if err != nil { - return nil, LocationsListOutput{}, err - } - - out := LocationsListOutput{Locations: make([]LocationView, 0, len(found))} - for _, location := range found { - code, _ := location.CityCode() - out.Locations = append(out.Locations, LocationView{ - Name: location.Name, - CityCode: code, - Topology: location.Topology, - Placeable: location.Placeable(), - Ready: location.Ready, - }) - } - // By name, so two calls in one conversation read the same way. - sort.Slice(out.Locations, func(i, j int) bool { - return out.Locations[i].Name < out.Locations[j].Name - }) - return nil, out, nil - } -} - -func networksList(deps DepsFor) mcp.ToolHandlerFor[NetworksListInput, NetworksListOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, _ NetworksListInput, - ) (*mcp.CallToolResult, NetworksListOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, NetworksListOutput{}, err - } - disc, err := d.discoverer() - if err != nil { - return nil, NetworksListOutput{}, err - } - - found, err := disc.ListNetworks(ctx, d.Namespace) - if err != nil { - return nil, NetworksListOutput{}, err - } - - out := NetworksListOutput{Networks: make([]NetworkView, 0, len(found))} - for i := range found { - n := &found[i] - view := NetworkView{Name: n.Name} - for _, family := range n.Spec.IPFamilies { - view.IPFamilies = append(view.IPFamilies, string(family)) - } - // A network with no Ready condition at all has not been looked at - // yet, which reads as not ready with nothing to say about why. - if ready := apimeta.FindStatusCondition(n.Status.Conditions, networkingv1alpha.NetworkReady); ready != nil { - view.Ready = ready.Status == metav1.ConditionTrue - if !view.Ready { - view.Reason = ready.Reason - } - } - out.Networks = append(out.Networks, view) - } - sort.Slice(out.Networks, func(i, j int) bool { - return out.Networks[i].Name < out.Networks[j].Name - }) - return nil, out, nil - } -} - -func quotaGet(deps DepsFor) mcp.ToolHandlerFor[QuotaGetInput, QuotaGetOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, _ QuotaGetInput, - ) (*mcp.CallToolResult, QuotaGetOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, QuotaGetOutput{}, err - } - disc, err := d.discoverer() - if err != nil { - return nil, QuotaGetOutput{}, err - } - - rows, err := disc.GetQuota(ctx) - if err != nil { - return nil, QuotaGetOutput{}, err - } - // An empty result is "no quota is configured", not an error: the tool - // says so by returning an empty list rather than a null one. - if rows == nil { - rows = []quotaview.QuotaRow{} - } - return nil, QuotaGetOutput{Resources: rows}, nil - } -} - -// instanceTypesList reads only the catalog, but still resolves deps for the -// same reason reasonExplain does: an unauthenticated caller must not be able to -// use it to probe the server. -func instanceTypesList(deps DepsFor) mcp.ToolHandlerFor[InstanceTypesListInput, InstanceTypesListOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, _ InstanceTypesListInput, - ) (*mcp.CallToolResult, InstanceTypesListOutput, error) { - if _, err := deps(ctx); err != nil { - return nil, InstanceTypesListOutput{}, err - } - return nil, InstanceTypesListOutput{InstanceTypes: Catalog()}, nil - } -} - -// ----------------------------------------------------------------- helpers - -// discoverer returns the Discoverer for this call, or an error naming the -// wiring mistake. A nil one is a server that was built without discovery, and -// saying so beats a nil dereference in a handler. -func (d ToolDeps) discoverer() (Discoverer, error) { - if d.Discoverer == nil { - return nil, fmt.Errorf( - "this server was built without the ability to read what the project may deploy, so this " + - "tool cannot answer. The person who asked did nothing wrong: whoever operates this " + - "server needs to configure it") - } - return d.Discoverer, nil -} - -// Catalog returns the instance types a Workload may ask for, in offer order. -// The first is the default: the platform's catalog holds exactly one type -// today, and the order it lists them in is the order to prefer them. -// -// The names and sizes both come from pkg/instancetype, which is the platform's -// single source for instance sizing — the same table the instance controller -// claims quota against. Restating either here would let the tool offer a size -// the API bills differently. -func Catalog() []InstanceTypeView { - names := instancetype.Names() - out := make([]InstanceTypeView, 0, len(names)) - for i, name := range names { - size, _ := instancetype.Lookup(name) - out = append(out, InstanceTypeView{ - Name: name, - VCPU: float64(size.CPUMillicores) / 1000, - MemoryMiB: size.MemoryMiB, - Default: i == 0, - }) - } - return out -} diff --git a/internal/agent/discovery_test.go b/internal/agent/discovery_test.go deleted file mode 100644 index 02ff2de5..00000000 --- a/internal/agent/discovery_test.go +++ /dev/null @@ -1,521 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/modelcontextprotocol/go-sdk/mcp" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - - "go.datum.net/compute/internal/locations" - "go.datum.net/compute/internal/quotaview" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" - locationsv1alpha1 "go.miloapis.com/locations/api/v1alpha1" - servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" -) - -// fakeDiscoverer serves canned entitlement facts so the discovery tools can be -// exercised without a cluster. -type fakeDiscoverer struct { - locations []locations.PlacementLocation - networks []networkingv1alpha.Network - quota []quotaview.QuotaRow - err error -} - -var _ Discoverer = (*fakeDiscoverer)(nil) - -func (f *fakeDiscoverer) ListPlacementLocations(context.Context) ([]locations.PlacementLocation, error) { - return f.locations, f.err -} - -func (f *fakeDiscoverer) ListNetworks(context.Context, string) ([]networkingv1alpha.Network, error) { - return f.networks, f.err -} - -func (f *fakeDiscoverer) GetQuota(context.Context) ([]quotaview.QuotaRow, error) { - return f.quota, f.err -} - -// fixtureDiscoverer covers the shapes worth distinguishing: a location that -// declares a city and one that does not, a ready network and one that is still -// waiting, and quota with room in one dimension and none in another. -func fixtureDiscoverer() *fakeDiscoverer { - return &fakeDiscoverer{ - // Deliberately out of alphabetical order, so the sort is proven. - locations: []locations.PlacementLocation{ - {Name: locDFW, Topology: map[string]string{locations.TopologyCityCodeKey: cityDFW}, - Ready: true, ServiceAvailable: true}, - {Name: "eu-west-ams", Topology: map[string]string{locations.TopologyCityCodeKey: cityAMS}, - Ready: true, ServiceAvailable: true}, - // Compute is offered here, but the location is not serving yet. - {Name: "no-city", Topology: map[string]string{"topology.datum.net/region": "unknown"}, - ServiceAvailable: true}, - }, - networks: []networkingv1alpha.Network{ - network("staging", []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}, - metav1.Condition{ - Type: networkingv1alpha.NetworkReady, - Status: metav1.ConditionFalse, - Reason: networkingv1alpha.NetworkReasonProjectNamespaceNotFound, - }), - network("default", - []networkingv1alpha.IPFamily{networkingv1alpha.IPv4Protocol, networkingv1alpha.IPv6Protocol}, - metav1.Condition{ - Type: networkingv1alpha.NetworkReady, - Status: metav1.ConditionTrue, - Reason: networkingv1alpha.NetworkReadyReasonReady, - }), - }, - quota: []quotaview.QuotaRow{ - {ResourceType: "compute.datumapis.com/workloads", DisplayName: "Workloads", Unit: "workloads", - Limit: 10, Used: 3, Available: 7}, - {ResourceType: "compute.datumapis.com/vcpus", DisplayName: "vCPUs", Unit: "vCPUs", - Limit: 8, Used: 8, Available: 0}, - }, - } -} - -func network(name string, families []networkingv1alpha.IPFamily, conditions ...metav1.Condition) networkingv1alpha.Network { - return networkingv1alpha.Network{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, - Spec: networkingv1alpha.NetworkSpec{IPFamilies: families}, - Status: networkingv1alpha.NetworkStatus{Conditions: conditions}, - } -} - -// discoveryDeps supplies both halves, since RegisterTools registers every tool -// against one DepsFor. -func discoveryDeps(d Discoverer) DepsFor { - return func(context.Context) (ToolDeps, error) { - return ToolDeps{Reader: fixtureReader(), Discoverer: d, Namespace: testNamespace}, nil - } -} - -func TestLocationsListReportsCityCodesAndSortsByName(t *testing.T) { - deps := discoveryDeps(fixtureDiscoverer()) - - _, out, err := locationsList(deps)(context.Background(), nil, LocationsListInput{}) - if err != nil { - t.Fatalf("compute_locations_list: %v", err) - } - if len(out.Locations) != 3 { - t.Fatalf("got %d locations, want 3", len(out.Locations)) - } - - wantOrder := []string{"eu-west-ams", "no-city", locDFW} - for i, want := range wantOrder { - if got := out.Locations[i].Name; got != want { - t.Errorf("locations[%d] = %q, want %q (the list must be sorted by name)", i, got, want) - } - } - - byName := make(map[string]LocationView, len(out.Locations)) - for _, l := range out.Locations { - byName[l.Name] = l - } - if got := byName[locDFW].CityCode; got != cityDFW { - t.Errorf("us-south-dfw cityCode = %q, want %q", got, cityDFW) - } - // A location with no city is reported rather than dropped: a placement that - // names a city cannot be satisfied by it, and the model needs to see that. - if got := byName["no-city"].CityCode; got != "" { - t.Errorf("no-city cityCode = %q, want empty", got) - } - if len(byName["no-city"].Topology) == 0 { - t.Error("no-city lost its topology; the attributes are what is left to match on") - } - // Readiness is reported rather than filtered on: a placement naming a - // location that is not serving yet will not be scheduled, and the model has - // to be able to say so instead of the customer finding out afterwards. - if !byName[locDFW].Placeable { - t.Error("us-south-dfw is Ready and compute is available; want placeable") - } - if byName["no-city"].Placeable || byName["no-city"].Ready { - t.Error("no-city is not Ready; want placeable and ready both false") - } -} - -func TestNetworksListReportsReadinessAndFamilies(t *testing.T) { - deps := discoveryDeps(fixtureDiscoverer()) - - _, out, err := networksList(deps)(context.Background(), nil, NetworksListInput{}) - if err != nil { - t.Fatalf("compute_networks_list: %v", err) - } - if len(out.Networks) != 2 { - t.Fatalf("got %d networks, want 2", len(out.Networks)) - } - - // "default" is the one an assistant reaches for, and sorting puts it first. - first := out.Networks[0] - if first.Name != "default" { - t.Fatalf("networks[0] = %q, want default (the list must be sorted by name)", first.Name) - } - if !first.Ready { - t.Error("default Ready = false, want true") - } - if first.Reason != "" { - t.Errorf("default reason = %q, want empty on a ready network", first.Reason) - } - if strings.Join(first.IPFamilies, ",") != "IPv4,IPv6" { - t.Errorf("default ipFamilies = %v, want [IPv4 IPv6]", first.IPFamilies) - } - - second := out.Networks[1] - if second.Ready { - t.Error("staging Ready = true, want false") - } - // The reason is the whole point of reporting an unready network: it says - // what the network is waiting on. - if second.Reason != networkingv1alpha.NetworkReasonProjectNamespaceNotFound { - t.Errorf("staging reason = %q, want %q", second.Reason, - networkingv1alpha.NetworkReasonProjectNamespaceNotFound) - } -} - -// TestNetworksListTreatsAnUnreportedNetworkAsNotReady pins the safe default: a -// network nothing has looked at yet must not read as usable. -func TestNetworksListTreatsAnUnreportedNetworkAsNotReady(t *testing.T) { - d := &fakeDiscoverer{networks: []networkingv1alpha.Network{ - network("fresh", []networkingv1alpha.IPFamily{networkingv1alpha.IPv6Protocol}), - }} - - _, out, err := networksList(discoveryDeps(d))(context.Background(), nil, NetworksListInput{}) - if err != nil { - t.Fatalf("compute_networks_list: %v", err) - } - if len(out.Networks) != 1 || out.Networks[0].Ready { - t.Errorf("networks = %+v, want one network reported not ready", out.Networks) - } -} - -func TestQuotaGetReturnsEveryResourceType(t *testing.T) { - deps := discoveryDeps(fixtureDiscoverer()) - - _, out, err := quotaGet(deps)(context.Background(), nil, QuotaGetInput{}) - if err != nil { - t.Fatalf("compute_quota_get: %v", err) - } - if len(out.Resources) != 2 { - t.Fatalf("got %d resources, want 2", len(out.Resources)) - } - // Order is the caller's, not re-sorted here: quotaview already returns the - // rows in the order a person reads them. - if got := out.Resources[0].ResourceType; got != "compute.datumapis.com/workloads" { - t.Errorf("resources[0] = %q, want the workloads row first", got) - } - exhausted := out.Resources[1] - if exhausted.Available != 0 || exhausted.Used != exhausted.Limit { - t.Errorf("vCPU row = %+v, want a row with nothing available", exhausted) - } -} - -// TestQuotaGetReturnsAnEmptyListWhenNoQuotaIsConfigured keeps "no quota is set -// up" from arriving as a null the model has to interpret. -func TestQuotaGetReturnsAnEmptyListWhenNoQuotaIsConfigured(t *testing.T) { - _, out, err := quotaGet(discoveryDeps(&fakeDiscoverer{}))(context.Background(), nil, QuotaGetInput{}) - if err != nil { - t.Fatalf("compute_quota_get: %v", err) - } - if out.Resources == nil { - t.Fatal("Resources is nil; an empty project must return an empty list") - } - if len(out.Resources) != 0 { - t.Errorf("Resources = %+v, want empty", out.Resources) - } -} - -func TestInstanceTypesListOffersOnlyWhatValidationAccepts(t *testing.T) { - deps := discoveryDeps(fixtureDiscoverer()) - - _, out, err := instanceTypesList(deps)(context.Background(), nil, InstanceTypesListInput{}) - if err != nil { - t.Fatalf("compute_instance_types_list: %v", err) - } - if len(out.InstanceTypes) == 0 { - t.Fatal("no instance types offered; a model with no catalog invents one") - } - - var defaults int - for _, it := range out.InstanceTypes { - if it.Default { - defaults++ - } - // A type with no size is worse than useless: it invites a replica count - // chosen against nothing. - if it.VCPU <= 0 || it.MemoryMiB <= 0 { - t.Errorf("%s = %g vCPU / %d MiB, want a real size", it.Name, it.VCPU, it.MemoryMiB) - } - } - if defaults != 1 { - t.Errorf("got %d default instance types, want exactly 1", defaults) - } - - // The one supported type today, with the sizing quota is accounted against. - first := out.InstanceTypes[0] - if first.Name != "datumcloud/d1-standard-2" || first.VCPU != 1 || first.MemoryMiB != 2048 { - t.Errorf("first type = %+v, want datumcloud/d1-standard-2 at 1 vCPU / 2048 MiB", first) - } -} - -// TestDiscoveryToolsFailWhenDepsAreUnavailable covers the path every handler -// shares: an unauthenticated or misconfigured caller must be turned away before -// any read, so no tool can be used to probe the server. -func TestDiscoveryToolsFailWhenDepsAreUnavailable(t *testing.T) { - wantErr := errors.New("no credentials on this request") - deps := DepsFor(func(context.Context) (ToolDeps, error) { return ToolDeps{}, wantErr }) - ctx := context.Background() - - calls := map[string]func() error{ - ToolLocationsList: func() error { - _, _, err := locationsList(deps)(ctx, nil, LocationsListInput{}) - return err - }, - ToolNetworksList: func() error { - _, _, err := networksList(deps)(ctx, nil, NetworksListInput{}) - return err - }, - ToolQuotaGet: func() error { - _, _, err := quotaGet(deps)(ctx, nil, QuotaGetInput{}) - return err - }, - // Answerable from the catalog alone, and still refused. - ToolInstanceTypesList: func() error { - _, _, err := instanceTypesList(deps)(ctx, nil, InstanceTypesListInput{}) - return err - }, - } - for name, call := range calls { - if err := call(); !errors.Is(err, wantErr) { - t.Errorf("%s error = %v, want the deps error to surface unchanged", name, err) - } - } -} - -// TestDiscoveryToolsExplainAMissingDiscoverer covers a server wired for -// diagnosis only: the tools must name the wiring gap rather than panic. -func TestDiscoveryToolsExplainAMissingDiscoverer(t *testing.T) { - deps := func(context.Context) (ToolDeps, error) { - return ToolDeps{Reader: fixtureReader(), Namespace: testNamespace}, nil - } - ctx := context.Background() - - if _, _, err := locationsList(deps)(ctx, nil, LocationsListInput{}); err == nil { - t.Error("compute_locations_list succeeded with no Discoverer, want an error") - } - if _, _, err := networksList(deps)(ctx, nil, NetworksListInput{}); err == nil { - t.Error("compute_networks_list succeeded with no Discoverer, want an error") - } - if _, _, err := quotaGet(deps)(ctx, nil, QuotaGetInput{}); err == nil { - t.Error("compute_quota_get succeeded with no Discoverer, want an error") - } - // compute_instance_types_list reads no project state, so it still answers. - if _, _, err := instanceTypesList(deps)(ctx, nil, InstanceTypesListInput{}); err != nil { - t.Errorf("compute_instance_types_list: %v, want the catalog to answer without a Discoverer", err) - } -} - -// TestDiscoveryToolsAnswerOverTheWire proves registration, not just the -// handlers: a tool that is never wired into RegisterTools passes every unit -// test above and is uncallable in production. -func TestDiscoveryToolsAnswerOverTheWire(t *testing.T) { - ctx := context.Background() - - server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) - RegisterTools(server, discoveryDeps(fixtureDiscoverer())) - - serverTransport, clientTransport := mcp.NewInMemoryTransports() - serverSession, err := server.Connect(ctx, serverTransport, nil) - if err != nil { - t.Fatalf("connecting server: %v", err) - } - defer func() { _ = serverSession.Close() }() - - client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) - clientSession, err := client.Connect(ctx, clientTransport, nil) - if err != nil { - t.Fatalf("connecting client: %v", err) - } - defer func() { _ = clientSession.Close() }() - - res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ - Name: ToolLocationsList, - Arguments: map[string]any{}, - }) - if err != nil { - t.Fatalf("calling %s: %v", ToolLocationsList, err) - } - if res.IsError { - t.Fatalf("%s returned an error result: %+v", ToolLocationsList, res.Content) - } - - // Round-tripped through the wire's JSON, so the output schema is exercised - // as the model would receive it. - raw, err := json.Marshal(res.StructuredContent) - if err != nil { - t.Fatalf("marshalling structured content: %v", err) - } - var out LocationsListOutput - if err := json.Unmarshal(raw, &out); err != nil { - t.Fatalf("decoding %s output: %v", ToolLocationsList, err) - } - if len(out.Locations) != 3 { - t.Fatalf("got %d locations over the wire, want 3: %s", len(out.Locations), raw) - } - if out.Locations[0].CityCode != cityAMS { - t.Errorf("locations[0].cityCode = %q, want the city the location declares", out.Locations[0].CityCode) - } -} - -// TestClientDiscovererReadsComputeAvailability covers the one Discoverer that -// talks to a control plane. The tools above run against a fake, so nothing else -// proves that the locations an assistant is shown are the ones compute reports -// itself available at — not every location the platform has, and not another -// service's. -func TestClientDiscovererReadsComputeAvailability(t *testing.T) { - scheme := runtime.NewScheme() - if err := locationsv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering locations: %v", err) - } - if err := servicesv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering service availability: %v", err) - } - - location := func(name, cityCode string) *locationsv1alpha1.Location { - return &locationsv1alpha1.Location{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: locationsv1alpha1.LocationSpec{ - LocationClassRef: locationsv1alpha1.LocationClassReference{Name: "datum-managed"}, - Topology: map[string]string{locations.TopologyCityCodeKey: cityCode}, - }, - Status: locationsv1alpha1.LocationStatus{ - Conditions: []metav1.Condition{{ - Type: locationsv1alpha1.LocationConditionReady, - Status: metav1.ConditionTrue, - Reason: "Ready", - LastTransitionTime: metav1.Now(), - }}, - }, - } - } - availability := func(name, service, at string, status metav1.ConditionStatus) *servicesv1alpha1.ServiceAvailability { - return &servicesv1alpha1.ServiceAvailability{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: servicesv1alpha1.ServiceAvailabilitySpec{ - ServiceRef: servicesv1alpha1.ServiceRef{Name: service}, - LocationRef: servicesv1alpha1.LocationRef{Name: at}, - }, - Status: servicesv1alpha1.ServiceAvailabilityStatus{ - Conditions: []metav1.Condition{{ - Type: "Available", - Status: status, - Reason: "Reported", - LastTransitionTime: metav1.Now(), - }}, - }, - } - } - - cl := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects( - location(locDFW, cityDFW), - location("eu-west-ams", cityAMS), - location("us-east-iad", "IAD"), - availability("compute-dfw", "compute", locDFW, metav1.ConditionTrue), - // Compute is not up here yet, so it is not somewhere to place. - availability("compute-ams", "compute", "eu-west-ams", metav1.ConditionFalse), - // Another service is available at IAD. Compute is not, and the - // project's control plane carries every service's records. - availability("dns-iad", "dns", "us-east-iad", metav1.ConditionTrue), - ). - Build() - - found, err := NewClientDiscoverer(cl).ListPlacementLocations(context.Background()) - if err != nil { - t.Fatalf("ListPlacementLocations: %v", err) - } - if len(found) != 1 { - t.Fatalf("got %d locations %+v, want only the one compute is available at", len(found), found) - } - if found[0].Name != locDFW { - t.Errorf("location = %q, want us-south-dfw", found[0].Name) - } - if code, ok := found[0].CityCode(); !ok || code != cityDFW { - t.Errorf("cityCode = %q (declared %v), want %q from the location itself", code, ok, cityDFW) - } - if !found[0].Placeable() { - t.Error("the location is Ready and compute is available there; want placeable") - } -} - -// TestLocationsListBlamesTheDeploymentWhenAvailabilityIsNotServed is the case -// the empty list must never be given for. A project that cannot be asked where -// compute is offered has to say so: told "no locations", a customer waits for -// Datum to add one, and nobody ever looks at the deployment that is actually -// broken. -func TestLocationsListBlamesTheDeploymentWhenAvailabilityIsNotServed(t *testing.T) { - scheme := runtime.NewScheme() - if err := locationsv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering locations: %v", err) - } - if err := servicesv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("registering service availability: %v", err) - } - - // Nothing here serves the availability records, the way a project the - // service was never installed for behaves. - notServed := interceptor.Funcs{ - List: func( - ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption, - ) error { - if _, ok := list.(*servicesv1alpha1.ServiceAvailabilityList); ok { - return &apimeta.NoKindMatchError{GroupKind: schema.GroupKind{Kind: "ServiceAvailability"}} - } - return c.List(ctx, list, opts...) - }, - } - - cl := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(notServed).Build() - disc := NewClientDiscoverer(cl) - - found, err := disc.ListPlacementLocations(context.Background()) - if err == nil { - t.Fatalf("ListPlacementLocations returned %+v and no error; a kind nobody serves must not "+ - "read as a project with nowhere to run", found) - } - if !errors.Is(err, locations.ErrAvailabilityNotServed) { - t.Errorf("error = %v, want it to stay identifiable as the availability read failing", err) - } - - // The tool has to relay it, not swallow it into an empty list. - _, out, err := locationsList(discoveryDeps(disc))(context.Background(), nil, LocationsListInput{}) - if err == nil { - t.Fatalf("%s returned %+v and no error", ToolLocationsList, out) - } - - // What the customer is told: the deployment is at fault, they are not, and - // nothing in the wording is Datum's internal vocabulary. - msg := err.Error() - for _, want := range []string{"deployed", "not with the person who asked", "re-authenticating will not help"} { - if !strings.Contains(msg, want) { - t.Errorf("error %q does not say %q; the customer must not be sent to fix their workload", msg, want) - } - } - terms := append(internalVocabulary(), customerFacingOnly()...) - // "ServiceAvailability" travels as an identifier, as a reason code does. - checkCopy(t, ToolLocationsList+" not-served error", msg, terms, "ServiceAvailability") -} - -// locDFW is the location name the discovery tests place in DFW. -const locDFW = "us-south-dfw" diff --git a/internal/agent/instancetypes.go b/internal/agent/instancetypes.go new file mode 100644 index 00000000..c70c641f --- /dev/null +++ b/internal/agent/instancetypes.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "go.datum.net/compute/pkg/instancetype" +) + +// ToolInstanceTypesList lists the instance types a Workload may ask for. +// +// A model with no catalog invents a plausible size, and the API rejects it the +// moment it is submitted. The catalog is compiled into compute rather than +// published as a resource, so the assistant's generic resource tools cannot +// read it and compute has to. +const ToolInstanceTypesList = "compute_instance_types_list" + +// InstanceTypeView is one instance type a Workload may ask for. +type InstanceTypeView struct { + Name string `json:"name"` + // VCPU is how many virtual CPUs the type provides. Fractional, because the + // size is stored in thousandths and a future type need not be a whole one. + VCPU float64 `json:"vcpu"` + // MemoryMiB is the RAM the type provides, in mebibytes. + MemoryMiB int64 `json:"memoryMiB"` + // Default marks the type to use when the customer expressed no preference. + Default bool `json:"default"` +} + +// InstanceTypesListInput takes no arguments. +type InstanceTypesListInput struct{} + +// InstanceTypesListOutput is the catalog of instance types. +type InstanceTypesListOutput struct { + InstanceTypes []InstanceTypeView `json:"instanceTypes"` +} + +// registerInstanceTypesTool adds compute_instance_types_list to s. +func registerInstanceTypesTool(s *mcp.Server, deps DepsFor) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolInstanceTypesList, + Title: "List instance types", + Description: "List the instance types a Workload may ask for, with the vCPU and memory each one " + + "provides and which is the default. Only these names are accepted — a Workload naming any " + + "other is rejected the moment it is submitted, so never invent a size. Read-only.", + }, instanceTypesList(deps)) +} + +// instanceTypesList reads only the catalog, but still resolves deps for the +// same reason reasonExplain does: an unauthenticated caller must not be able to +// use it to probe the server. +func instanceTypesList(deps DepsFor) mcp.ToolHandlerFor[InstanceTypesListInput, InstanceTypesListOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, _ InstanceTypesListInput, + ) (*mcp.CallToolResult, InstanceTypesListOutput, error) { + if _, err := deps(ctx); err != nil { + return nil, InstanceTypesListOutput{}, err + } + return nil, InstanceTypesListOutput{InstanceTypes: InstanceTypes()}, nil + } +} + +// InstanceTypes returns the instance types a Workload may ask for, in offer +// order. The first is the default: the order the platform's catalog lists them +// in is the order to prefer them. +// +// Names and sizes both come from pkg/instancetype, the same table the instance +// controller claims quota against. Restating either here would let the tool +// offer a size the API bills differently. +func InstanceTypes() []InstanceTypeView { + names := instancetype.Names() + out := make([]InstanceTypeView, 0, len(names)) + for i, name := range names { + size, _ := instancetype.Lookup(name) + out = append(out, InstanceTypeView{ + Name: name, + VCPU: float64(size.CPUMillicores) / 1000, + MemoryMiB: size.MemoryMiB, + Default: i == 0, + }) + } + return out +} diff --git a/internal/agent/instancetypes_test.go b/internal/agent/instancetypes_test.go new file mode 100644 index 00000000..7813636d --- /dev/null +++ b/internal/agent/instancetypes_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + "errors" + "testing" +) + +func TestInstanceTypesListOffersOnlyWhatValidationAccepts(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + + _, out, err := instanceTypesList(deps)(context.Background(), nil, InstanceTypesListInput{}) + if err != nil { + t.Fatalf("compute_instance_types_list: %v", err) + } + if len(out.InstanceTypes) == 0 { + t.Fatal("no instance types offered; a model with no catalog invents one") + } + + var defaults int + for _, it := range out.InstanceTypes { + if it.Default { + defaults++ + } + // A type with no size is worse than useless: it invites a replica count + // chosen against nothing. + if it.VCPU <= 0 || it.MemoryMiB <= 0 { + t.Errorf("%s = %g vCPU / %d MiB, want a real size", it.Name, it.VCPU, it.MemoryMiB) + } + } + if defaults != 1 { + t.Errorf("got %d default instance types, want exactly 1", defaults) + } + + // The one supported type today, with the sizing quota is accounted against. + first := out.InstanceTypes[0] + if first.Name != "datumcloud/d1-standard-2" || first.VCPU != 1 || first.MemoryMiB != 2048 { + t.Errorf("first type = %+v, want datumcloud/d1-standard-2 at 1 vCPU / 2048 MiB", first) + } +} + +// TestInstanceTypesListFailsWhenDepsAreUnavailable: the catalog alone could +// answer, but an unauthenticated caller is still turned away so the tool is not +// a probe. +func TestInstanceTypesListFailsWhenDepsAreUnavailable(t *testing.T) { + wantErr := errors.New("no credentials on this request") + denied := DepsFor(func(context.Context) (ToolDeps, error) { return ToolDeps{}, wantErr }) + + if _, _, err := instanceTypesList(denied)(context.Background(), nil, InstanceTypesListInput{}); !errors.Is(err, wantErr) { + t.Errorf("compute_instance_types_list error = %v, want the deps error to surface unchanged", err) + } +} diff --git a/internal/agent/render.go b/internal/agent/render.go new file mode 100644 index 00000000..75d11a7c --- /dev/null +++ b/internal/agent/render.go @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.datum.net/compute/internal/workloadspec" +) + +// ToolWorkloadRender turns a short description of a deployment into a complete +// Workload manifest. +// +// Compute publishes no tool that writes. The assistant's own base tools +// validate, plan and apply manifests of any kind as the caller, behind a plan +// token and an explicit confirmation. What they cannot know is how to write a +// Workload the admission webhook accepts, and a model guessing at that invents +// fields plausibly. Rendering is the compute-specific half, so it is the half +// compute publishes. +const ToolWorkloadRender = "compute_workload_render" + +// The assistant's base tools the render output points at. They belong to the +// assistant, not to this server, so nothing here can check they exist; the +// names are the assistant's published contract. +const ( + baseToolLocationsList = "locations_list" + baseToolResourcesList = "resources_list" + baseToolResourcesPlan = "resources_plan" +) + +// ---------------------------------------------------------------- I/O types + +// RenderPlacement is one group of locations scaled together. +type RenderPlacement struct { + Name string `json:"name,omitempty" jsonschema:"Placement name, a DNS label. Defaults to \"default\"."` + // Locations and LocationSelector are the two ways to say where a placement + // runs, and exactly one of them must be given. The schema says so rather + // than leaving a model to discover it from a rejection. + Locations []string `json:"locations,omitempty" jsonschema:"Location names this placement runs in, e.g. [\"us-south-dfw-1\"]. Take the names verbatim from locations_list with service \"compute\" — a name that is not in that list can never be satisfied. Set exactly one of locations or locationSelector."` + LocationSelector *RenderLocationSelector `json:"locationSelector,omitempty" jsonschema:"Place at every location whose topology matches, instead of naming them. Use this for \"every location in Dallas\" or \"every location in a region\": match on the topology keys locations_list reports, such as topology.datum.net/city-code. New locations matching it are picked up automatically. Set exactly one of locations or locationSelector."` + MinReplicas int32 `json:"minReplicas,omitempty" jsonschema:"Instances to run per placement. At least 1 — there is no scaling to zero — and at most 1000. Defaults to 1."` +} + +// RenderLocationSelector is a label selector over location topology, in the +// two forms the API accepts. An empty selector is refused rather than read as +// matching every location. +type RenderLocationSelector struct { + MatchLabels map[string]string `json:"matchLabels,omitempty" jsonschema:"Topology key/value pairs a location must carry, e.g. {\"topology.datum.net/city-code\": \"DFW\"}."` + MatchExpressions []RenderLocationSelectorReq `json:"matchExpressions,omitempty" jsonschema:"Set-based requirements over topology keys, for cases matchLabels cannot express, such as one of several cities."` +} + +// RenderLocationSelectorReq is one set-based requirement. +type RenderLocationSelectorReq struct { + Key string `json:"key" jsonschema:"Topology key, e.g. topology.datum.net/city-code."` + Operator string `json:"operator" jsonschema:"In, NotIn, Exists or DoesNotExist."` + Values []string `json:"values,omitempty" jsonschema:"Values for In and NotIn. Must be empty for Exists and DoesNotExist."` +} + +// RenderPort is a named port the workload serves. +type RenderPort struct { + Name string `json:"name" jsonschema:"Port name, e.g. \"http\". At most 15 characters, and must contain a letter."` + Port int32 `json:"port" jsonschema:"Port number, 1 to 65535."` + Protocol string `json:"protocol,omitempty" jsonschema:"TCP, UDP or SCTP. Defaults to TCP."` +} + +// RenderKeyRef selects one key of a ConfigMap or Secret. +type RenderKeyRef struct { + Name string `json:"name" jsonschema:"Name of the ConfigMap or Secret, which must already exist in the project."` + Key string `json:"key" jsonschema:"Key within it."` +} + +// RenderEnvVar is one environment variable on the container. +type RenderEnvVar struct { + Name string `json:"name" jsonschema:"Variable name."` + Value string `json:"value,omitempty" jsonschema:"Literal value. Set at most one of value, configMapKeyRef, secretKeyRef."` + ConfigMapKeyRef *RenderKeyRef `json:"configMapKeyRef,omitempty" jsonschema:"Read the value from a ConfigMap key instead."` + SecretKeyRef *RenderKeyRef `json:"secretKeyRef,omitempty" jsonschema:"Read the value from a Secret key instead."` +} + +// RenderMount projects a ConfigMap or Secret into the instance's filesystem. +type RenderMount struct { + Name string `json:"name,omitempty" jsonschema:"Volume name. Defaults to the ConfigMap or Secret name."` + ConfigMap string `json:"configMap,omitempty" jsonschema:"Name of the ConfigMap to mount. Set exactly one of configMap or secret."` + Secret string `json:"secret,omitempty" jsonschema:"Name of the Secret to mount. Set exactly one of configMap or secret."` + MountPath string `json:"mountPath" jsonschema:"Absolute path the contents appear at inside the instance."` +} + +// RenderVM asks for a virtual machine rather than a container. +type RenderVM struct { + SSHKeys []string `json:"sshKeys" jsonschema:"Keys authorized to log in, each \"username:ssh-public-key\". At least one — a machine with no key is unreachable and is rejected."` + BootImage string `json:"bootImage,omitempty" jsonschema:"Disk image the machine boots. Defaults to datumcloud/ubuntu-2204-lts, currently the only one accepted."` +} + +// WorkloadRenderInput is the flat description a manifest is rendered from. It +// mirrors workloadspec.Input field for field, so the tool schema can be worded +// for a model without that wording leaking into the renderer. +type WorkloadRenderInput struct { + Name string `json:"name" jsonschema:"Workload name, a DNS label, e.g. \"api-backend\". Cannot be changed later."` + Image string `json:"image,omitempty" jsonschema:"Fully qualified container image, e.g. \"ghcr.io/acme/api:1.4.2\". Required unless vm is set. A bare name is the most common cause of ImageUnavailable afterwards."` + InstanceType string `json:"instanceType,omitempty" jsonschema:"Instance type from compute_instance_types_list. Defaults to the only one accepted today."` + RuntimeClass string `json:"runtimeClass,omitempty" jsonschema:"Execution tier the instances run in, named verbatim from the RuntimeClass objects resources_list returns for compute.datumapis.com/v1alpha. Leave unset unless the person named one: the server picks its default, and the tier cannot be changed after the workload exists."` + Network string `json:"network,omitempty" jsonschema:"Network the instance attaches to. Defaults to \"default\"."` + Placements []RenderPlacement `json:"placements" jsonschema:"Where instances run and how many. At least one is required."` + Ports []RenderPort `json:"ports,omitempty" jsonschema:"Named ports the workload serves. Each is also opened to the internet, since a port nothing can reach is not useful."` + Env []RenderEnvVar `json:"env,omitempty" jsonschema:"Environment variables on the container. Not accepted for a virtual machine."` + ConfigMounts []RenderMount `json:"configMounts,omitempty" jsonschema:"ConfigMaps and Secrets projected into the instance's filesystem."` + PublicIPv4 bool `json:"publicIPv4,omitempty" jsonschema:"Ask for a public IPv4 address. Settled at create: it cannot be added or removed later, so ask before rendering rather than defaulting it."` + Labels map[string]string `json:"labels,omitempty" jsonschema:"Labels applied to the workload and to every instance it creates."` + VM *RenderVM `json:"vm,omitempty" jsonschema:"Render a virtual machine instead of a container. Only when the person needs a whole operating system to log into."` +} + +// WorkloadRenderOutput is the manifest and what rendering it settled. +type WorkloadRenderOutput struct { + // Manifest is the complete Workload, as YAML. + Manifest string `json:"manifest"` + // Notes are the decisions this manifest fixes for the life of the workload + // and the defaults that were filled in. Worth reading out: several of them + // cannot be changed after the first apply. + Notes []string `json:"notes,omitempty"` +} + +// ------------------------------------------------------------ registration + +// registerRenderTool adds compute_workload_render to s. +func registerRenderTool(s *mcp.Server, deps DepsFor) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolWorkloadRender, + Title: "Render a workload manifest", + Description: "Turn a short description of a deployment — name, image, where, how many — into a " + + "complete Workload manifest, and report what rendering it settled. Nothing is read and nothing " + + "is changed, so render as often as it takes to get the manifest right. Read the manifest that " + + "comes back rather than assuming it says what was asked for, and read the notes: they name the " + + "choices that cannot be changed once the workload exists, the interface's address families and " + + "a public IPv4 address among them. Gather the inputs from the person rather than inventing " + + "them: take location names from locations_list with service \"compute\" and the instance " + + "type from compute_instance_types_list. A placement either names locations or selects them by " + + "topology; use a locationSelector for \"every location in a city or region\", which also picks " + + "up locations added later. The manifest is then passed to resources_plan and, once the person " + + "agrees, resources_apply. Load the workload-create skill before using this. Writes nothing.", + }, workloadRender(deps)) +} + +// ---------------------------------------------------------------- handlers + +func workloadRender(deps DepsFor) mcp.ToolHandlerFor[WorkloadRenderInput, WorkloadRenderOutput] { + return func( + ctx context.Context, _ *mcp.CallToolRequest, in WorkloadRenderInput, + ) (*mcp.CallToolResult, WorkloadRenderOutput, error) { + // Rendering reads nothing, but an unauthenticated caller must not be + // able to use it as a probe, the same rule compute_reason_explain follows. + if _, err := deps(ctx); err != nil { + return nil, WorkloadRenderOutput{}, err + } + + spec := toSpecInput(in) + workload, err := workloadspec.Render(spec) + if err != nil { + return nil, WorkloadRenderOutput{}, err + } + manifest, err := workloadspec.MarshalYAML(workload) + if err != nil { + return nil, WorkloadRenderOutput{}, err + } + + return nil, WorkloadRenderOutput{ + Manifest: string(manifest), + Notes: renderNotes(spec), + }, nil + } +} + +// ---------------------------------------------------------------- rendering + +// toSpecInput converts the tool's input to workloadspec's. A straight mapping, +// kept explicit so the tool schema can be worded for a model without that +// wording leaking into the renderer. +func toSpecInput(in WorkloadRenderInput) workloadspec.Input { + out := workloadspec.Input{ + Name: in.Name, + Image: in.Image, + InstanceType: in.InstanceType, + RuntimeClass: in.RuntimeClass, + Network: in.Network, + PublicIPv4: in.PublicIPv4, + Labels: in.Labels, + } + + for _, p := range in.Placements { + out.Placements = append(out.Placements, workloadspec.Placement{ + Name: p.Name, + Locations: p.Locations, + LocationSelector: toLabelSelector(p.LocationSelector), + MinReplicas: p.MinReplicas, + }) + } + for _, p := range in.Ports { + out.Ports = append(out.Ports, workloadspec.Port{ + Name: p.Name, + Port: p.Port, + Protocol: corev1.Protocol(p.Protocol), + }) + } + for _, e := range in.Env { + out.Env = append(out.Env, workloadspec.EnvVar{ + Name: e.Name, + Value: e.Value, + ConfigMapKeyRef: toKeyRef(e.ConfigMapKeyRef), + SecretKeyRef: toKeyRef(e.SecretKeyRef), + }) + } + for _, m := range in.ConfigMounts { + out.ConfigMounts = append(out.ConfigMounts, workloadspec.Mount{ + Name: m.Name, + ConfigMap: m.ConfigMap, + Secret: m.Secret, + MountPath: m.MountPath, + }) + } + if in.VM != nil { + out.VM = &workloadspec.VMInput{ + SSHKeys: in.VM.SSHKeys, + BootImage: in.VM.BootImage, + } + } + + return out +} + +// toLabelSelector converts the tool's selector to the API's. The operator is +// passed through verbatim: an unrecognized one is refused by the render's own +// validation with the field path, which is more useful than silently dropping +// the requirement here. +func toLabelSelector(sel *RenderLocationSelector) *metav1.LabelSelector { + if sel == nil { + return nil + } + out := &metav1.LabelSelector{MatchLabels: sel.MatchLabels} + for _, req := range sel.MatchExpressions { + out.MatchExpressions = append(out.MatchExpressions, metav1.LabelSelectorRequirement{ + Key: req.Key, + Operator: metav1.LabelSelectorOperator(req.Operator), + Values: req.Values, + }) + } + return out +} + +func toKeyRef(ref *RenderKeyRef) *workloadspec.KeyRef { + if ref == nil { + return nil + } + return &workloadspec.KeyRef{Name: ref.Name, Key: ref.Key} +} + +// renderNotes says what this manifest settled that a later render cannot +// correct, and which values were filled in for a caller who did not name them. +// +// It is written from the input as given, before defaults are applied, so +// "defaulted to" means the person did not choose it — which is the thing they +// need to be asked about while the workload can still be changed. +func renderNotes(in workloadspec.Input) []string { + notes := []string{ + "The instance's single network interface is settled by this manifest and cannot be changed " + + "once the workload exists: its name, the address families it carries, any extra addresses, " + + "and what becomes of those addresses when an instance goes away. Getting one of them wrong " + + "means creating a new workload, not editing this one.", + } + + if in.PublicIPv4 { + notes = append(notes, "A public IPv4 address was asked for, so the interface carries both IPv4 "+ + "and IPv6. Neither the address nor the families can be removed later.") + } else { + notes = append(notes, "The interface carries IPv6 only, which is the default. If this workload "+ + "has to answer on IPv4, say so before it is applied: IPv4 cannot be added afterwards.") + } + + notes = append(notes, "Addresses are given back when an instance goes away. Keeping one — an "+ + "address published in DNS, or allowed through someone's firewall — means editing this manifest "+ + "before the first apply.") + + if in.InstanceType == "" { + notes = append(notes, fmt.Sprintf( + "No instance type was given, so every instance is %s. Per-container CPU and memory are not "+ + "accepted: the instance type is what decides the size.", workloadspec.DefaultInstanceType)) + } + if in.Network == "" { + notes = append(notes, fmt.Sprintf( + "No network was named, so the interface attaches to %q. Check it exists with %s; if it does "+ + "not, plan a Network manifest of that name in the same %s call as this workload.", + workloadspec.DefaultNetwork, baseToolResourcesList, baseToolResourcesPlan)) + } + for _, p := range in.Placements { + if p.Name == "" { + notes = append(notes, fmt.Sprintf("A placement was not named, so it is called %q.", + workloadspec.DefaultPlacementName)) + } + if p.MinReplicas == 0 { + notes = append(notes, fmt.Sprintf( + "Placement %q did not say how many instances to run, so it runs %d. There is no "+ + "scaling to zero.", placementName(p), workloadspec.DefaultMinReplicas)) + } + if p.LocationSelector != nil { + notes = append(notes, fmt.Sprintf( + "Placement %q selects its locations by topology rather than naming them, so it runs "+ + "wherever the selector matches — including locations added later, which will "+ + "start instances without this manifest changing. %s shows which locations match "+ + "today.", placementName(p), baseToolLocationsList)) + } + } + if in.VM != nil && in.VM.BootImage == "" { + notes = append(notes, fmt.Sprintf( + "No boot image was given, so the machine boots %s, currently the only one accepted.", + workloadspec.DefaultBootImage)) + } + + return notes +} + +func placementName(p workloadspec.Placement) string { + if p.Name == "" { + return workloadspec.DefaultPlacementName + } + return p.Name +} diff --git a/internal/agent/render_test.go b/internal/agent/render_test.go new file mode 100644 index 00000000..b67b8843 --- /dev/null +++ b/internal/agent/render_test.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package agent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + sigsyaml "sigs.k8s.io/yaml" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/locations" + "go.datum.net/compute/internal/workloadspec" +) + +const testImage = "ghcr.io/acme/api:1.4.2" + +// renderInput is the everyday case: one container, one location, one port. +func renderInput() WorkloadRenderInput { + return WorkloadRenderInput{ + Name: wlAPIBackend, + Image: testImage, + Placements: []RenderPlacement{{Locations: []string{locationDFW}, MinReplicas: 2}}, + Ports: []RenderPort{{Name: "http", Port: 8080}}, + } +} + +// TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled: the manifest is only +// half the answer. The notes carry the decisions that cannot be corrected by a +// later render, and a model that does not read them out lets a person agree to +// something they would have to recreate the workload to change. +func TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + + _, out, err := workloadRender(deps)(context.Background(), nil, renderInput()) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + + for _, want := range []string{ + "kind: Workload", + "name: " + wlAPIBackend, + testImage, + "minReplicas: 2", + "- name: " + locationDFW, + } { + if !strings.Contains(out.Manifest, want) { + t.Errorf("manifest is missing %q:\n%s", want, out.Manifest) + } + } + // The manifest is handed on to the assistant's plan tool as-is, so it has + // to read straight back into a Workload. + var decoded computev1alpha.Workload + if err := sigsyaml.UnmarshalStrict([]byte(out.Manifest), &decoded); err != nil { + t.Errorf("the rendered manifest does not read back: %v", err) + } + + notes := strings.Join(out.Notes, "\n") + // The interface is settled at create, and the instance type and network + // were defaulted rather than chosen — both are things to say out loud + // while the workload can still be changed. A missing network is planned + // alongside the workload, so the note has to say how. + for _, want := range []string{ + "cannot be changed once the workload exists", + "IPv6 only", + workloadspec.DefaultInstanceType, + "\"" + workloadspec.DefaultNetwork + "\"", + baseToolResourcesPlan, + } { + if !strings.Contains(notes, want) { + t.Errorf("notes do not mention %q:\n%s", want, notes) + } + } +} + +// TestWorkloadRenderSelectsLocationsByTopology covers the second way a +// placement says where: a selector over location topology rather than a list of +// names. It is the only way to say "every location in this city", and it keeps +// matching locations added later — which is a standing behaviour the person +// agreeing to the manifest has to be told about, so the notes carry it. +func TestWorkloadRenderSelectsLocationsByTopology(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + in := renderInput() + in.Placements = []RenderPlacement{{ + LocationSelector: &RenderLocationSelector{ + MatchLabels: map[string]string{locations.TopologyCityCodeKey: cityDFW}, + }, + MinReplicas: 2, + }} + + _, out, err := workloadRender(deps)(context.Background(), nil, in) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + for _, want := range []string{"locationSelector:", locations.TopologyCityCodeKey + ": " + cityDFW} { + if !strings.Contains(out.Manifest, want) { + t.Errorf("manifest is missing %q:\n%s", want, out.Manifest) + } + } + if strings.Contains(out.Manifest, "locations:") { + t.Errorf("a selector was given, so no location list may be emitted:\n%s", out.Manifest) + } + if !strings.Contains(strings.Join(out.Notes, "\n"), "locations added later") { + t.Errorf("notes do not say the selector keeps matching new locations:\n%s", out.Notes) + } +} + +// TestWorkloadRenderPassesTheRuntimeClassThrough: the tier is the server's +// catalog to own. Whatever the person named goes through verbatim, and naming +// nothing leaves the field off so the server picks its own default rather than +// this tool settling a choice that cannot be changed afterwards. +func TestWorkloadRenderPassesTheRuntimeClassThrough(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + + _, bare, err := workloadRender(deps)(context.Background(), nil, renderInput()) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + if strings.Contains(bare.Manifest, "class:") { + t.Errorf("no runtime class was asked for, so none may be rendered:\n%s", bare.Manifest) + } + + in := renderInput() + in.RuntimeClass = "datum-sandbox" + _, out, err := workloadRender(deps)(context.Background(), nil, in) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + if !strings.Contains(out.Manifest, "class: datum-sandbox") { + t.Errorf("manifest does not carry the runtime class that was asked for:\n%s", out.Manifest) + } +} + +// TestWorkloadRenderReportsAPublicAddressAsFinal: asking for IPv4 fixes the +// address families for the life of the workload, so the note has to change +// with the input rather than always saying the same thing. +func TestWorkloadRenderReportsAPublicAddressAsFinal(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + in := renderInput() + in.PublicIPv4 = true + + _, out, err := workloadRender(deps)(context.Background(), nil, in) + if err != nil { + t.Fatalf("compute_workload_render: %v", err) + } + notes := strings.Join(out.Notes, "\n") + if !strings.Contains(notes, "public IPv4 address was asked for") { + t.Errorf("notes do not report the public address as settled:\n%s", notes) + } + if strings.Contains(notes, "IPv6 only") { + t.Errorf("notes still claim IPv6 only after IPv4 was asked for:\n%s", notes) + } +} + +// TestWorkloadRenderRefusesAnIncompleteInput: a missing image is the caller's +// to supply, and rendering something plausible around a name nobody pushed is +// the failure this prevents. +func TestWorkloadRenderRefusesAnIncompleteInput(t *testing.T) { + deps := fixtureDeps(fixtureReader()) + in := renderInput() + in.Image = "" + + if _, _, err := workloadRender(deps)(context.Background(), nil, in); err == nil { + t.Error("compute_workload_render accepted an input with no image") + } +} + +// TestWorkloadRenderFailsWhenDepsAreUnavailable: rendering reads nothing, but +// it must not be a probe an unauthenticated caller can use either. +func TestWorkloadRenderFailsWhenDepsAreUnavailable(t *testing.T) { + wantErr := errors.New("no bearer token on the request") + denied := DepsFor(func(context.Context) (ToolDeps, error) { return ToolDeps{}, wantErr }) + + if _, _, err := workloadRender(denied)(context.Background(), nil, renderInput()); !errors.Is(err, wantErr) { + t.Errorf("compute_workload_render error = %v, want the deps error to surface unchanged", err) + } +} + +// TestWorkloadRenderAnswersOverTheWire proves registration and the schemas, +// not just the handler: a tool that is never wired into RegisterTools passes +// every test above and is uncallable in production, and an output the SDK +// cannot encode reaches the model as nothing at all. +func TestWorkloadRenderAnswersOverTheWire(t *testing.T) { + ctx := context.Background() + + server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) + RegisterTools(server, fixtureDeps(fixtureReader())) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("connecting server: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connecting client: %v", err) + } + defer func() { _ = clientSession.Close() }() + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: ToolWorkloadRender, + Arguments: map[string]any{ + "name": wlAPIBackend, + "image": testImage, + "placements": []map[string]any{ + {"locations": []string{locationDFW}, "minReplicas": 2}, + }, + }, + }) + if err != nil { + t.Fatalf("calling %s: %v", ToolWorkloadRender, err) + } + if res.IsError { + t.Fatalf("%s returned an error result: %+v", ToolWorkloadRender, res.Content) + } + + // Round-tripped through the wire's JSON, so the output schema is exercised + // as the model would receive it. + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshalling structured content: %v", err) + } + var out WorkloadRenderOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decoding %s output: %v", ToolWorkloadRender, err) + } + if !strings.Contains(out.Manifest, "name: "+wlAPIBackend) { + t.Errorf("render over the wire returned no usable manifest: %s", raw) + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 21659096..e588776b 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -12,23 +12,16 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" ) -// The tools compute publishes to an assistant. These five are read-only, as -// are the four discovery tools in discovery.go. +// The tools compute publishes to an assistant. These five diagnose what a +// project has deployed; render.go and instancetypes.go add the two that help +// write a new workload. None of them changes anything. // -// Compute publishes exactly two tools that can change anything: -// compute_workload_plan and compute_workload_apply, in write.go. They are one -// operation split in half. Plan returns a manifest and a token that is a hash -// of it; apply takes that manifest and that token and re-derives the hash, so -// the only thing that can be created is the manifest the model already showed -// the person who asked, unchanged, in this project, against the workload the -// plan saw. Everything else about the surface is unchanged by them: every call -// runs as the caller's own credential, so a tool can write nothing the person -// could not write themselves, and whether the mutating tools are offered to a -// given project at all is the gateway's allow-list to decide. -// -// There is still no delete, no scale, and no restart. A third mutating tool is -// a new decision and gets its own review — the argument for these two is about -// these two and does not generalise. +// There is deliberately no mutating tool — no delete, no scale, no restart, +// and no create. Creating a workload goes through the assistant's own plan and +// apply tools, which hold the confirmation step for every service. The +// gateway's allow-list is the enforcement point, but a tool that is never +// implemented cannot be called through any path at all. Adding one needs its +// own review, not a quiet addition here. const ( ToolWorkloadsList = "compute_workloads_list" ToolWorkloadsGet = "compute_workloads_get" @@ -40,24 +33,8 @@ const ( // ToolDeps is what one request's tool calls operate over: where to read from, // and which project's namespace they are confined to. type ToolDeps struct { - Reader Reader - // Discoverer reads what the project may deploy — locations, networks, - // quota. May be nil on a server built for diagnosis only; the discovery - // tools then fail with a message naming that, rather than panicking. - Discoverer Discoverer - // Writer creates and changes workloads. Nil on a server built for - // diagnosis only, and the write tools then say so rather than panicking — - // a deployment that publishes no write path is a supported configuration. - Writer Writer + Reader Reader Namespace string - // Project is the project this request is for. Tools never take it as an - // argument; it is carried here so a plan token can be bound to it, and a - // plan minted for one project is refused in another. - Project string - // PlanTokenKey signs plan tokens. Empty on a server built without the - // write path, which then refuses to mint or accept one: a server that - // cannot check a token must not issue something that looks like one. - PlanTokenKey []byte } // DepsFor resolves the dependencies for a tool call. A function rather than a @@ -194,9 +171,9 @@ type ReasonExplainOutput struct { // ------------------------------------------------------------ registration -// RegisterTools adds every tool compute publishes to s: diagnosis, discovery, -// and the write path. deps is consulted per call rather than captured once, so -// no caller can inherit another's identity or project. +// RegisterTools adds every tool compute publishes to s. deps is consulted per +// call rather than captured once, so no caller can inherit another's identity +// or project. func RegisterTools(s *mcp.Server, deps DepsFor) { mcp.AddTool(s, &mcp.Tool{ Name: ToolWorkloadsList, @@ -252,13 +229,8 @@ func RegisterTools(s *mcp.Server, deps DepsFor) { "diagnose tool did not cover. Read-only.", }, reasonExplain(deps)) - // What the project may deploy, alongside what it has deployed. See - // discovery.go for why an assistant needs both. - RegisterDiscoveryTools(s, deps) - - // And the write path: render and validate, which change nothing, then the - // two token-bound tools that do. See write.go. - RegisterWriteTools(s, deps) + registerInstanceTypesTool(s, deps) + registerRenderTool(s, deps) } // ---------------------------------------------------------------- handlers diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index b23bb0f9..dc092adb 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -23,7 +23,6 @@ const ( locationDFW = "loc-dfw-1" locationAMS = "loc-ams-1" cityDFW = "DFW" - cityAMS = "AMS" ) // The identities the in-memory MCP transports are exercised under. Shared, so @@ -379,22 +378,11 @@ func TestReaderErrorsPropagate(t *testing.T) { } // TestRegisterToolsPublishesExactlyTheDocumentedSet inspects what a registered -// server actually advertises. Two things are pinned here, and they are the -// reason this test is worth its length. -// -// The set is closed: thirteen tools, named, so a fourteenth cannot arrive -// without someone editing this list. The gateway's allow-list is the -// enforcement point, but a tool that does not exist cannot be called through -// any path at all. -// -// And of those thirteen, exactly two may leave out the promise that they -// change nothing: compute_workload_plan and compute_workload_apply. That promise is load -// bearing — it is what tells the model it can run a tool without asking first -// — so a tool that quietly stops making it, or a new mutating tool that never -// made it, fails here rather than in a conversation. -// -// It also catches a schema that fails to infer, since AddTool panics on a bad -// one. +// server actually advertises: seven named tools, none of them mutating, so +// anything extra over the wire is a bug. A tool's promise that it changes +// nothing is what tells the model it can run it without asking first, so every +// description must make it. It also catches a schema that fails to infer, since +// AddTool panics on a bad one. func TestRegisterToolsPublishesExactlyTheDocumentedSet(t *testing.T) { ctx := context.Background() @@ -432,16 +420,9 @@ func TestRegisterToolsPublishesExactlyTheDocumentedSet(t *testing.T) { ToolInstancesList, ToolWorkloadDiagnose, ToolReasonExplain, - // What the project may deploy. - ToolLocationsList, - ToolNetworksList, - ToolQuotaGet, + // What a new workload may ask for, and its manifest. ToolInstanceTypesList, - // Writing: two that cannot change anything, and two that can. ToolWorkloadRender, - ToolWorkloadValidate, - ToolWorkloadPlan, - ToolWorkloadApply, } if len(got) != len(want) { t.Errorf("published %d tools %v, want exactly %d", len(got), keysOf(got), len(want)) @@ -459,21 +440,16 @@ func TestRegisterToolsPublishesExactlyTheDocumentedSet(t *testing.T) { } } - // The two mutating tools, and no others. A tool whose description does not - // promise it changes nothing is one the model has to ask about first, so - // the set of tools making no such promise IS the mutating surface, as the - // model sees it. - mutating := map[string]bool{ToolWorkloadPlan: true, ToolWorkloadApply: true} + // Compute ships no mutating tool. Enforcement of the allow-list is the + // gateway's job, but a tool that does not exist cannot be called at all. for name, desc := range got { - promises := strings.Contains(desc, "Read-only.") || strings.Contains(desc, "Writes nothing.") - switch { - case promises && mutating[name]: - t.Errorf("tool %q changes things but its description promises it does not; "+ - "the model will call it without asking", name) - case !promises && !mutating[name]: - t.Errorf("tool %q does not say it is read-only or writes nothing. Either say so, or — if "+ - "it really can change something — a third mutating tool is a new decision that gets "+ - "its own review, not a quiet addition here", name) + for _, forbidden := range []string{"delete", "create", "update", "scale", "restart", "apply", "plan"} { + if strings.Contains(name, forbidden) { + t.Errorf("tool %q looks mutating; compute publishes read-only tools only", name) + } + } + if !strings.Contains(desc, "Read-only.") && !strings.Contains(desc, "Writes nothing.") { + t.Errorf("tool %q does not say it is read-only or writes nothing", name) } } } diff --git a/internal/agent/write.go b/internal/agent/write.go deleted file mode 100644 index c91f20e9..00000000 --- a/internal/agent/write.go +++ /dev/null @@ -1,1069 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -package agent - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - sigsyaml "sigs.k8s.io/yaml" - - computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/workloadspec" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -// The write path: four tools, of which exactly two can change anything. -// -// It is one operation split into four steps because the model is not the -// person. compute_workload_render turns inputs into a manifest and touches -// nothing. compute_workload_validate asks the server for its verdict on that -// manifest without creating anything. Neither can write, so both are safe to -// run as often as it takes to get the manifest right. -// -// compute_workload_plan and compute_workload_apply are the two that matter. -// Plan returns a canonical manifest and a token that is a hash of it, together -// with the project and the version of the workload the plan saw. Apply takes -// that manifest and that token and re-derives the hash: a manifest edited after -// the plan, a token minted for another project, or a workload someone else -// changed in the meantime all fail to match, and apply refuses rather than -// writing something nobody agreed to. So the only thing that can reach the API -// is the manifest the model already put in front of the person who asked — a -// model that reads a poisoned status message cannot smuggle a different -// workload past a confirmation of this one, and a manifest nobody was shown has -// no token and cannot be applied at all. -const ( - ToolWorkloadRender = "compute_workload_render" - ToolWorkloadValidate = "compute_workload_validate" - ToolWorkloadPlan = "compute_workload_plan" - ToolWorkloadApply = "compute_workload_apply" -) - -const ( - // planTokenTTL is how long a plan token stays good. Long enough for the - // model to show the manifest and the person to read it and answer, short - // enough that a token cannot outlive the conversation it belongs to. - planTokenTTL = 15 * time.Minute - - // actionCreate and actionUpdate are the two things an apply can be. - actionCreate = "create" - actionUpdate = "update" - - // fieldManifest is the field a rejection names when the manifest could not - // be read at all, rather than when the server named a path inside it. - fieldManifest = "manifest" -) - -// Writer creates and changes the objects a deployment needs. -// -// Separate from Reader for the reason the interface exists at all: a server -// built for diagnosis is given no Writer, and the write tools then say so -// rather than failing somewhere further down. Like Reader, whoever constructs -// one decides the identity its writes run under — the server builds one per -// request from the caller's own credentials, so a tool call can create nothing -// the person who asked could not create themselves. -type Writer interface { - // DryRunCreate asks the server to check a create without performing it. - // The error is the server's own rejection, unwrapped, because its wording - // and the field path it names are the answer. - DryRunCreate(ctx context.Context, w *computev1alpha.Workload) error - // DryRunUpdate asks the server to check an update without performing it. - DryRunUpdate(ctx context.Context, w *computev1alpha.Workload) error - // Create creates the workload. - Create(ctx context.Context, w *computev1alpha.Workload) error - // Update replaces the workload. The caller sets the resource version it - // read, so a change made in between is refused by the server. - Update(ctx context.Context, w *computev1alpha.Workload) error - // GetNetwork returns one network by name. A missing network is reported as - // a not-found error, which callers distinguish with apierrors.IsNotFound. - GetNetwork(ctx context.Context, namespace, name string) (*networkingv1alpha.Network, error) - // CreateNetwork creates a network. - CreateNetwork(ctx context.Context, n *networkingv1alpha.Network) error -} - -// ClientWriter implements Writer against a controller-runtime client. -type ClientWriter struct { - // Client writes the project, with whatever credentials it carries. - Client client.Client -} - -var _ Writer = (*ClientWriter)(nil) - -// NewClientWriter returns a Writer backed by c. Every write is performed with -// whatever credentials c carries. -func NewClientWriter(c client.Client) *ClientWriter { - return &ClientWriter{Client: c} -} - -// DryRunCreate copies the workload before handing it over: a dry run still -// returns a populated object, and the caller's copy is what the plan token is -// computed over, so it must come back unchanged. -func (w *ClientWriter) DryRunCreate(ctx context.Context, workload *computev1alpha.Workload) error { - return w.Client.Create(ctx, workload.DeepCopy(), client.DryRunAll) -} - -func (w *ClientWriter) DryRunUpdate(ctx context.Context, workload *computev1alpha.Workload) error { - return w.Client.Update(ctx, workload.DeepCopy(), client.DryRunAll) -} - -func (w *ClientWriter) Create(ctx context.Context, workload *computev1alpha.Workload) error { - if err := w.Client.Create(ctx, workload); err != nil { - return fmt.Errorf("creating workload %s: %w", workload.Name, err) - } - return nil -} - -func (w *ClientWriter) Update(ctx context.Context, workload *computev1alpha.Workload) error { - if err := w.Client.Update(ctx, workload); err != nil { - return fmt.Errorf("updating workload %s: %w", workload.Name, err) - } - return nil -} - -func (w *ClientWriter) GetNetwork( - ctx context.Context, namespace, name string, -) (*networkingv1alpha.Network, error) { - var n networkingv1alpha.Network - key := client.ObjectKey{Namespace: namespace, Name: name} - if err := w.Client.Get(ctx, key, &n); err != nil { - return nil, fmt.Errorf("getting network %s: %w", name, err) - } - return &n, nil -} - -func (w *ClientWriter) CreateNetwork(ctx context.Context, n *networkingv1alpha.Network) error { - if err := w.Client.Create(ctx, n); err != nil { - return fmt.Errorf("creating network %s: %w", n.Name, err) - } - return nil -} - -// ---------------------------------------------------------------- I/O types - -// RenderPlacement is one group of locations scaled together. -type RenderPlacement struct { - Name string `json:"name,omitempty" jsonschema:"Placement name, a DNS label. Defaults to \"default\"."` - // Locations and LocationSelector are the two ways to say where a placement - // runs, and exactly one of them must be given. The schema says so rather - // than leaving a model to discover it from a rejection. - Locations []string `json:"locations,omitempty" jsonschema:"Location names this placement runs in, e.g. [\"us-south-dfw-1\"]. Take the names verbatim from compute_locations_list — a name that is not in that list can never be satisfied. Set exactly one of locations or locationSelector."` - LocationSelector *RenderLocationSelector `json:"locationSelector,omitempty" jsonschema:"Place at every location whose topology matches, instead of naming them. Use this for \"every location in Dallas\" or \"every location in a region\": match on the topology keys compute_locations_list reports, such as topology.datum.net/city-code. New locations matching it are picked up automatically. Set exactly one of locations or locationSelector."` - MinReplicas int32 `json:"minReplicas,omitempty" jsonschema:"Instances to run per placement. At least 1 — there is no scaling to zero — and at most 1000. Defaults to 1."` -} - -// RenderLocationSelector is a label selector over location topology, in the -// two forms the API accepts. An empty selector is refused rather than read as -// matching every location. -type RenderLocationSelector struct { - MatchLabels map[string]string `json:"matchLabels,omitempty" jsonschema:"Topology key/value pairs a location must carry, e.g. {\"topology.datum.net/city-code\": \"DFW\"}."` - MatchExpressions []RenderLocationSelectorReq `json:"matchExpressions,omitempty" jsonschema:"Set-based requirements over topology keys, for cases matchLabels cannot express, such as one of several cities."` -} - -// RenderLocationSelectorReq is one set-based requirement. -type RenderLocationSelectorReq struct { - Key string `json:"key" jsonschema:"Topology key, e.g. topology.datum.net/city-code."` - Operator string `json:"operator" jsonschema:"In, NotIn, Exists or DoesNotExist."` - Values []string `json:"values,omitempty" jsonschema:"Values for In and NotIn. Must be empty for Exists and DoesNotExist."` -} - -// RenderPort is a named port the workload serves. -type RenderPort struct { - Name string `json:"name" jsonschema:"Port name, e.g. \"http\". At most 15 characters, and must contain a letter."` - Port int32 `json:"port" jsonschema:"Port number, 1 to 65535."` - Protocol string `json:"protocol,omitempty" jsonschema:"TCP, UDP or SCTP. Defaults to TCP."` -} - -// RenderKeyRef selects one key of a ConfigMap or Secret. -type RenderKeyRef struct { - Name string `json:"name" jsonschema:"Name of the ConfigMap or Secret, which must already exist in the project."` - Key string `json:"key" jsonschema:"Key within it."` -} - -// RenderEnvVar is one environment variable on the container. -type RenderEnvVar struct { - Name string `json:"name" jsonschema:"Variable name."` - Value string `json:"value,omitempty" jsonschema:"Literal value. Set at most one of value, configMapKeyRef, secretKeyRef."` - ConfigMapKeyRef *RenderKeyRef `json:"configMapKeyRef,omitempty" jsonschema:"Read the value from a ConfigMap key instead."` - SecretKeyRef *RenderKeyRef `json:"secretKeyRef,omitempty" jsonschema:"Read the value from a Secret key instead."` -} - -// RenderMount projects a ConfigMap or Secret into the instance's filesystem. -type RenderMount struct { - Name string `json:"name,omitempty" jsonschema:"Volume name. Defaults to the ConfigMap or Secret name."` - ConfigMap string `json:"configMap,omitempty" jsonschema:"Name of the ConfigMap to mount. Set exactly one of configMap or secret."` - Secret string `json:"secret,omitempty" jsonschema:"Name of the Secret to mount. Set exactly one of configMap or secret."` - MountPath string `json:"mountPath" jsonschema:"Absolute path the contents appear at inside the instance."` -} - -// RenderVM asks for a virtual machine rather than a container. -type RenderVM struct { - SSHKeys []string `json:"sshKeys" jsonschema:"Keys authorized to log in, each \"username:ssh-public-key\". At least one — a machine with no key is unreachable and is rejected."` - BootImage string `json:"bootImage,omitempty" jsonschema:"Disk image the machine boots. Defaults to datumcloud/ubuntu-2204-lts, currently the only one accepted."` -} - -// WorkloadRenderInput is the flat description a manifest is rendered from. It -// mirrors workloadspec.Input field for field, so the manifest a model renders -// and the one `datumctl compute deploy` writes cannot drift apart. -type WorkloadRenderInput struct { - Name string `json:"name" jsonschema:"Workload name, a DNS label, e.g. \"api-backend\". Cannot be changed later."` - Image string `json:"image,omitempty" jsonschema:"Fully qualified container image, e.g. \"ghcr.io/acme/api:1.4.2\". Required unless vm is set. A bare name is the most common cause of ImageUnavailable afterwards."` - InstanceType string `json:"instanceType,omitempty" jsonschema:"Instance type from compute_instance_types_list. Defaults to the only one accepted today."` - RuntimeClass string `json:"runtimeClass,omitempty" jsonschema:"Execution tier the instances run in. Leave unset unless the person named one: the server picks its default, and the tier cannot be changed after the workload exists."` - Network string `json:"network,omitempty" jsonschema:"Network the instance attaches to. Defaults to \"default\"."` - Placements []RenderPlacement `json:"placements" jsonschema:"Where instances run and how many. At least one is required."` - Ports []RenderPort `json:"ports,omitempty" jsonschema:"Named ports the workload serves. Each is also opened to the internet, since a port nothing can reach is not useful."` - Env []RenderEnvVar `json:"env,omitempty" jsonschema:"Environment variables on the container. Not accepted for a virtual machine."` - ConfigMounts []RenderMount `json:"configMounts,omitempty" jsonschema:"ConfigMaps and Secrets projected into the instance's filesystem."` - PublicIPv4 bool `json:"publicIPv4,omitempty" jsonschema:"Ask for a public IPv4 address. Settled at create: it cannot be added or removed later, so ask before rendering rather than defaulting it."` - Labels map[string]string `json:"labels,omitempty" jsonschema:"Labels applied to the workload and to every instance it creates."` - VM *RenderVM `json:"vm,omitempty" jsonschema:"Render a virtual machine instead of a container. Only when the person needs a whole operating system to log into."` -} - -// WorkloadRenderOutput is the manifest and what rendering it settled. -type WorkloadRenderOutput struct { - // Manifest is the complete Workload, as YAML. - Manifest string `json:"manifest"` - // Notes are the decisions this manifest fixes for the life of the workload - // and the defaults that were filled in. Worth reading out: several of them - // cannot be changed after the first apply. - Notes []string `json:"notes,omitempty"` -} - -// FieldError is one rejection, with the field it names. -type FieldError struct { - // Field is the path the server named, e.g. - // "spec.template.spec.volumes[1].name". Empty when the rejection is about - // the manifest as a whole. - Field string `json:"field,omitempty"` - // Message is the server's own wording, kept verbatim so it can be quoted. - Message string `json:"message"` -} - -// WorkloadValidateInput is one manifest to check. -type WorkloadValidateInput struct { - Manifest string `json:"manifest" jsonschema:"A complete Workload manifest as YAML, normally the one compute_workload_render returned."` -} - -// WorkloadValidateOutput is the server's verdict. -type WorkloadValidateOutput struct { - Valid bool `json:"valid"` - Errors []FieldError `json:"errors,omitempty"` - // Exists reports whether a workload of this name is already there, which - // decides whether applying would create or change one. - Exists bool `json:"exists"` - // Diff is what applying would change about the existing workload. Empty - // for a create, and empty for an update that changes nothing this diff - // covers. - Diff []string `json:"diff,omitempty"` -} - -// NetworkPlan says what the plan found out about the network the interface -// names. -type NetworkPlan struct { - Name string `json:"name"` - Exists bool `json:"exists"` - // WillCreate reports that applying this plan creates the network too. Say - // so when showing the plan: it is a second object being created. - WillCreate bool `json:"willCreate"` -} - -// WorkloadPlanInput is the manifest to plan. -type WorkloadPlanInput struct { - Manifest string `json:"manifest" jsonschema:"A complete Workload manifest as YAML, normally the one compute_workload_render returned and compute_workload_validate accepted."` -} - -// WorkloadPlanOutput is everything the person needs to see before agreeing, -// plus the token that binds their agreement to this manifest. -type WorkloadPlanOutput struct { - // Valid is false when the manifest was rejected. There is no token in that - // case, and nothing can be applied until it is fixed. - Valid bool `json:"valid"` - Errors []FieldError `json:"errors,omitempty"` - // Manifest is the canonical form of what was planned. This exact manifest is - // what the token covers and what compute_workload_apply has to be given, and - // it is the one to show the person who asked. - Manifest string `json:"manifest,omitempty"` - // Action is "create" or "update". - Action string `json:"action,omitempty"` - Diff []string `json:"diff,omitempty"` - Network *NetworkPlan `json:"network,omitempty"` - // PlanToken authorizes applying this manifest, and nothing else. - PlanToken string `json:"planToken,omitempty"` - // ExpiresAt is when the token stops being accepted, in RFC 3339. - ExpiresAt string `json:"expiresAt,omitempty"` -} - -// WorkloadApplyInput is the plan, handed back whole. -type WorkloadApplyInput struct { - Manifest string `json:"manifest" jsonschema:"The manifest compute_workload_plan returned, verbatim. One changed character and the token stops matching and nothing is created."` - PlanToken string `json:"planToken" jsonschema:"The token compute_workload_plan returned for that manifest. Only call this after the person who asked has seen the manifest and the diff and said yes."` -} - -// NetworkApplied reports whether the network had to be created alongside the -// workload. -type NetworkApplied struct { - Name string `json:"name,omitempty"` - Created bool `json:"created"` -} - -// WorkloadApplyOutput is what was done. -type WorkloadApplyOutput struct { - Action string `json:"action"` - Workload string `json:"workload"` - Network NetworkApplied `json:"network"` - // Next is the step that turns an accepted request into a running workload, - // which are not the same thing. - Next string `json:"next"` -} - -// ------------------------------------------------------------ registration - -// RegisterWriteTools adds the render, validate, plan and apply tools. Called -// by RegisterTools; separate so the two mutating tools can be read on their -// own, which is what a review of this surface wants to look at. -func RegisterWriteTools(s *mcp.Server, deps DepsFor) { - mcp.AddTool(s, &mcp.Tool{ - Name: ToolWorkloadRender, - Title: "Render a workload manifest", - Description: "Turn a short description of a deployment — name, image, where, how many — into a " + - "complete Workload manifest, and report what rendering it settled. Nothing is read and nothing " + - "is changed, so render as often as it takes to get the manifest right. Read the manifest that " + - "comes back rather than assuming it says what was asked for, and read the notes: they name the " + - "choices that cannot be changed once the workload exists, the interface's address families and " + - "a public IPv4 address among them. Gather the inputs from the person rather than inventing " + - "them, and take location names from compute_locations_list and the instance type from " + - "compute_instance_types_list. A placement either names locations or selects them by topology; use a " + - "locationSelector for \"every location in a city or region\", which also picks up locations added later. " + - "Load the workload-create skill before using this. Writes nothing.", - }, workloadRender(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolWorkloadValidate, - Title: "Validate a workload manifest", - Description: "Ask the server whether a manifest would be accepted, without creating anything. Returns " + - "the exact rejection with the field path it names, whether a workload of that name already " + - "exists, and — when it does — what applying this manifest would change about it. Every " + - "rejection reported here is one that would otherwise arrive after the person was told the " + - "workload was written correctly. Quote the field path verbatim and say in plain words what it " + - "means, fix the manifest, render it again, and validate again. Never plan or apply a manifest " + - "that failed validation. Writes nothing.", - }, workloadValidate(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolWorkloadPlan, - Title: "Plan a workload change", - Description: "Settle everything that has to be true before a workload can be created or changed, and " + - "mint the token that authorizes exactly that. Validates the manifest, resolves whether this is " + - "a create or an update, reports what would change, says whether the network the interface names " + - "is already there or would be created alongside the workload, and returns a canonical manifest " + - "with a plan token that is a hash of it. The manifest in this output is the one the token " + - "covers: show that manifest in full, and the diff, to the person who asked, say what will exist " + - "and where and how many, and get an explicit yes before calling compute_workload_apply. A question " + - "about the plan is not a yes. If anything changes, plan again — a token minted for the earlier " + - "manifest will be refused, and applying it because it was close is exactly what this prevents. " + - "Tokens are good for 15 minutes. Planning by itself creates and changes nothing.", - }, workloadPlan(deps)) - - mcp.AddTool(s, &mcp.Tool{ - Name: ToolWorkloadApply, - Title: "Apply a planned workload", - Description: "Create or change the workload a plan token was minted for, and nothing else. Takes the " + - "manifest compute_workload_plan returned and that plan's token: the token is a hash of that manifest, " + - "the project, and the version of the workload the plan saw, so a manifest edited after the " + - "plan, a token from another project, or a workload someone else changed in the meantime is " + - "refused rather than applied. Call this only once the person who asked has been shown the " + - "plan's manifest and diff and has said yes; if they asked for a change instead, go back and " + - "plan again. When the plan said the network was missing, it is created first. A workload being " + - "created means the request was accepted, not that anything is running — call compute_workload_diagnose " + - "next and say that plainly rather than reporting a deployment.", - }, workloadApply(deps)) -} - -// ---------------------------------------------------------------- handlers - -func workloadRender(deps DepsFor) mcp.ToolHandlerFor[WorkloadRenderInput, WorkloadRenderOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, in WorkloadRenderInput, - ) (*mcp.CallToolResult, WorkloadRenderOutput, error) { - // Rendering reads nothing, but an unauthenticated caller must not be - // able to use it as a probe, the same rule compute_reason_explain follows. - if _, err := deps(ctx); err != nil { - return nil, WorkloadRenderOutput{}, err - } - - spec := toSpecInput(in) - workload, err := workloadspec.Render(spec) - if err != nil { - return nil, WorkloadRenderOutput{}, err - } - manifest, err := workloadspec.MarshalYAML(workload) - if err != nil { - return nil, WorkloadRenderOutput{}, err - } - - return nil, WorkloadRenderOutput{ - Manifest: string(manifest), - Notes: renderNotes(spec), - }, nil - } -} - -func workloadValidate(deps DepsFor) mcp.ToolHandlerFor[WorkloadValidateInput, WorkloadValidateOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, in WorkloadValidateInput, - ) (*mcp.CallToolResult, WorkloadValidateOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, WorkloadValidateOutput{}, err - } - - checked, err := check(ctx, d, in.Manifest) - if err != nil { - return nil, WorkloadValidateOutput{}, err - } - return nil, WorkloadValidateOutput{ - Valid: checked.errors == nil, - Errors: checked.errors, - Exists: checked.existing != nil, - Diff: checked.diff, - }, nil - } -} - -func workloadPlan(deps DepsFor) mcp.ToolHandlerFor[WorkloadPlanInput, WorkloadPlanOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, in WorkloadPlanInput, - ) (*mcp.CallToolResult, WorkloadPlanOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - // Resolved before any work, so a server that cannot mint a token says - // so rather than validating a manifest it could never let through. - key, err := d.planTokenKey() - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - - checked, err := check(ctx, d, in.Manifest) - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - if checked.errors != nil { - return nil, WorkloadPlanOutput{Valid: false, Errors: checked.errors}, nil - } - - network, err := planNetwork(ctx, d, checked.desired) - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - - manifest, err := workloadspec.MarshalYAML(checked.desired) - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - canonical, err := canonicalJSON(checked.desired) - if err != nil { - return nil, WorkloadPlanOutput{}, err - } - - action := actionCreate - if checked.existing != nil { - action = actionUpdate - } - expiry := time.Now().Add(planTokenTTL) - - return nil, WorkloadPlanOutput{ - Valid: true, - Manifest: string(manifest), - Action: action, - Diff: checked.diff, - Network: network, - PlanToken: mintPlanToken(key, canonical, d.Project, resourceVersionOf(checked.existing), expiry), - ExpiresAt: expiry.UTC().Format(time.RFC3339), - }, nil - } -} - -func workloadApply(deps DepsFor) mcp.ToolHandlerFor[WorkloadApplyInput, WorkloadApplyOutput] { - return func( - ctx context.Context, _ *mcp.CallToolRequest, in WorkloadApplyInput, - ) (*mcp.CallToolResult, WorkloadApplyOutput, error) { - d, err := deps(ctx) - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - key, err := d.planTokenKey() - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - w, err := d.writer() - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - - desired, ferr := decodeManifest(in.Manifest, d.Namespace) - if ferr != nil { - return nil, WorkloadApplyOutput{}, fmt.Errorf( - "this manifest could not be read, so nothing was created. %s: %s. Render the workload "+ - "again, plan it, and show the person who asked what came back", - ferr.Field, ferr.Message) - } - existing, err := getExisting(ctx, d, desired.Name) - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - canonical, err := canonicalJSON(desired) - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - - // The token is checked before anything is read further or written: a - // manifest nobody agreed to must not reach the server at all, not even - // as a dry run. - if err := verifyPlanToken( - key, in.PlanToken, canonical, d.Project, resourceVersionOf(existing), time.Now(), - ); err != nil { - return nil, WorkloadApplyOutput{}, err - } - - // Checked once more against the live server, because the plan may have - // been made minutes ago and quota, references and the catalogs all move - // underneath it. A rejection here writes nothing. - attempt := desired.DeepCopy() - if existing != nil { - attempt.ResourceVersion = existing.ResourceVersion - err = w.DryRunUpdate(ctx, attempt) - } else { - err = w.DryRunCreate(ctx, attempt) - } - if err != nil { - return nil, WorkloadApplyOutput{}, fmt.Errorf( - "the server rejected this workload when it was checked again just before creating it, so "+ - "nothing was created: %w. Something changed since the plan was made. Fix the manifest, "+ - "plan again, and show the person who asked what came back", err) - } - - // The network the interface names has to exist for instances to be - // published, and creating it is part of what the plan promised. - applied, err := applyNetwork(ctx, d, w, desired) - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - - action := actionCreate - if existing != nil { - action = actionUpdate - desired.ResourceVersion = existing.ResourceVersion - err = w.Update(ctx, desired) - } else { - err = w.Create(ctx, desired) - } - if err != nil { - return nil, WorkloadApplyOutput{}, err - } - - return nil, WorkloadApplyOutput{ - Action: action, - Workload: desired.Name, - Network: applied, - Next: fmt.Sprintf( - "call %s with name %q. The request was accepted, which is not the same as anything "+ - "running yet: instances appear, then start, and the first pull of a large image takes "+ - "a while", - ToolWorkloadDiagnose, desired.Name), - }, nil - } -} - -// ----------------------------------------------------------------- helpers - -// checked is the shared result of the validate step, which plan and apply both -// begin with. -type checked struct { - // desired is the manifest, normalized. Nil when errors is set. - desired *computev1alpha.Workload - // existing is the workload of that name today, or nil when there is none. - existing *computev1alpha.Workload - // errors is nil when the server accepted the manifest. - errors []FieldError - diff []string -} - -// check decodes a manifest and asks the server for its verdict, without -// persisting anything. A rejection is a result, not an error: the field paths -// are what the model has to act on. -func check(ctx context.Context, d ToolDeps, manifest string) (checked, error) { - w, err := d.writer() - if err != nil { - return checked{}, err - } - - desired, ferr := decodeManifest(manifest, d.Namespace) - if ferr != nil { - return checked{errors: []FieldError{*ferr}}, nil - } - - existing, err := getExisting(ctx, d, desired.Name) - if err != nil { - return checked{}, err - } - - attempt := desired.DeepCopy() - if existing != nil { - // An update is checked at the version that was read, so the check is - // of the change that would actually be made. - attempt.ResourceVersion = existing.ResourceVersion - err = w.DryRunUpdate(ctx, attempt) - } else { - err = w.DryRunCreate(ctx, attempt) - } - if err != nil { - return checked{existing: existing, errors: fieldErrors(err)}, nil - } - - out := checked{desired: desired, existing: existing} - if existing != nil { - out.diff = workloadspec.Diff(existing, desired) - } - return out, nil -} - -// planNetwork reports on the network the interface names. -func planNetwork(ctx context.Context, d ToolDeps, desired *computev1alpha.Workload) (*NetworkPlan, error) { - name := networkNameOf(desired) - if name == "" { - return nil, nil - } - - w, err := d.writer() - if err != nil { - return nil, err - } - if _, err := w.GetNetwork(ctx, d.Namespace, name); err != nil { - if !apierrors.IsNotFound(err) { - return nil, err - } - return &NetworkPlan{Name: name, WillCreate: true}, nil - } - return &NetworkPlan{Name: name, Exists: true}, nil -} - -// applyNetwork creates the network the interface names when it is still -// missing, mirroring what `datumctl compute deploy` does: a minimal network -// with automatic address management. -func applyNetwork( - ctx context.Context, d ToolDeps, w Writer, desired *computev1alpha.Workload, -) (NetworkApplied, error) { - name := networkNameOf(desired) - if name == "" { - return NetworkApplied{}, nil - } - - if _, err := w.GetNetwork(ctx, d.Namespace, name); err == nil { - return NetworkApplied{Name: name}, nil - } else if !apierrors.IsNotFound(err) { - return NetworkApplied{}, err - } - - network := &networkingv1alpha.Network{ - ObjectMeta: metav1.ObjectMeta{Namespace: d.Namespace, Name: name}, - Spec: networkingv1alpha.NetworkSpec{ - IPAM: networkingv1alpha.NetworkIPAM{Mode: networkingv1alpha.NetworkIPAMModeAuto}, - }, - } - if err := w.CreateNetwork(ctx, network); err != nil { - return NetworkApplied{}, fmt.Errorf( - "the workload was not created: the network %q it attaches to is missing and could not be "+ - "created either: %w", name, err) - } - return NetworkApplied{Name: name, Created: true}, nil -} - -// networkNameOf returns the network the workload's interface attaches to. A -// rendered workload has exactly one interface; a hand-written one that has -// none is left to the server to reject. -func networkNameOf(w *computev1alpha.Workload) string { - interfaces := w.Spec.Template.Spec.NetworkInterfaces - if len(interfaces) == 0 { - return "" - } - return interfaces[0].Network.Name -} - -// getExisting returns the workload of that name, or nil when there is none. -func getExisting(ctx context.Context, d ToolDeps, name string) (*computev1alpha.Workload, error) { - if d.Reader == nil { - return nil, fmt.Errorf( - "this server was built without the ability to read the project's workloads, so this tool " + - "cannot answer. The person who asked did nothing wrong: whoever operates this server " + - "needs to configure it") - } - w, err := d.Reader.GetWorkload(ctx, d.Namespace, name) - if err != nil { - if apierrors.IsNotFound(err) { - return nil, nil - } - return nil, err - } - return w, nil -} - -func resourceVersionOf(w *computev1alpha.Workload) string { - if w == nil { - return "" - } - return w.ResourceVersion -} - -// decodeManifest reads a manifest into a Workload and normalizes it: the -// namespace is forced to the project's, the type is stamped, and everything -// the server owns is dropped. What comes back is what the plan token is -// computed over, so two manifests that mean the same thing hash the same. -func decodeManifest(manifest, namespace string) (*computev1alpha.Workload, *FieldError) { - if strings.TrimSpace(manifest) == "" { - return nil, &FieldError{Field: fieldManifest, Message: "a workload manifest is required"} - } - - var w computev1alpha.Workload - // Strict, so a misspelled field is reported rather than silently dropped - // and then missing from a workload that was said to have it. - if err := sigsyaml.UnmarshalStrict([]byte(manifest), &w); err != nil { - return nil, &FieldError{ - Field: fieldManifest, - Message: fmt.Sprintf("this is not a readable Workload manifest: %v", err), - } - } - - if w.Kind != "" && w.Kind != "Workload" { - return nil, &FieldError{ - Field: "kind", - Message: fmt.Sprintf("these tools create Workloads, not %s", w.Kind), - } - } - if w.Name == "" { - return nil, &FieldError{Field: "metadata.name", Message: "a workload name is required"} - } - - w.TypeMeta = metav1.TypeMeta{ - APIVersion: computev1alpha.GroupVersion.String(), - Kind: "Workload", - } - // The project decides the namespace, never the manifest: a manifest that - // named another one would be asking to write somewhere this request does - // not reach. - w.Namespace = namespace - w.ResourceVersion = "" - w.UID = "" - w.Generation = 0 - w.CreationTimestamp = metav1.Time{} - w.ManagedFields = nil - w.Status = computev1alpha.WorkloadStatus{} - - return &w, nil -} - -// canonicalJSON renders the manifest the token is computed over. The workload -// is normalized first, and Go marshals struct fields in declaration order and -// map keys in sorted order, so the same manifest always produces the same -// bytes. -func canonicalJSON(w *computev1alpha.Workload) ([]byte, error) { - raw, err := json.Marshal(w) - if err != nil { - return nil, fmt.Errorf("normalizing the manifest: %w", err) - } - return raw, nil -} - -// fieldErrors turns a server rejection into field/message pairs. A structured -// rejection carries a cause per field; the whole message is returned alongside -// them, because it is the server's own wording and is what a person quotes -// when escalating. -func fieldErrors(err error) []FieldError { - var status *apierrors.StatusError - if !errors.As(err, &status) { - return []FieldError{{Message: err.Error()}} - } - - out := []FieldError{} - if details := status.ErrStatus.Details; details != nil { - for _, cause := range details.Causes { - out = append(out, FieldError{Field: cause.Field, Message: cause.Message}) - } - } - return append(out, FieldError{Message: status.ErrStatus.Message}) -} - -// writer returns the Writer for this call, or an error naming the wiring -// mistake, the same way discovery's does. -func (d ToolDeps) writer() (Writer, error) { - if d.Writer == nil { - return nil, fmt.Errorf( - "this server was built without the ability to create or change a workload, so this tool " + - "cannot answer. The person who asked did nothing wrong: whoever operates this server " + - "needs to configure it") - } - return d.Writer, nil -} - -// planTokenKey returns the key plan tokens are minted and checked with, or an -// error. Both halves are refused without one: a server that cannot check a -// token must not issue something that looks like one. -func (d ToolDeps) planTokenKey() ([]byte, error) { - if len(d.PlanTokenKey) == 0 { - return nil, fmt.Errorf( - "this server was built without a plan token key, so it cannot authorize creating or " + - "changing a workload. The person who asked did nothing wrong: whoever operates this " + - "server needs to configure it") - } - if d.Project == "" { - return nil, fmt.Errorf( - "this request did not say which project it is for, so a plan cannot be bound to one. The " + - "person who asked did nothing wrong: whoever operates the client that called this tool " + - "needs to configure it") - } - return d.PlanTokenKey, nil -} - -// ------------------------------------------------------------- plan tokens - -// mintPlanToken returns the token that authorizes applying exactly this -// manifest, in this project, against this version of the workload, until -// expiry. The form is base64(HMAC-SHA256(payload)) + "." + expiry in seconds: -// the expiry travels in the clear because it is also covered by the hash, so -// moving it invalidates the token. -func mintPlanToken(key, canonical []byte, project, resourceVersion string, expiry time.Time) string { - unix := expiry.Unix() - mac := planTokenMAC(key, canonical, project, resourceVersion, unix) - return base64.RawURLEncoding.EncodeToString(mac) + "." + strconv.FormatInt(unix, 10) -} - -// verifyPlanToken refuses anything that is not a token minted for exactly this -// manifest, project and workload version, and still inside its window. The -// refusals are what a person reads, so each one says what happened, that -// nothing was created, and what to do instead. -func verifyPlanToken( - key []byte, token string, canonical []byte, project, resourceVersion string, now time.Time, -) error { - encoded, expiryText, found := strings.Cut(token, ".") - if !found { - return fmt.Errorf( - "this is not a plan token in the form %s issues, so nothing was created. Call %s with the "+ - "manifest to apply and use the token it returns", - ToolWorkloadPlan, ToolWorkloadPlan) - } - unix, err := strconv.ParseInt(expiryText, 10, 64) - if err != nil { - return fmt.Errorf( - "this plan token does not carry a readable expiry, so nothing was created. Call %s again "+ - "and use the token it returns", ToolWorkloadPlan) - } - if expiry := time.Unix(unix, 0); now.After(expiry) { - return fmt.Errorf( - "this plan expired at %s and nothing was created. A plan is good for %s, so that what is "+ - "created is still what was agreed to. Call %s again, show the person who asked the "+ - "manifest and the diff it returns, and ask again before applying", - expiry.UTC().Format(time.RFC3339), planTokenTTL, ToolWorkloadPlan) - } - - presented, err := base64.RawURLEncoding.DecodeString(encoded) - if err != nil { - presented = nil - } - if !hmac.Equal(presented, planTokenMAC(key, canonical, project, resourceVersion, unix)) { - return fmt.Errorf( - "this plan token does not cover the manifest it was given, so nothing was created. That "+ - "happens when the manifest changed after the plan was made — one character is enough — "+ - "when the plan was made for a different project, or when the workload was changed by "+ - "someone else in the meantime. This refusal is the check working. Call %s again with "+ - "the manifest to apply, show the person who asked the manifest and the diff it returns, "+ - "and ask again", ToolWorkloadPlan) - } - return nil -} - -// planTokenMAC hashes the manifest together with everything the plan assumed, -// each part separated by a newline so no two payloads can be built out of the -// same bytes. -func planTokenMAC(key, canonical []byte, project, resourceVersion string, expiryUnix int64) []byte { - var payload bytes.Buffer - payload.Write(canonical) - payload.WriteByte('\n') - payload.WriteString(project) - payload.WriteByte('\n') - payload.WriteString(resourceVersion) - payload.WriteByte('\n') - payload.WriteString(strconv.FormatInt(expiryUnix, 10)) - - mac := hmac.New(sha256.New, key) - mac.Write(payload.Bytes()) - return mac.Sum(nil) -} - -// ---------------------------------------------------------------- rendering - -// toSpecInput converts the tool's input to workloadspec's. A straight mapping, -// kept explicit so the tool schema can be worded for a model without that -// wording leaking into the package the CLI also renders through. -func toSpecInput(in WorkloadRenderInput) workloadspec.Input { - out := workloadspec.Input{ - Name: in.Name, - Image: in.Image, - InstanceType: in.InstanceType, - RuntimeClass: in.RuntimeClass, - Network: in.Network, - PublicIPv4: in.PublicIPv4, - Labels: in.Labels, - } - - for _, p := range in.Placements { - out.Placements = append(out.Placements, workloadspec.Placement{ - Name: p.Name, - Locations: p.Locations, - LocationSelector: toLabelSelector(p.LocationSelector), - MinReplicas: p.MinReplicas, - }) - } - for _, p := range in.Ports { - out.Ports = append(out.Ports, workloadspec.Port{ - Name: p.Name, - Port: p.Port, - Protocol: corev1.Protocol(p.Protocol), - }) - } - for _, e := range in.Env { - out.Env = append(out.Env, workloadspec.EnvVar{ - Name: e.Name, - Value: e.Value, - ConfigMapKeyRef: toKeyRef(e.ConfigMapKeyRef), - SecretKeyRef: toKeyRef(e.SecretKeyRef), - }) - } - for _, m := range in.ConfigMounts { - out.ConfigMounts = append(out.ConfigMounts, workloadspec.Mount{ - Name: m.Name, - ConfigMap: m.ConfigMap, - Secret: m.Secret, - MountPath: m.MountPath, - }) - } - if in.VM != nil { - out.VM = &workloadspec.VMInput{ - SSHKeys: in.VM.SSHKeys, - BootImage: in.VM.BootImage, - } - } - - return out -} - -// toLabelSelector converts the tool's selector to the API's. The operator is -// passed through verbatim: an unrecognized one is refused by the render's own -// validation with the field path, which is more useful than silently dropping -// the requirement here. -func toLabelSelector(sel *RenderLocationSelector) *metav1.LabelSelector { - if sel == nil { - return nil - } - out := &metav1.LabelSelector{MatchLabels: sel.MatchLabels} - for _, req := range sel.MatchExpressions { - out.MatchExpressions = append(out.MatchExpressions, metav1.LabelSelectorRequirement{ - Key: req.Key, - Operator: metav1.LabelSelectorOperator(req.Operator), - Values: req.Values, - }) - } - return out -} - -func toKeyRef(ref *RenderKeyRef) *workloadspec.KeyRef { - if ref == nil { - return nil - } - return &workloadspec.KeyRef{Name: ref.Name, Key: ref.Key} -} - -// renderNotes says what this manifest settled that a later render cannot -// correct, and which values were filled in for a caller who did not name them. -// -// It is written from the input as given, before defaults are applied, so -// "defaulted to" means the person did not choose it — which is the thing they -// need to be asked about while the workload can still be changed. -func renderNotes(in workloadspec.Input) []string { - notes := []string{ - "The instance's single network interface is settled by this manifest and cannot be changed " + - "once the workload exists: its name, the address families it carries, any extra addresses, " + - "and what becomes of those addresses when an instance goes away. Getting one of them wrong " + - "means creating a new workload, not editing this one.", - } - - if in.PublicIPv4 { - notes = append(notes, "A public IPv4 address was asked for, so the interface carries both IPv4 "+ - "and IPv6. Neither the address nor the families can be removed later.") - } else { - notes = append(notes, "The interface carries IPv6 only, which is the default. If this workload "+ - "has to answer on IPv4, say so before it is applied: IPv4 cannot be added afterwards.") - } - - notes = append(notes, "Addresses are given back when an instance goes away. Keeping one — an "+ - "address published in DNS, or allowed through someone's firewall — means editing this manifest "+ - "before the first apply.") - - if in.InstanceType == "" { - notes = append(notes, fmt.Sprintf( - "No instance type was given, so every instance is %s. Per-container CPU and memory are not "+ - "accepted: the instance type is what decides the size.", workloadspec.DefaultInstanceType)) - } - if in.Network == "" { - notes = append(notes, fmt.Sprintf( - "No network was named, so the interface attaches to %q. If the project does not have "+ - "one, %s says so and %s creates it alongside the workload.", - workloadspec.DefaultNetwork, ToolWorkloadPlan, ToolWorkloadApply)) - } - for _, p := range in.Placements { - if p.Name == "" { - notes = append(notes, fmt.Sprintf("A placement was not named, so it is called %q.", - workloadspec.DefaultPlacementName)) - } - if p.MinReplicas == 0 { - notes = append(notes, fmt.Sprintf( - "Placement %q did not say how many instances to run, so it runs %d. There is no "+ - "scaling to zero.", placementName(p), workloadspec.DefaultMinReplicas)) - } - if p.LocationSelector != nil { - notes = append(notes, fmt.Sprintf( - "Placement %q selects its locations by topology rather than naming them, so it runs "+ - "wherever the selector matches — including locations added later, which will "+ - "start instances without this manifest changing. %s shows which locations match "+ - "today.", placementName(p), ToolLocationsList)) - } - } - if in.VM != nil && in.VM.BootImage == "" { - notes = append(notes, fmt.Sprintf( - "No boot image was given, so the machine boots %s, currently the only one accepted.", - workloadspec.DefaultBootImage)) - } - - return notes -} - -func placementName(p workloadspec.Placement) string { - if p.Name == "" { - return workloadspec.DefaultPlacementName - } - return p.Name -} diff --git a/internal/agent/write_test.go b/internal/agent/write_test.go deleted file mode 100644 index 596fcbda..00000000 --- a/internal/agent/write_test.go +++ /dev/null @@ -1,1004 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -package agent - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - - computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/locations" - "go.datum.net/compute/internal/workloadspec" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" -) - -const ( - // testProject is the project a plan is bound to. Plan tokens are only good - // in the project they were minted for, so the tests need two of them. - testProject = "acme-prod" - otherProject = "acme-staging" - - testImage = "ghcr.io/acme/api:1.4.2" - // deployedImage is what the fixture workload is already running, so an - // update has something to change. - deployedImage = "ghcr.io/acme/api:1.0.0" -) - -// testPlanKey stands in for PLAN_TOKEN_KEY. Long enough to be a real key, and -// obviously not one that was ever deployed. -var testPlanKey = []byte("test-plan-token-key-32-bytes-long!!") - -// fakeWriter records what would have reached the server, and can be told to -// reject a dry run the way the server itself would. The recording is the point: -// the strongest thing these tests assert is that nothing was written. -type fakeWriter struct { - // dryRunErr, when set, is what both dry-run checks return. - dryRunErr error - // writeErr, when set, is what Create and Update return. - writeErr error - // networks that already exist, by name. - networks map[string]bool - // networkGetErr, when set, fails the network read with something other - // than a not-found. - networkGetErr error - - dryRuns int - created []computev1alpha.Workload - updated []computev1alpha.Workload - createdNetworks []networkingv1alpha.Network -} - -var _ Writer = (*fakeWriter)(nil) - -func (w *fakeWriter) DryRunCreate(_ context.Context, _ *computev1alpha.Workload) error { - w.dryRuns++ - return w.dryRunErr -} - -func (w *fakeWriter) DryRunUpdate(_ context.Context, _ *computev1alpha.Workload) error { - w.dryRuns++ - return w.dryRunErr -} - -func (w *fakeWriter) Create(_ context.Context, workload *computev1alpha.Workload) error { - if w.writeErr != nil { - return w.writeErr - } - w.created = append(w.created, *workload.DeepCopy()) - return nil -} - -func (w *fakeWriter) Update(_ context.Context, workload *computev1alpha.Workload) error { - if w.writeErr != nil { - return w.writeErr - } - w.updated = append(w.updated, *workload.DeepCopy()) - return nil -} - -func (w *fakeWriter) GetNetwork( - _ context.Context, namespace, name string, -) (*networkingv1alpha.Network, error) { - if w.networkGetErr != nil { - return nil, w.networkGetErr - } - if !w.networks[name] { - return nil, apierrors.NewNotFound( - schema.GroupResource{Group: networkingv1alpha.GroupVersion.Group, Resource: "networks"}, name) - } - return &networkingv1alpha.Network{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, - }, nil -} - -func (w *fakeWriter) CreateNetwork(_ context.Context, n *networkingv1alpha.Network) error { - if w.networks == nil { - w.networks = map[string]bool{} - } - w.networks[n.Name] = true - w.createdNetworks = append(w.createdNetworks, *n.DeepCopy()) - return nil -} - -// wrote reports whether anything at all reached the server. -func (w *fakeWriter) wrote() bool { - return len(w.created) > 0 || len(w.updated) > 0 || len(w.createdNetworks) > 0 -} - -// writeReader answers "is this workload already there?" the way the API server -// does — a missing one is a not-found, not an error — because that distinction -// is what decides create versus update. -type writeReader struct { - workloads map[string]*computev1alpha.Workload - err error -} - -var _ Reader = (*writeReader)(nil) - -func (r *writeReader) ListWorkloads(context.Context, string) ([]computev1alpha.Workload, error) { - return nil, r.err -} - -func (r *writeReader) GetWorkload(_ context.Context, _, name string) (*computev1alpha.Workload, error) { - if r.err != nil { - return nil, r.err - } - if w, ok := r.workloads[name]; ok { - return w.DeepCopy(), nil - } - return nil, apierrors.NewNotFound( - schema.GroupResource{Group: computev1alpha.GroupVersion.Group, Resource: "workloads"}, name) -} - -func (r *writeReader) ListDeployments( - context.Context, string, string, -) ([]computev1alpha.WorkloadDeployment, error) { - return nil, r.err -} - -func (r *writeReader) ListInstances(context.Context, string, string) ([]computev1alpha.Instance, error) { - return nil, r.err -} - -// existingWorkload is the fixture workload as it is already deployed, at a -// known resource version, so a plan can be made against it and then -// invalidated by moving it. -func existingWorkload(image, resourceVersion string) *computev1alpha.Workload { - w, err := workloadspec.Render(workloadspec.Input{ - Name: wlAPIBackend, - Image: image, - Placements: []workloadspec.Placement{ - {Locations: []string{locationDFW}, MinReplicas: 1}, - }, - }) - if err != nil { - panic(err) - } - w.ResourceVersion = resourceVersion - return w -} - -// writeDeps supplies everything the write path needs, in testProject. -func writeDeps(r Reader, w Writer) DepsFor { - return writeDepsFor(testProject, r, w) -} - -func writeDepsFor(project string, r Reader, w Writer) DepsFor { - return func(context.Context) (ToolDeps, error) { - return ToolDeps{ - Reader: r, - Writer: w, - Namespace: testNamespace, - Project: project, - PlanTokenKey: testPlanKey, - }, nil - } -} - -// renderInput is the everyday case: one container, one location, one port. -func renderInput() WorkloadRenderInput { - return WorkloadRenderInput{ - Name: wlAPIBackend, - Image: testImage, - Placements: []RenderPlacement{{Locations: []string{locationDFW}, MinReplicas: 2}}, - Ports: []RenderPort{{Name: "http", Port: 8080}}, - } -} - -func mustRender(t *testing.T, deps DepsFor, in WorkloadRenderInput) string { - t.Helper() - _, out, err := workloadRender(deps)(context.Background(), nil, in) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - return out.Manifest -} - -func mustPlan(t *testing.T, deps DepsFor, manifest string) WorkloadPlanOutput { - t.Helper() - _, out, err := workloadPlan(deps)(context.Background(), nil, WorkloadPlanInput{Manifest: manifest}) - if err != nil { - t.Fatalf("compute_workload_plan: %v", err) - } - if !out.Valid { - t.Fatalf("compute_workload_plan rejected the manifest: %+v", out.Errors) - } - return out -} - -// rejection is a server rejection shaped the way the API server sends one: a -// message, and a cause naming the field. Parsing it back into field/message -// pairs is what lets the model quote the field path. -func rejection(field, message string) error { - return &apierrors.StatusError{ErrStatus: metav1.Status{ - Status: metav1.StatusFailure, - Code: 422, - Reason: metav1.StatusReasonInvalid, - Message: "Workload.compute.datumapis.com \"api-backend\" is invalid: " + field + ": " + message, - Details: &metav1.StatusDetails{ - Causes: []metav1.StatusCause{{ - Type: metav1.CauseTypeFieldValueInvalid, - Field: field, - Message: message, - }}, - }, - }} -} - -// ------------------------------------------------------------------ render - -// TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled: the manifest is only -// half the answer. The notes carry the decisions that cannot be corrected by a -// later render, and a model that does not read them out lets a person agree to -// something they would have to recreate the workload to change. -func TestWorkloadRenderProducesAManifestAndSaysWhatIsSettled(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - - _, out, err := workloadRender(deps)(context.Background(), nil, renderInput()) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - - for _, want := range []string{ - "kind: Workload", - "name: " + wlAPIBackend, - testImage, - "minReplicas: 2", - "- name: " + locationDFW, - } { - if !strings.Contains(out.Manifest, want) { - t.Errorf("manifest is missing %q:\n%s", want, out.Manifest) - } - } - // Rendering reaches nothing, so the manifest has to be readable straight - // back into the workload the plan token would be computed over. - if _, ferr := decodeManifest(out.Manifest, testNamespace); ferr != nil { - t.Errorf("the rendered manifest does not read back: %+v", ferr) - } - - notes := strings.Join(out.Notes, "\n") - // The interface is settled at create, and the instance type and network - // were defaulted rather than chosen — both are things to say out loud - // while the workload can still be changed. - for _, want := range []string{ - "cannot be changed once the workload exists", - "IPv6 only", - workloadspec.DefaultInstanceType, - "\"" + workloadspec.DefaultNetwork + "\"", - } { - if !strings.Contains(notes, want) { - t.Errorf("notes do not mention %q:\n%s", want, notes) - } - } -} - -// TestWorkloadRenderReportsAPublicAddressAsFinal: asking for IPv4 fixes the -// address families for the life of the workload, so the note has to change -// with the input rather than always saying the same thing. -// TestWorkloadRenderSelectsLocationsByTopology covers the second way a -// placement says where: a selector over location topology rather than a list of -// names. It is the only way to say "every location in this city", and it keeps -// matching locations added later — which is a standing behaviour the person -// agreeing to the manifest has to be told about, so the notes carry it. -func TestWorkloadRenderSelectsLocationsByTopology(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - in := renderInput() - in.Placements = []RenderPlacement{{ - LocationSelector: &RenderLocationSelector{ - MatchLabels: map[string]string{locations.TopologyCityCodeKey: cityDFW}, - }, - MinReplicas: 2, - }} - - _, out, err := workloadRender(deps)(context.Background(), nil, in) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - for _, want := range []string{"locationSelector:", locations.TopologyCityCodeKey + ": " + cityDFW} { - if !strings.Contains(out.Manifest, want) { - t.Errorf("manifest is missing %q:\n%s", want, out.Manifest) - } - } - if strings.Contains(out.Manifest, "locations:") { - t.Errorf("a selector was given, so no location list may be emitted:\n%s", out.Manifest) - } - if !strings.Contains(strings.Join(out.Notes, "\n"), "locations added later") { - t.Errorf("notes do not say the selector keeps matching new locations:\n%s", out.Notes) - } -} - -// TestWorkloadRenderPassesTheRuntimeClassThrough: the tier is the server's -// catalog to own. Whatever the person named goes through verbatim, and naming -// nothing leaves the field off so the server picks its own default rather than -// this tool settling a choice that cannot be changed afterwards. -func TestWorkloadRenderPassesTheRuntimeClassThrough(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - - _, bare, err := workloadRender(deps)(context.Background(), nil, renderInput()) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - if strings.Contains(bare.Manifest, "class:") { - t.Errorf("no runtime class was asked for, so none may be rendered:\n%s", bare.Manifest) - } - - in := renderInput() - in.RuntimeClass = "datum-sandbox" - _, out, err := workloadRender(deps)(context.Background(), nil, in) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - if !strings.Contains(out.Manifest, "class: datum-sandbox") { - t.Errorf("manifest does not carry the runtime class that was asked for:\n%s", out.Manifest) - } -} - -func TestWorkloadRenderReportsAPublicAddressAsFinal(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - in := renderInput() - in.PublicIPv4 = true - - _, out, err := workloadRender(deps)(context.Background(), nil, in) - if err != nil { - t.Fatalf("compute_workload_render: %v", err) - } - notes := strings.Join(out.Notes, "\n") - if !strings.Contains(notes, "public IPv4 address was asked for") { - t.Errorf("notes do not report the public address as settled:\n%s", notes) - } - if strings.Contains(notes, "IPv6 only") { - t.Errorf("notes still claim IPv6 only after IPv4 was asked for:\n%s", notes) - } -} - -// TestWorkloadRenderRefusesAnIncompleteInput: a missing image is the caller's -// to supply, and rendering something plausible around a name nobody pushed is -// the failure this prevents. -func TestWorkloadRenderRefusesAnIncompleteInput(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - in := renderInput() - in.Image = "" - - if _, _, err := workloadRender(deps)(context.Background(), nil, in); err == nil { - t.Error("compute_workload_render accepted an input with no image") - } -} - -// ---------------------------------------------------------------- validate - -// TestWorkloadValidateReportsARejectionAsFieldErrors: the server's answer is -// the whole value of this tool, so the field path it named has to survive as a -// field path rather than being flattened into prose. -func TestWorkloadValidateReportsARejectionAsFieldErrors(t *testing.T) { - const field = "spec.template.spec.volumes[1].name" - writer := &fakeWriter{dryRunErr: rejection(field, "volume must be attached at least 1 time")} - deps := writeDeps(&writeReader{}, writer) - manifest := mustRender(t, deps, renderInput()) - - _, out, err := workloadValidate(deps)(context.Background(), nil, - WorkloadValidateInput{Manifest: manifest}) - if err != nil { - t.Fatalf("compute_workload_validate: %v", err) - } - - if out.Valid { - t.Fatal("valid = true after the server rejected the manifest") - } - if out.Exists { - t.Error("exists = true for a workload that is not there") - } - fields := make([]string, 0, len(out.Errors)) - messages := make([]string, 0, len(out.Errors)) - for _, e := range out.Errors { - fields = append(fields, e.Field) - messages = append(messages, e.Message) - } - if !contains(fields, field) { - t.Errorf("errors do not name the field the server named: %+v", out.Errors) - } - // The whole message travels too: it is what a person quotes when the field - // path alone does not tell them what to change. - if !strings.Contains(strings.Join(messages, "\n"), "is invalid") { - t.Errorf("errors dropped the server's own message: %+v", out.Errors) - } - if writer.wrote() { - t.Error("compute_workload_validate wrote something") - } -} - -// TestWorkloadValidateReportsAnExistingWorkloadAsADiff: validate is where the -// model learns it is about to change something rather than create it, and the -// diff is what the person has to be shown. -func TestWorkloadValidateReportsAnExistingWorkloadAsADiff(t *testing.T) { - reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ - wlAPIBackend: existingWorkload(deployedImage, "7"), - }} - writer := &fakeWriter{} - deps := writeDeps(reader, writer) - manifest := mustRender(t, deps, renderInput()) - - _, out, err := workloadValidate(deps)(context.Background(), nil, - WorkloadValidateInput{Manifest: manifest}) - if err != nil { - t.Fatalf("compute_workload_validate: %v", err) - } - - if !out.Valid || !out.Exists { - t.Fatalf("valid = %v, exists = %v; want both true", out.Valid, out.Exists) - } - diff := strings.Join(out.Diff, "\n") - if !strings.Contains(diff, testImage) { - t.Errorf("diff does not report the image change: %q", diff) - } - if !strings.Contains(diff, "min replicas: 1 → 2") { - t.Errorf("diff does not report the replica change: %q", diff) - } - if writer.wrote() { - t.Error("compute_workload_validate wrote something") - } -} - -// TestWorkloadValidateRejectsAnUnreadableManifest: a manifest the model -// invented has to come back as something it can fix, not as a tool failure. -func TestWorkloadValidateRejectsAnUnreadableManifest(t *testing.T) { - deps := writeDeps(&writeReader{}, &fakeWriter{}) - - for name, manifest := range map[string]string{ - "empty": "", - "not yaml": "this is not: a: manifest:", - "no name": "apiVersion: compute.datumapis.com/v1alpha\nkind: Workload\nspec: {}\n", - "another kind": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config\n", - "typo": "kind: Workload\nmetadata:\n name: api\nspce: {}\n", - } { - t.Run(name, func(t *testing.T) { - _, out, err := workloadValidate(deps)(context.Background(), nil, - WorkloadValidateInput{Manifest: manifest}) - if err != nil { - t.Fatalf("compute_workload_validate: %v", err) - } - if out.Valid { - t.Errorf("valid = true for %s", name) - } - if len(out.Errors) == 0 { - t.Error("no errors reported, so there is nothing to fix") - } - }) - } -} - -// -------------------------------------------------------------------- plan - -// TestWorkloadPlanMintsATokenAndReportsAMissingNetwork: the network is a second -// object the apply would create, and a person agreeing to a workload has not -// agreed to that unless the plan says so. -func TestWorkloadPlanMintsATokenAndReportsAMissingNetwork(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - manifest := mustRender(t, deps, renderInput()) - - out := mustPlan(t, deps, manifest) - - if out.Action != actionCreate { - t.Errorf("action = %q, want %q", out.Action, actionCreate) - } - if out.Network == nil { - t.Fatal("plan did not report on the network") - } - if out.Network.Name != workloadspec.DefaultNetwork || out.Network.Exists || !out.Network.WillCreate { - t.Errorf("network = %+v, want the default network reported as missing and to be created", *out.Network) - } - if out.PlanToken == "" { - t.Fatal("plan minted no token") - } - // The manifest in the output is the one the token covers, which is why the - // description tells the model to show that one and not its own draft. - if _, ferr := decodeManifest(out.Manifest, testNamespace); ferr != nil { - t.Errorf("the planned manifest does not read back: %+v", ferr) - } - expires, err := time.Parse(time.RFC3339, out.ExpiresAt) - if err != nil { - t.Fatalf("expiresAt = %q, want RFC 3339: %v", out.ExpiresAt, err) - } - if until := time.Until(expires); until <= 0 || until > planTokenTTL { - t.Errorf("token expires in %s, want inside %s", until, planTokenTTL) - } - if writer.wrote() { - t.Error("compute_workload_plan wrote something") - } -} - -// TestWorkloadPlanReportsANetworkThatIsAlreadyThere is the other half: nothing -// extra is created, and the plan must not say it would be. -func TestWorkloadPlanReportsANetworkThatIsAlreadyThere(t *testing.T) { - writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} - deps := writeDeps(&writeReader{}, writer) - - out := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - if out.Network == nil || !out.Network.Exists || out.Network.WillCreate { - t.Errorf("network = %+v, want it reported as already there", out.Network) - } -} - -// TestWorkloadPlanMintsNoTokenForARejectedManifest: a token is authority to -// write. A manifest the server would refuse must never carry one, or a later -// apply spends it on a rejection. -func TestWorkloadPlanMintsNoTokenForARejectedManifest(t *testing.T) { - writer := &fakeWriter{dryRunErr: rejection("spec.template.spec.runtime.resources.instanceType", - "Unsupported value: \"datumcloud/d1-huge-64\"")} - deps := writeDeps(&writeReader{}, writer) - manifest := mustRender(t, deps, renderInput()) - - _, out, err := workloadPlan(deps)(context.Background(), nil, WorkloadPlanInput{Manifest: manifest}) - if err != nil { - t.Fatalf("compute_workload_plan: %v", err) - } - if out.Valid { - t.Fatal("valid = true after the server rejected the manifest") - } - if out.PlanToken != "" { - t.Error("plan minted a token for a manifest the server rejected") - } - if len(out.Errors) == 0 { - t.Error("no errors reported, so there is nothing to fix") - } -} - -// TestWorkloadPlanResolvesAnUpdate: the same manifest is a create or an update -// depending only on what is already there, and the model has to be told which. -func TestWorkloadPlanResolvesAnUpdate(t *testing.T) { - reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ - wlAPIBackend: existingWorkload(deployedImage, "7"), - }} - deps := writeDeps(reader, &fakeWriter{}) - - out := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - if out.Action != actionUpdate { - t.Errorf("action = %q, want %q", out.Action, actionUpdate) - } - if len(out.Diff) == 0 { - t.Error("an update was planned with no diff to show") - } -} - -// ------------------------------------------------------------------- apply - -// TestWorkloadApplyCreatesWhatWasPlanned covers the whole point of the split: -// the manifest that was planned, and only that one, reaches the server — along -// with the network the plan said would have to be created with it. -func TestWorkloadApplyCreatesWhatWasPlanned(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err != nil { - t.Fatalf("compute_workload_apply: %v", err) - } - - if out.Action != actionCreate || out.Workload != wlAPIBackend { - t.Errorf("apply reported %q of %q, want a create of %q", out.Action, out.Workload, wlAPIBackend) - } - if len(writer.created) != 1 { - t.Fatalf("created %d workloads, want exactly 1", len(writer.created)) - } - created := writer.created[0] - if created.Namespace != testNamespace { - t.Errorf("created in namespace %q, want the project's %q", created.Namespace, testNamespace) - } - if got := created.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image; got != testImage { - t.Errorf("created image = %q, want the planned %q", got, testImage) - } - // The plan said the network would be created, so it was. - if len(writer.createdNetworks) != 1 || writer.createdNetworks[0].Name != workloadspec.DefaultNetwork { - t.Fatalf("created networks = %+v, want the default network", writer.createdNetworks) - } - if got := writer.createdNetworks[0].Spec.IPAM.Mode; got != networkingv1alpha.NetworkIPAMModeAuto { - t.Errorf("network IPAM mode = %q, want %q", got, networkingv1alpha.NetworkIPAMModeAuto) - } - if !out.Network.Created { - t.Error("apply did not report that the network was created; it is a second object") - } - // A created workload is not a running one, and the next step has to say so. - if !strings.Contains(out.Next, ToolWorkloadDiagnose) { - t.Errorf("next = %q, want it to name %s", out.Next, ToolWorkloadDiagnose) - } -} - -// TestWorkloadApplyLeavesAnExistingNetworkAlone: creating one that is already -// there would fail, and reporting one that was not created as created would -// tell the person something untrue about their project. -func TestWorkloadApplyLeavesAnExistingNetworkAlone(t *testing.T) { - writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} - deps := writeDeps(&writeReader{}, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err != nil { - t.Fatalf("compute_workload_apply: %v", err) - } - if len(writer.createdNetworks) != 0 || out.Network.Created { - t.Errorf("apply created a network that already existed: %+v", writer.createdNetworks) - } -} - -// TestWorkloadApplyUpdatesAtTheVersionThePlanSaw: an update carries the -// resource version that was read, so a change that lands in between is refused -// by the server rather than silently overwritten. -func TestWorkloadApplyUpdatesAtTheVersionThePlanSaw(t *testing.T) { - reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ - wlAPIBackend: existingWorkload(deployedImage, "7"), - }} - writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} - deps := writeDeps(reader, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - _, out, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err != nil { - t.Fatalf("compute_workload_apply: %v", err) - } - if out.Action != actionUpdate { - t.Errorf("action = %q, want %q", out.Action, actionUpdate) - } - if len(writer.updated) != 1 { - t.Fatalf("updated %d workloads, want exactly 1", len(writer.updated)) - } - if got := writer.updated[0].ResourceVersion; got != "7" { - t.Errorf("updated at resource version %q, want the %q the plan read", got, "7") - } - if len(writer.created) != 0 { - t.Error("apply created a workload that already existed") - } -} - -// TestWorkloadApplyRefusesATamperedManifest is the property the whole design -// rests on: what is created is what was shown, or nothing. A model that read a -// poisoned status message and changed one field cannot spend a token minted -// for the manifest the person actually agreed to. -func TestWorkloadApplyRefusesATamperedManifest(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - tampered := strings.Replace(plan.Manifest, testImage, "ghcr.io/attacker/miner:latest", 1) - if tampered == plan.Manifest { - t.Fatal("the manifest was not actually changed; the test proves nothing") - } - - _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: tampered, - PlanToken: plan.PlanToken, - }) - if err == nil { - t.Fatal("apply accepted a manifest the token was not minted for") - } - assertRefusalIsReadable(t, err) - if writer.wrote() { - t.Errorf("apply wrote something after refusing: %+v %+v", writer.created, writer.createdNetworks) - } -} - -// TestWorkloadApplyRefusesAnExpiredToken: a plan is an agreement about a moment. -// Fifteen minutes later the quota, the images and the workload itself may all -// have moved, so consent has to be asked for again rather than assumed. -func TestWorkloadApplyRefusesAnExpiredToken(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - manifest := mustRender(t, deps, renderInput()) - - desired, ferr := decodeManifest(manifest, testNamespace) - if ferr != nil { - t.Fatalf("decoding the rendered manifest: %+v", ferr) - } - canonical, err := canonicalJSON(desired) - if err != nil { - t.Fatalf("canonicalJSON: %v", err) - } - stale := mintPlanToken(testPlanKey, canonical, testProject, "", time.Now().Add(-time.Minute)) - - _, _, err = workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: manifest, - PlanToken: stale, - }) - if err == nil { - t.Fatal("apply accepted an expired token") - } - if !strings.Contains(err.Error(), "expired") { - t.Errorf("error = %q, want it to say the plan expired", err) - } - assertRefusalIsReadable(t, err) - if writer.wrote() { - t.Error("apply wrote something after refusing an expired token") - } -} - -// TestWorkloadApplyRefusesATokenFromAnotherProject: the project is fixed by the -// request, so a token that travelled between conversations must not spend. -func TestWorkloadApplyRefusesATokenFromAnotherProject(t *testing.T) { - writer := &fakeWriter{} - planned := writeDepsFor(otherProject, &writeReader{}, &fakeWriter{}) - manifest := mustRender(t, planned, renderInput()) - plan := mustPlan(t, planned, manifest) - - applying := writeDepsFor(testProject, &writeReader{}, writer) - _, _, err := workloadApply(applying)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err == nil { - t.Fatal("apply accepted a token minted for another project") - } - assertRefusalIsReadable(t, err) - if writer.wrote() { - t.Error("apply wrote something after refusing a token from another project") - } -} - -// TestWorkloadApplyRefusesAWorkloadThatMovedSinceThePlan: someone else changed -// it in between, so the diff the person was shown is no longer the change that -// would be made. Re-plan and ask again. -func TestWorkloadApplyRefusesAWorkloadThatMovedSinceThePlan(t *testing.T) { - reader := &writeReader{workloads: map[string]*computev1alpha.Workload{ - wlAPIBackend: existingWorkload(deployedImage, "7"), - }} - writer := &fakeWriter{networks: map[string]bool{workloadspec.DefaultNetwork: true}} - deps := writeDeps(reader, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - // Someone else edits the workload between the plan and the apply. - reader.workloads[wlAPIBackend] = existingWorkload("ghcr.io/acme/api:1.2.0", "8") - - _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err == nil { - t.Fatal("apply accepted a plan made against an older version of the workload") - } - assertRefusalIsReadable(t, err) - if writer.wrote() { - t.Error("apply wrote something after the workload moved underneath the plan") - } -} - -// TestWorkloadApplyWritesNothingWhenTheServerRejectsIt: the plan may be minutes -// old and quota, references and the catalogs all move. The check runs again, -// and a rejection stops the apply before the network is created too — a -// half-applied plan is worse than a refused one. -func TestWorkloadApplyWritesNothingWhenTheServerRejectsIt(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - plan := mustPlan(t, deps, mustRender(t, deps, renderInput())) - - // Accepted at plan time, refused now. - writer.dryRunErr = rejection("spec.template.spec.volumes[0].name", - "volume must be attached at least 1 time") - - _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: plan.Manifest, - PlanToken: plan.PlanToken, - }) - if err == nil { - t.Fatal("apply proceeded after the server rejected the workload") - } - if writer.wrote() { - t.Errorf("apply wrote something after a rejected check: workloads %+v, networks %+v", - writer.created, writer.createdNetworks) - } -} - -// TestWorkloadApplyRefusesAMalformedToken: a token the model made up, or one -// truncated in transit, is refused the same way — and the message says which -// tool issues real ones. -func TestWorkloadApplyRefusesAMalformedToken(t *testing.T) { - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - manifest := mustRender(t, deps, renderInput()) - - for name, token := range map[string]string{ - "empty": "", - "no expiry": "bm90LWEtdG9rZW4", - "bad expiry": "bm90LWEtdG9rZW4.soon", - "not base64": "!!!!.99999999999", - "wrong length": "AAAA.99999999999", - } { - t.Run(name, func(t *testing.T) { - _, _, err := workloadApply(deps)(context.Background(), nil, WorkloadApplyInput{ - Manifest: manifest, - PlanToken: token, - }) - if err == nil { - t.Fatalf("apply accepted a %s token", name) - } - assertRefusalIsReadable(t, err) - }) - } - if writer.wrote() { - t.Error("apply wrote something while refusing made-up tokens") - } -} - -// ------------------------------------------------------------ wiring, wire - -// TestWriteToolsFailWhenDepsAreUnavailable: an unauthenticated request must -// fail every write tool, render included. Rendering reads nothing, but it must -// not be a probe an unauthenticated caller can use either. -func TestWriteToolsFailWhenDepsAreUnavailable(t *testing.T) { - denied := func(context.Context) (ToolDeps, error) { - return ToolDeps{}, errors.New("no bearer token on the request") - } - ctx := context.Background() - - if _, _, err := workloadRender(denied)(ctx, nil, renderInput()); err == nil { - t.Error("compute_workload_render should fail without deps") - } - if _, _, err := workloadValidate(denied)(ctx, nil, WorkloadValidateInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_validate should fail without deps") - } - if _, _, err := workloadPlan(denied)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_plan should fail without deps") - } - if _, _, err := workloadApply(denied)(ctx, nil, WorkloadApplyInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_apply should fail without deps") - } -} - -// TestWriteToolsExplainAMissingWriter: a server built for diagnosis only has no -// Writer and no plan key. Both are wiring mistakes, and saying so beats a nil -// dereference in a handler or a token nothing can check. -func TestWriteToolsExplainAMissingWriter(t *testing.T) { - ctx := context.Background() - - diagnoseOnly := func(context.Context) (ToolDeps, error) { - return ToolDeps{Reader: &writeReader{}, Namespace: testNamespace, Project: testProject}, nil - } - _, _, err := workloadValidate(diagnoseOnly)(ctx, nil, WorkloadValidateInput{Manifest: "x"}) - if err == nil || !strings.Contains(err.Error(), "whoever operates this server") { - t.Errorf("compute_workload_validate error = %v, want it to name the wiring mistake", err) - } - - noKey := func(context.Context) (ToolDeps, error) { - return ToolDeps{ - Reader: &writeReader{}, Writer: &fakeWriter{}, - Namespace: testNamespace, Project: testProject, - }, nil - } - if _, _, err := workloadPlan(noKey)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_plan minted a token with no key to sign it") - } - if _, _, err := workloadApply(noKey)(ctx, nil, WorkloadApplyInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_apply accepted a token with no key to check it") - } - - // A request that never said which project it is for cannot bind a plan to - // one, and a token that binds to "" would spend anywhere. - noProject := func(context.Context) (ToolDeps, error) { - return ToolDeps{ - Reader: &writeReader{}, Writer: &fakeWriter{}, - Namespace: testNamespace, PlanTokenKey: testPlanKey, - }, nil - } - if _, _, err := workloadPlan(noProject)(ctx, nil, WorkloadPlanInput{Manifest: "x"}); err == nil { - t.Error("compute_workload_plan minted a token bound to no project") - } -} - -// TestPlanToApplyOverTheWire proves registration and the schemas, not just the -// handlers: a tool that is never wired into RegisterTools passes every test -// above and is uncallable in production, and an output the SDK cannot encode -// reaches the model as nothing at all. -func TestPlanToApplyOverTheWire(t *testing.T) { - ctx := context.Background() - writer := &fakeWriter{} - deps := writeDeps(&writeReader{}, writer) - - server := mcp.NewServer(&mcp.Implementation{Name: testServerName, Version: testImplVersion}, nil) - RegisterTools(server, deps) - - serverTransport, clientTransport := mcp.NewInMemoryTransports() - serverSession, err := server.Connect(ctx, serverTransport, nil) - if err != nil { - t.Fatalf("connecting server: %v", err) - } - defer func() { _ = serverSession.Close() }() - - client := mcp.NewClient(&mcp.Implementation{Name: testClientName, Version: testImplVersion}, nil) - clientSession, err := client.Connect(ctx, clientTransport, nil) - if err != nil { - t.Fatalf("connecting client: %v", err) - } - defer func() { _ = clientSession.Close() }() - - call := func(name string, args map[string]any, out any) { - t.Helper() - res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) - if err != nil { - t.Fatalf("calling %s: %v", name, err) - } - if res.IsError { - t.Fatalf("%s returned an error result: %+v", name, res.Content) - } - // Round-tripped through the wire's JSON, so the output schema is - // exercised as the model would receive it. - raw, err := json.Marshal(res.StructuredContent) - if err != nil { - t.Fatalf("marshalling %s output: %v", name, err) - } - if err := json.Unmarshal(raw, out); err != nil { - t.Fatalf("decoding %s output: %v", name, err) - } - } - - var rendered WorkloadRenderOutput - call(ToolWorkloadRender, map[string]any{ - "name": wlAPIBackend, - "image": testImage, - "placements": []map[string]any{ - {"locations": []string{locationDFW}, "minReplicas": 2}, - }, - }, &rendered) - if rendered.Manifest == "" { - t.Fatal("render returned no manifest over the wire") - } - - var planned WorkloadPlanOutput - call(ToolWorkloadPlan, map[string]any{"manifest": rendered.Manifest}, &planned) - if !planned.Valid || planned.PlanToken == "" { - t.Fatalf("plan over the wire returned %+v, want a token", planned) - } - - var applied WorkloadApplyOutput - call(ToolWorkloadApply, map[string]any{ - "manifest": planned.Manifest, - "planToken": planned.PlanToken, - }, &applied) - - if applied.Action != actionCreate || applied.Workload != wlAPIBackend { - t.Errorf("apply over the wire reported %+v, want a create of %q", applied, wlAPIBackend) - } - if len(writer.created) != 1 { - t.Fatalf("created %d workloads over the wire, want exactly 1", len(writer.created)) - } -} - -// assertRefusalIsReadable pins what every refusal owes the person reading it: -// that nothing happened, and what to do next. A refusal they cannot act on -// reads as a broken tool, and the next thing they try is the CLI. -func assertRefusalIsReadable(t *testing.T, err error) { - t.Helper() - msg := err.Error() - if !strings.Contains(msg, "nothing was created") { - t.Errorf("refusal = %q, want it to say plainly that nothing was created", msg) - } - if !strings.Contains(msg, ToolWorkloadPlan) { - t.Errorf("refusal = %q, want it to name %s as the way forward", msg, ToolWorkloadPlan) - } -} - -func contains(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} diff --git a/internal/cmd/compute/util/quota.go b/internal/cmd/compute/util/quota.go index 51611e58..8acf99ff 100644 --- a/internal/cmd/compute/util/quota.go +++ b/internal/cmd/compute/util/quota.go @@ -2,31 +2,163 @@ package util import ( "context" + "strings" + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/client" - - "go.datum.net/compute/internal/quotaview" ) -// The quota reading lives in internal/quotaview so the CLI and the MCP server -// share one implementation — the numbers a person is shown and the numbers an -// assistant reports must not be able to drift apart. These aliases keep the -// existing call sites in this package's consumers working. - // QuotaRow holds display-ready quota data for one resource type. -type QuotaRow = quotaview.QuotaRow +type QuotaRow struct { + ResourceType string `json:"resourceType"` + DisplayName string `json:"displayName"` + Unit string `json:"unit"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` + Available int64 `json:"available"` +} -// QuotaMeta overrides display metadata for a resource type. -type QuotaMeta = quotaview.QuotaMeta +// QuotaMeta overrides display metadata for a resource type. When provided, +// DisplayName, Unit, and Divisor take precedence over ResourceRegistration values. +type QuotaMeta struct { + DisplayName string + Unit string + // Divisor converts the stored integer value to display units (e.g. 1000 for + // millicores → vCPUs). Zero is treated as 1. + Divisor int64 + // Order controls the position of this row in the returned slice (ascending). + // Rows without a meta entry sort after all meta rows, alphabetically. + Order int +} -// ListServiceQuota returns quota rows for the project's quota whose resource -// type begins with resourceTypePrefix. See quotaview.ListServiceQuota. +// ListServiceQuota returns quota rows for AllowanceBuckets whose resource type +// begins with resourceTypePrefix (e.g. "compute.datumapis.com"). projectClient +// must target the project's virtual control plane; platformClient must target +// the platform API server (used to fetch ResourceRegistrations for display +// metadata when no override is provided in meta). +// +// meta may be nil. When an entry exists for a resource type, its DisplayName, +// Unit, and Divisor are used; otherwise the ResourceRegistration's displayUnit +// field is used and the divisor defaults to 1. func ListServiceQuota( ctx context.Context, projectClient, platformClient client.Client, resourceTypePrefix string, meta map[string]QuotaMeta, - orderedTypes []string, + orderedTypes []string, // explicit display order; types not in this list follow alphabetically ) ([]QuotaRow, error) { - return quotaview.ListServiceQuota(ctx, projectClient, platformClient, resourceTypePrefix, meta, orderedTypes) + // Fetch AllowanceBuckets from the project VCP. + var bucketList quotav1alpha1.AllowanceBucketList + if err := projectClient.List(ctx, &bucketList, + client.InNamespace("milo-system"), + client.MatchingLabels{"quota.miloapis.com/consumer-kind": "Project"}, + ); err != nil { + return nil, err + } + + // Index buckets by resource type, filtering to the requested prefix. + bucketByType := make(map[string]*quotav1alpha1.AllowanceBucket) + for i := range bucketList.Items { + b := &bucketList.Items[i] + if strings.HasPrefix(b.Spec.ResourceType, resourceTypePrefix) { + bucketByType[b.Spec.ResourceType] = b + } + } + + if len(bucketByType) == 0 { + return nil, nil + } + + // Fetch ResourceRegistrations from the platform for display metadata fallback. + rrByType := make(map[string]*quotav1alpha1.ResourceRegistration) + if platformClient != nil { + var rrList quotav1alpha1.ResourceRegistrationList + if err := platformClient.List(ctx, &rrList); err == nil { + for i := range rrList.Items { + rr := &rrList.Items[i] + if strings.HasPrefix(rr.Spec.ResourceType, resourceTypePrefix) { + rrByType[rr.Spec.ResourceType] = rr + } + } + } + } + + // Build an ordered index: position in orderedTypes slice. + orderIndex := make(map[string]int, len(orderedTypes)) + for i, rt := range orderedTypes { + orderIndex[rt] = i + } + + // Build rows in explicit order first, then append remaining alphabetically. + rows := make([]QuotaRow, 0, len(bucketByType)) + seen := make(map[string]bool, len(bucketByType)) + + appendRow := func(rt string, b *quotav1alpha1.AllowanceBucket) { + if seen[rt] { + return + } + seen[rt] = true + + displayName := resourceTypeSuffix(rt) + unit := "units" + var divisor int64 = 1 + + if m, ok := meta[rt]; ok { + if m.DisplayName != "" { + displayName = m.DisplayName + } + if m.Unit != "" { + unit = m.Unit + } + if m.Divisor > 1 { + divisor = m.Divisor + } + } else if rr, ok := rrByType[rt]; ok && rr.Spec.DisplayUnit != "" && rr.Spec.DisplayUnit != "1" { + unit = rr.Spec.DisplayUnit + } + + rows = append(rows, QuotaRow{ + ResourceType: rt, + DisplayName: displayName, + Unit: unit, + Limit: b.Status.Limit / divisor, + Used: b.Status.Allocated / divisor, + Available: b.Status.Available / divisor, + }) + } + + for _, rt := range orderedTypes { + if b, ok := bucketByType[rt]; ok { + appendRow(rt, b) + } + } + // Append any buckets not covered by orderedTypes. + remaining := make([]string, 0) + for rt := range bucketByType { + if !seen[rt] { + remaining = append(remaining, rt) + } + } + // Stable alphabetical order for the tail. + for i := 0; i < len(remaining)-1; i++ { + for j := i + 1; j < len(remaining); j++ { + if remaining[i] > remaining[j] { + remaining[i], remaining[j] = remaining[j], remaining[i] + } + } + } + for _, rt := range remaining { + appendRow(rt, bucketByType[rt]) + } + + return rows, nil +} + +// resourceTypeSuffix derives a human-readable name from the last segment of a +// resource type string (e.g. "compute.datumapis.com/vcpus" → "vcpus"). +func resourceTypeSuffix(resourceType string) string { + if idx := strings.LastIndex(resourceType, "/"); idx >= 0 { + return resourceType[idx+1:] + } + return resourceType } diff --git a/internal/locations/locations.go b/internal/locations/locations.go index d28b89cb..9dd2a9c0 100644 --- a/internal/locations/locations.go +++ b/internal/locations/locations.go @@ -199,68 +199,6 @@ func AvailableLocations(ctx context.Context, c client.Client) (available sets.Se return available, true, nil } -// ErrAvailabilityNotServed reports that a project does not serve one of the two -// kinds ListAvailableLocations reads, so where compute is offered cannot be -// answered at all. -// -// This does NOT degrade to no locations, where placement reads do. An empty -// list is a real answer — compute is offered nowhere this project may use — -// and returning it for a kind nobody is serving tells a customer their project -// has no locations when the truth is that nothing looked. The two call for -// opposite actions: one waits for Datum to add a location, the other is a -// deployment that needs fixing, so they must never arrive as the same answer. -// -// Wrapped with the kind that was missing, and matched with errors.Is. -var ErrAvailabilityNotServed = errors.New("where compute is offered cannot be read from this project") - -// ListAvailableLocations returns the locations where compute is offered and -// this project may use it, read only from the ServiceAvailability records the -// platform mirrors into the project and the Locations they name. -// -// There is no choice of source here, deliberately. ListPlacementLocations -// serves the manager, which reads whichever kinds its deployment was migrated -// to and treats a control plane that serves no availability as one that -// enforces none. This answers a customer instead, and a customer asking where -// they may deploy must be told the same thing wherever they ask, or refused — -// hence ErrAvailabilityNotServed rather than the permissive fallback. -func ListAvailableLocations(ctx context.Context, c client.Client) ([]PlacementLocation, error) { - available, enforced, err := AvailableLocations(ctx, c) - if err != nil { - return nil, err - } - if !enforced { - return nil, fmt.Errorf("%w: %s is not served here", ErrAvailabilityNotServed, "ServiceAvailability") - } - - var list locationsv1alpha1.LocationList - if err := c.List(ctx, &list); err != nil { - if kindNotInstalled(err) { - return nil, fmt.Errorf("%w: %s is not served here: %w", ErrAvailabilityNotServed, "Location", err) - } - return nil, fmt.Errorf("failed to list locations: %w", err) - } - - // A record naming a Location that is not there is skipped, not failed: the - // two objects are written by different services, and a project that can - // read one but not the other must still see the locations it can. - found := make([]PlacementLocation, 0, available.Len()) - for i := range list.Items { - location := &list.Items[i] - if !available.Has(location.Name) { - continue - } - found = append(found, PlacementLocation{ - Name: location.Name, - Topology: location.Spec.Topology, - Ready: apimeta.IsStatusConditionTrue( - location.Status.Conditions, locationsv1alpha1.LocationConditionReady), - ServiceAvailable: true, - }) - } - sort.Slice(found, func(i, j int) bool { return found[i].Name < found[j].Name }) - return found, nil -} - // ServiceAvailabilityGVK returns the kind a controller watches to learn that // compute availability at a location changed. func ServiceAvailabilityGVK() schema.GroupVersionKind { diff --git a/internal/locations/locations_test.go b/internal/locations/locations_test.go index b522a94b..29f2ef43 100644 --- a/internal/locations/locations_test.go +++ b/internal/locations/locations_test.go @@ -12,7 +12,6 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -468,136 +467,3 @@ func TestListPlacementLocations_ServiceAvailability(t *testing.T) { }) } } - -// Kinds the not-served tests withhold from the fake client. -const ( - kindServiceAvailability = "ServiceAvailability" - kindLocation = "Location" -) - -// TestListAvailableLocations covers the four shapes a control plane actually -// serves: compute available, compute not available, a location another service -// is available at, and an available record whose Location is not there. Only -// the first is returned. -func TestListAvailableLocations(t *testing.T) { - t.Parallel() - - cl := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - newReadyLocation(testLocationDFWA, testCityCode), - newReadyLocation(testLocationORD, testOtherCityCode), - newReadyLocation("lhr", "LHR"), - newComputeAvailability(testLocationDFWA), - // Deployed but not yet validated: not somewhere to place. - newAvailability(ComputeServiceName, testLocationORD, false), - // Another service is available at lhr; compute is not offered - // there, and a control plane serves every service's records. - newAvailability("dns", "lhr", true), - // A record whose Location is gone is skipped, not failed: it - // carries no topology to place against. - newComputeAvailability("atl"), - ). - Build() - - found, err := ListAvailableLocations(context.Background(), cl) - require.NoError(t, err) - require.Len(t, found, 1) - assert.Equal(t, testLocationDFWA, found[0].Name) - assert.True(t, found[0].Placeable()) - assert.Equal(t, []string{testCityCode}, CityCodes(found).UnsortedList()) -} - -// TestListAvailableLocations_ReportsUnreadyLocations keeps readiness visible -// rather than filtering on it: compute is offered there, and whether the -// location itself is serving yet is a separate fact the caller may report. -func TestListAvailableLocations_ReportsUnreadyLocations(t *testing.T) { - t.Parallel() - - cl := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - newLocation(testLocationDFWA, testCityCode), - newComputeAvailability(testLocationDFWA), - ). - Build() - - found, err := ListAvailableLocations(context.Background(), cl) - require.NoError(t, err) - require.Len(t, found, 1) - assert.True(t, found[0].ServiceAvailable) - assert.False(t, found[0].Ready) - assert.False(t, found[0].Placeable()) -} - -// TestListAvailableLocations_IgnoresLocationBindings keeps the reads apart: an -// availability read must never fall back to the bindings a control plane -// happens to still carry. -func TestListAvailableLocations_IgnoresLocationBindings(t *testing.T) { - t.Parallel() - - cl := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(newBinding("lhr", "LHR"), newReadyLocation("lhr", "LHR")). - Build() - - found, err := ListAvailableLocations(context.Background(), cl) - require.NoError(t, err) - assert.Empty(t, found) -} - -// TestListAvailableLocations_FailsWhenNotServed is the difference between -// "compute is offered nowhere" and "nothing looked". Either kind missing must -// fail, and fail identifiably, rather than answer with an empty list. -// -// This is where ListAvailableLocations parts company with ListPlacementLocations, -// which treats an unserved ServiceAvailability as a control plane that enforces -// no availability gate at all. -func TestListAvailableLocations_FailsWhenNotServed(t *testing.T) { - t.Parallel() - - noMatchFor := func(kinds ...string) interceptor.Funcs { - missing := sets.New(kinds...) - return interceptor.Funcs{ - List: func( - ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption, - ) error { - var kind string - switch list.(type) { - case *servicesv1alpha1.ServiceAvailabilityList: - kind = kindServiceAvailability - case *locationsv1alpha1.LocationList: - kind = kindLocation - } - if kind != "" && missing.Has(kind) { - return &apimeta.NoKindMatchError{ - GroupKind: schema.GroupKind{Kind: kind}, - } - } - return c.List(ctx, list, opts...) - }, - } - } - - for name, missing := range map[string][]string{ - "availability records are not served": {kindServiceAvailability}, - "locations are not served": {kindLocation}, - "neither is served": {kindServiceAvailability, kindLocation}, - } { - t.Run(name, func(t *testing.T) { - cl := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - newReadyLocation(testLocationDFWA, testCityCode), - newComputeAvailability(testLocationDFWA), - ). - WithInterceptorFuncs(noMatchFor(missing...)). - Build() - - found, err := ListAvailableLocations(context.Background(), cl) - require.Error(t, err, "a kind nobody serves must never read as no locations") - assert.ErrorIs(t, err, ErrAvailabilityNotServed) - assert.Empty(t, found) - }) - } -} diff --git a/internal/quotaview/quota.go b/internal/quotaview/quota.go deleted file mode 100644 index 0f01a488..00000000 --- a/internal/quotaview/quota.go +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -// Package quotaview reads a project's compute quota and renders it as display -// rows. -// -// It is deliberately free of cobra and of the datumctl plugin runtime: the same -// numbers are read by `datumctl compute quota` and by the MCP server's -// compute_quota_get tool, and there is one implementation so the two can never -// disagree about what a project has left. -package quotaview - -import ( - "context" - "sort" - "strings" - - quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - // ComputeResourceTypePrefix selects the resource types compute owns. - ComputeResourceTypePrefix = "compute.datumapis.com" - - // quotaNamespace is where a project's quota objects live inside its own - // control plane. - quotaNamespace = "milo-system" - - // consumerKindLabel and consumerKindProject select the quota held by the - // project itself, rather than by anything nested under it. - consumerKindLabel = "quota.miloapis.com/consumer-kind" - consumerKindProject = "Project" -) - -// QuotaRow holds display-ready quota data for one resource type. -type QuotaRow struct { - ResourceType string `json:"resourceType"` - DisplayName string `json:"displayName"` - Unit string `json:"unit"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` - Available int64 `json:"available"` -} - -// QuotaMeta overrides display metadata for a resource type. When provided, -// DisplayName, Unit, and Divisor take precedence over registered values. -type QuotaMeta struct { - DisplayName string - Unit string - // Divisor converts the stored integer value to display units (e.g. 1000 for - // millicores → vCPUs). Zero is treated as 1. - Divisor int64 - // Order controls the position of this row in the returned slice (ascending). - // Rows without a meta entry sort after all meta rows, alphabetically. - Order int -} - -// Compute's quota resource types, as registered with the platform. -const ( - ResourceTypeWorkloads = "compute.datumapis.com/workloads" - ResourceTypeInstances = "compute.datumapis.com/instances" - ResourceTypeVCPUs = "compute.datumapis.com/vcpus" - ResourceTypeMemory = "compute.datumapis.com/memory" - - unitVCPUs = "vCPUs" -) - -// ComputeOrderedTypes is the order compute's resource types are displayed in: -// the things a person counts first, first. -var ComputeOrderedTypes = []string{ - ResourceTypeWorkloads, - ResourceTypeInstances, - ResourceTypeVCPUs, - ResourceTypeMemory, -} - -// ComputeMeta supplies display overrides for compute's resource types. The -// live registrations declare a display unit of "1", which tells a reader -// nothing, so the units are named here instead. vCPUs are stored in -// millicores, hence the divisor. -var ComputeMeta = map[string]QuotaMeta{ - ResourceTypeWorkloads: {DisplayName: "Workloads", Unit: "workloads", Divisor: 1}, - ResourceTypeInstances: {DisplayName: "Instances", Unit: "instances", Divisor: 1}, - ResourceTypeVCPUs: {DisplayName: unitVCPUs, Unit: unitVCPUs, Divisor: 1000}, - ResourceTypeMemory: {DisplayName: "Memory", Unit: "MiB", Divisor: 1}, -} - -// ListServiceQuota returns quota rows for the project's quota whose resource -// type begins with resourceTypePrefix (e.g. "compute.datumapis.com"). -// projectClient must target the project; platformClient must target the -// platform API server, and supplies display metadata when meta carries no -// override. -// -// platformClient may be nil. A caller that reads only as the person who asked -// holds no platform credential of its own, and display metadata is not worth -// failing a read over: without it, units fall back to the generic "units". -// -// meta may be nil. When an entry exists for a resource type, its DisplayName, -// Unit, and Divisor are used; otherwise the registered display unit is used and -// the divisor defaults to 1. -func ListServiceQuota( - ctx context.Context, - projectClient, platformClient client.Client, - resourceTypePrefix string, - meta map[string]QuotaMeta, - orderedTypes []string, // explicit display order; types not in this list follow alphabetically -) ([]QuotaRow, error) { - var bucketList quotav1alpha1.AllowanceBucketList - if err := projectClient.List(ctx, &bucketList, - client.InNamespace(quotaNamespace), - client.MatchingLabels{consumerKindLabel: consumerKindProject}, - ); err != nil { - return nil, err - } - - // Index by resource type, filtering to the requested prefix. - bucketByType := make(map[string]*quotav1alpha1.AllowanceBucket) - for i := range bucketList.Items { - b := &bucketList.Items[i] - if strings.HasPrefix(b.Spec.ResourceType, resourceTypePrefix) { - bucketByType[b.Spec.ResourceType] = b - } - } - - if len(bucketByType) == 0 { - return nil, nil - } - - // Display metadata fallback, best effort: a caller with no platform - // credential still gets numbers. - rrByType := make(map[string]*quotav1alpha1.ResourceRegistration) - if platformClient != nil { - var rrList quotav1alpha1.ResourceRegistrationList - if err := platformClient.List(ctx, &rrList); err == nil { - for i := range rrList.Items { - rr := &rrList.Items[i] - if strings.HasPrefix(rr.Spec.ResourceType, resourceTypePrefix) { - rrByType[rr.Spec.ResourceType] = rr - } - } - } - } - - rows := make([]QuotaRow, 0, len(bucketByType)) - seen := make(map[string]bool, len(bucketByType)) - - appendRow := func(rt string, b *quotav1alpha1.AllowanceBucket) { - if seen[rt] { - return - } - seen[rt] = true - - displayName := resourceTypeSuffix(rt) - unit := "units" - var divisor int64 = 1 - - if m, ok := meta[rt]; ok { - if m.DisplayName != "" { - displayName = m.DisplayName - } - if m.Unit != "" { - unit = m.Unit - } - if m.Divisor > 1 { - divisor = m.Divisor - } - } else if rr, ok := rrByType[rt]; ok && rr.Spec.DisplayUnit != "" && rr.Spec.DisplayUnit != "1" { - unit = rr.Spec.DisplayUnit - } - - rows = append(rows, QuotaRow{ - ResourceType: rt, - DisplayName: displayName, - Unit: unit, - Limit: b.Status.Limit / divisor, - Used: b.Status.Allocated / divisor, - Available: b.Status.Available / divisor, - }) - } - - for _, rt := range orderedTypes { - if b, ok := bucketByType[rt]; ok { - appendRow(rt, b) - } - } - - // Anything the caller did not order sorts alphabetically behind it, so the - // output stays reproducible when a new resource type appears. - remaining := make([]string, 0, len(bucketByType)) - for rt := range bucketByType { - if !seen[rt] { - remaining = append(remaining, rt) - } - } - sort.Strings(remaining) - for _, rt := range remaining { - appendRow(rt, bucketByType[rt]) - } - - return rows, nil -} - -// ListComputeQuota is ListServiceQuota with compute's own prefix, display -// metadata and order applied. -func ListComputeQuota( - ctx context.Context, projectClient, platformClient client.Client, -) ([]QuotaRow, error) { - return ListServiceQuota( - ctx, projectClient, platformClient, - ComputeResourceTypePrefix, ComputeMeta, ComputeOrderedTypes, - ) -} - -// resourceTypeSuffix derives a human-readable name from the last segment of a -// resource type string (e.g. "compute.datumapis.com/vcpus" → "vcpus"). -func resourceTypeSuffix(resourceType string) string { - if idx := strings.LastIndex(resourceType, "/"); idx >= 0 { - return resourceType[idx+1:] - } - return resourceType -} diff --git a/internal/quotaview/quota_test.go b/internal/quotaview/quota_test.go deleted file mode 100644 index 61b78179..00000000 --- a/internal/quotaview/quota_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package quotaview - -import ( - "context" - "testing" - - quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func testScheme(t *testing.T) *runtime.Scheme { - t.Helper() - s := runtime.NewScheme() - if err := quotav1alpha1.AddToScheme(s); err != nil { - t.Fatalf("building scheme: %v", err) - } - return s -} - -func bucket(resourceType string, limit, allocated, available int64) *quotav1alpha1.AllowanceBucket { - return "av1alpha1.AllowanceBucket{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceType, - Namespace: quotaNamespace, - Labels: map[string]string{consumerKindLabel: consumerKindProject}, - }, - Spec: quotav1alpha1.AllowanceBucketSpec{ResourceType: resourceType}, - Status: quotav1alpha1.AllowanceBucketStatus{Limit: limit, Allocated: allocated, Available: available}, - } -} - -func projectClient(t *testing.T, objs ...client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(objs...).Build() -} - -// TestListComputeQuotaOrdersRowsAndConvertsUnits pins the two things a caller -// depends on: the explicit order, and vCPUs arriving as vCPUs rather than as -// the thousandths they are stored in. -func TestListComputeQuotaOrdersRowsAndConvertsUnits(t *testing.T) { - c := projectClient(t, - // Inserted out of display order, so the ordering is proven. - bucket(ResourceTypeVCPUs, 8000, 3000, 5000), - bucket(ResourceTypeWorkloads, 10, 3, 7), - bucket(ResourceTypeMemory, 16384, 4096, 12288), - ) - - // No platform client: the server that reads as the person who asked has - // none, and the numbers must still arrive. - rows, err := ListComputeQuota(context.Background(), c, nil) - if err != nil { - t.Fatalf("ListComputeQuota: %v", err) - } - if len(rows) != 3 { - t.Fatalf("got %d rows, want 3: %+v", len(rows), rows) - } - - wantOrder := []string{ - ResourceTypeWorkloads, - ResourceTypeVCPUs, - ResourceTypeMemory, - } - for i, want := range wantOrder { - if rows[i].ResourceType != want { - t.Errorf("rows[%d] = %q, want %q", i, rows[i].ResourceType, want) - } - } - - vcpus := rows[1] - if vcpus.Unit != unitVCPUs || vcpus.Limit != 8 || vcpus.Used != 3 || vcpus.Available != 5 { - t.Errorf("vCPU row = %+v, want 8/3/5 vCPUs (divided down from millicores)", vcpus) - } -} - -// TestListServiceQuotaIgnoresOtherServices keeps another service's quota out of -// compute's answer, and sorts whatever compute owns but did not order. -func TestListServiceQuotaIgnoresOtherServices(t *testing.T) { - c := projectClient(t, - bucket("networking.datumapis.com/networks", 5, 1, 4), - bucket("compute.datumapis.com/zzz-new", 2, 0, 2), - bucket("compute.datumapis.com/aaa-new", 2, 0, 2), - bucket(ResourceTypeWorkloads, 10, 3, 7), - ) - - rows, err := ListComputeQuota(context.Background(), c, nil) - if err != nil { - t.Fatalf("ListComputeQuota: %v", err) - } - - want := []string{ - ResourceTypeWorkloads, // explicitly ordered, so first - "compute.datumapis.com/aaa-new", // the rest alphabetically, so a new - "compute.datumapis.com/zzz-new", // resource type lands reproducibly - } - if len(rows) != len(want) { - t.Fatalf("got %d rows, want %d: %+v", len(rows), len(want), rows) - } - for i, rt := range want { - if rows[i].ResourceType != rt { - t.Errorf("rows[%d] = %q, want %q", i, rows[i].ResourceType, rt) - } - } - // A type with no display metadata falls back to its last segment. - if rows[1].DisplayName != "aaa-new" || rows[1].Unit != "units" { - t.Errorf("unregistered row = %+v, want the suffix as its name and generic units", rows[1]) - } -} - -// TestListServiceQuotaReturnsNothingWhenNoQuotaIsConfigured distinguishes "no -// quota" from a failure: a project with none is not an error. -func TestListServiceQuotaReturnsNothingWhenNoQuotaIsConfigured(t *testing.T) { - rows, err := ListComputeQuota(context.Background(), projectClient(t), nil) - if err != nil { - t.Fatalf("ListComputeQuota: %v", err) - } - if len(rows) != 0 { - t.Errorf("rows = %+v, want none", rows) - } -} diff --git a/internal/workloadspec/diff.go b/internal/workloadspec/diff.go deleted file mode 100644 index 37439df0..00000000 --- a/internal/workloadspec/diff.go +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -package workloadspec - -import ( - "fmt" - "strings" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - computev1alpha "go.datum.net/compute/api/v1alpha" -) - -// Diff summarizes what applying desired would change about existing, as lines -// meant to be shown to a person before they confirm an apply. An empty result -// means nothing Diff reports on changed — it covers the image and the -// placements, not the whole spec. -// -// Lines are ordered by the desired manifest, then by the existing one, so the -// same pair of manifests always produces the same output. -func Diff(existing, desired *computev1alpha.Workload) []string { - var lines []string - - oldImage := imageOf(existing) - newImage := imageOf(desired) - if oldImage != newImage { - lines = append(lines, fmt.Sprintf(" image: %s → %s", oldImage, newImage)) - } - - oldPlacements := placementsByName(existing) - - seen := make(map[string]struct{}, len(oldPlacements)) - for _, np := range placementsOf(desired) { - seen[np.Name] = struct{}{} - - op, ok := oldPlacements[np.Name] - if !ok { - lines = append(lines, fmt.Sprintf(" + new placement %q: %s", np.Name, placementWhere(np))) - continue - } - - if op.ScaleSettings.MinReplicas != np.ScaleSettings.MinReplicas { - lines = append(lines, fmt.Sprintf(" placement %q min replicas: %d → %d", - np.Name, op.ScaleSettings.MinReplicas, np.ScaleSettings.MinReplicas)) - } - } - - for _, op := range placementsOf(existing) { - if _, ok := seen[op.Name]; !ok { - lines = append(lines, fmt.Sprintf(" - removed placement %q", op.Name)) - } - } - - return lines -} - -// placementWhere describes where a placement runs, in whichever of the two -// forms it was written: a fixed list of locations, or a selector over their -// topology. A selector is shown as the selector, not as the locations it -// happens to match today, because that is what is being added. -func placementWhere(p computev1alpha.WorkloadPlacement) string { - if p.LocationSelector != nil { - selector, err := metav1.LabelSelectorAsSelector(p.LocationSelector) - if err != nil { - return "locationSelector=" - } - return "locationSelector=" + selector.String() - } - - names := make([]string, 0, len(p.Locations)) - for _, location := range p.Locations { - names = append(names, location.Name) - } - return fmt.Sprintf("locations=[%s]", strings.Join(names, ", ")) -} - -// imageOf returns the first container image found in a workload, or the empty -// string when there is none (a nil workload, or a VM runtime). -func imageOf(w *computev1alpha.Workload) string { - if w == nil { - return "" - } - sandbox := w.Spec.Template.Spec.Runtime.Sandbox - if sandbox != nil && len(sandbox.Containers) > 0 { - return sandbox.Containers[0].Image - } - return "" -} - -func placementsOf(w *computev1alpha.Workload) []computev1alpha.WorkloadPlacement { - if w == nil { - return nil - } - return w.Spec.Placements -} - -func placementsByName(w *computev1alpha.Workload) map[string]computev1alpha.WorkloadPlacement { - placements := placementsOf(w) - byName := make(map[string]computev1alpha.WorkloadPlacement, len(placements)) - for _, p := range placements { - byName[p.Name] = p - } - return byName -} diff --git a/internal/workloadspec/diff_test.go b/internal/workloadspec/diff_test.go deleted file mode 100644 index df08ab66..00000000 --- a/internal/workloadspec/diff_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -package workloadspec - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - - computev1alpha "go.datum.net/compute/api/v1alpha" -) - -func TestDiff(t *testing.T) { - base := func(tweaks ...func(*Input)) *computev1alpha.Workload { - t.Helper() - w, err := Render(validInput(tweaks...)) - if err != nil { - t.Fatalf("Render() error: %v", err) - } - return w - } - - cases := map[string]struct { - existing *computev1alpha.Workload - desired *computev1alpha.Workload - want []string - }{ - "no changes": { - existing: base(), - desired: base(), - want: nil, - }, - "image change": { - existing: base(), - desired: base(func(in *Input) { in.Image = "ghcr.io/acme/api:2.0.0" }), - want: []string{" image: ghcr.io/acme/api:1.4.2 → ghcr.io/acme/api:2.0.0"}, - }, - "replica change": { - existing: base(), - desired: base(func(in *Input) { in.Placements[0].MinReplicas = 5 }), - want: []string{` placement "us" min replicas: 2 → 5`}, - }, - "added and removed placements are reported in manifest order": { - existing: base(), - desired: base(func(in *Input) { - in.Placements = []Placement{ - {Name: "eu", Locations: []string{"eu-west-ams-1", "eu-central-fra-1"}, MinReplicas: 1}, - } - }), - want: []string{ - ` + new placement "eu": locations=[eu-west-ams-1, eu-central-fra-1]`, - ` - removed placement "us"`, - }, - }, - "a placement that selects locations is described by its selector": { - existing: nil, - desired: base(func(in *Input) { - in.Placements[0].Locations = nil - in.Placements[0].LocationSelector = cityCodeSelector(testCityCode) - }), - want: []string{ - " image: → ghcr.io/acme/api:1.4.2", - ` + new placement "us": locationSelector=topology.datum.net/city-code=DFW`, - }, - }, - "creating from nothing": { - existing: nil, - desired: base(), - want: []string{ - " image: → ghcr.io/acme/api:1.4.2", - ` + new placement "us": locations=[us-south-dfw-1]`, - }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - if delta := cmp.Diff(tc.want, Diff(tc.existing, tc.desired)); delta != "" { - t.Errorf("Diff() mismatch (-want +got):\n%s", delta) - } - }) - } -} From 48a2da456e68c230ce94b5aa44a1b7a6c5f9c0a5 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 14 Sep 2026 12:54:14 -0500 Subject: [PATCH 5/5] docs(agent): route workload creation through the assistant's base tools The workload-create skill gathers inputs with locations_list (service compute), quota_get, resources_list and compute_instance_types_list, renders with compute_workload_render, and plans a missing Network in the same resources_plan call before resources_apply. The knowledge document, README and quota-triage point at the base tools in place of the removed compute ones. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agent/README.md | 37 ++----- docs/agent/llms-full.txt | 44 +++----- docs/agent/skills/quota-triage.md | 22 ++-- docs/agent/skills/workload-create.md | 148 +++++++++++++++------------ 4 files changed, 119 insertions(+), 132 deletions(-) diff --git a/docs/agent/README.md b/docs/agent/README.md index 02392ade..a2b97401 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -27,14 +27,20 @@ over Streamable HTTP: | Tools | Names | |---|---| | Diagnosis, read-only | `compute_workloads_list`, `compute_workloads_get`, `compute_instances_list`, `compute_workload_diagnose`, `compute_reason_explain` | -| Discovery, read-only | `compute_locations_list`, `compute_networks_list`, `compute_quota_get`, `compute_instance_types_list` — what a project may place, attach to, afford, and ask for. The locations come from compute's own availability records, so the list is where compute is offered and this project can use it. | -| Planning, writes nothing | `compute_workload_render` (inputs to a manifest, pure), `compute_workload_validate` (the server's verdict on that manifest without creating it) | -| Mutating | `compute_workload_plan`, `compute_workload_apply` | +| Creation, writes nothing | `compute_instance_types_list` (the sizes a workload may ask for), `compute_workload_render` (inputs to a Workload manifest, pure) | Every tool is prefixed `compute_`, so the assistant can compose tools from several services in one conversation without names colliding; the capability document must register the prefixed names. +Compute publishes no mutating tool. Everything else creating a workload needs +comes from the assistant's base tools, which it gives every project turn and +which act as the caller: `locations_list` with service `compute` for where +compute is offered, `quota_get` for what is left, `resources_list` for +Networks and RuntimeClasses, and `resources_validate`, `resources_plan` and +`resources_apply` for the change itself. The plan token and the confirmation +step live there, once, for every service. + ## HTTP surface One process answers everything compute's capability document points at: @@ -69,29 +75,6 @@ Three properties of the server are worth knowing before you deploy it: prompt injection away from another tenant's workloads. The caller sets `X-Datum-Project` after authenticating the user. -Compute publishes exactly two mutating tools, `compute_workload_plan` and -`compute_workload_apply`, and they are deliberately one operation split in half. -`compute_workload_plan` validates a manifest, resolves whether it is a create or an -update, reports whether the network the interface names would have to be -created too, and returns the manifest, the diff, and a plan token — a hash of -that manifest, the project, and the version of the workload it saw. -`compute_workload_apply` accepts that manifest and that token and nothing else, and -re-derives the hash before it writes: a manifest edited after the plan, a token -from another project, or a workload someone else changed in the meantime is -refused. So the only thing apply can produce is the manifest the model already -put in front of the person who asked. A model that reads a poisoned status message cannot -smuggle a different workload past a confirmation of this one, and a manifest -nobody was shown has no token and cannot be applied at all. - -The rest of the surface is unchanged by this. Every write runs as the caller, -from the bearer token on the request, so the server holds no credential of its -own and can create nothing the person could not create themselves; the project -still comes from the header. Whether `compute_workload_apply` is offered to a given -project at all is the gateway's decision, from its allow-list — the split above -constrains what a published tool can do, not which projects get it. Adding a -third mutating tool is a new decision and gets its own review: the argument -above is about these two and does not generalise. - ## Why the knowledge leads with "how to read conditions" Compute's top-level condition reasons are deliberately **pointers, not causes**. @@ -120,7 +103,7 @@ orientation and classification; the procedures live here and nowhere else. | `referenced-data-triage` | Missing, unauthorized, or oversized ConfigMaps/Secrets | | `placement-triage` | `NoMatchingLocation`, `AmbiguousServingLocation`, `LocationMismatch` | | `stalled-transient` | A transient reason that has outlived its expected window | -| `workload-create` | Deploying something new: prerequisites, the choices that are final at create, and render → validate → show → plan → confirm → apply | +| `workload-create` | Deploying something new: prerequisites, the choices that are final at create, and render → plan → show → confirm → apply | A skill never grants privileges. It can only direct the model toward tools that are independently on the enforced allow-list, which is why these go through the diff --git a/docs/agent/llms-full.txt b/docs/agent/llms-full.txt index e2a5ef7a..c030db41 100644 --- a/docs/agent/llms-full.txt +++ b/docs/agent/llms-full.txt @@ -208,14 +208,12 @@ the customer runs. And you cannot enable Compute for a project or grant it quota; both are Datum's to give. Writing is a sequence, not a call. `compute_workload_render` turns inputs into -a manifest and touches nothing. `compute_workload_validate` has the server -check that manifest without creating anything, and returns either the exact -rejection or the diff against an existing workload. `compute_workload_plan` -validates, settles create versus update, says whether the network has to be -created too, and returns a canonical manifest with a plan token that is a hash -of it. `compute_workload_apply` takes that manifest and that token and nothing -else, and re-derives the hash, so the only thing that can be created is the -manifest you showed the customer and they agreed to. +a Workload manifest and touches nothing. The rest is the platform's own change +path, the same for every service: `resources_plan` validates the manifests, +settles create versus update, orders a Network ahead of the Workload that names +it, and returns them with a plan token. `resources_apply` takes those manifests +and that token and nothing else, so the only thing that can be created is what +you showed the customer and they agreed to. Load `workload-create` before any of this. The prerequisites, the inputs, the rejections that are worth pre-empting, and what to do at each failure are all @@ -232,28 +230,16 @@ there, and this section deliberately does not restate them. steps compute_reason_explain any reason, explained, classified, and — when transient — the window it should clear inside - compute_locations_list the locations this project may place a workload - in, with the topology each declares, taken from - compute's availability records — a location - absent from it is one compute is not offered in. - Placements name these locations verbatim, or - select them by their topology - compute_networks_list the networks an interface may attach to - compute_quota_get how much compute the project is allowed, and - what is left compute_instance_types_list the instance types a workload may ask for - - compute_workload_render inputs to a full manifest; writes nothing, reads - nothing - compute_workload_validate the server's own verdict on a manifest, without - creating it: the exact rejection, or the diff - against what exists - compute_workload_plan validate, resolve create versus update, check - the network, and mint a plan token over the - manifest - compute_workload_apply create or update — the planned manifest and its - token, and nothing that was not planned and - shown + compute_workload_render inputs to a full Workload manifest; writes + nothing, reads nothing + +The platform's base tools fill in the rest of a create. Call locations_list +with service "compute" for the locations this project may place a workload in — +a location absent from it is one compute is not offered in, and placements name +these verbatim or select them by their topology. quota_get says how much +compute is left, resources_list reads Networks and RuntimeClasses, and +resources_plan and resources_apply make the change. Skills (load on demand) carry the procedures: workload-not-available, quota-triage, instance-not-ready, referenced-data-triage, placement-triage, diff --git a/docs/agent/skills/quota-triage.md b/docs/agent/skills/quota-triage.md index 6cb6af33..9abe3ff7 100644 --- a/docs/agent/skills/quota-triage.md +++ b/docs/agent/skills/quota-triage.md @@ -34,7 +34,8 @@ service that evaluates it, and not the request compute files against it. 3. **For `QuotaExceeded`, quantify it.** The status message carries the amount requested and the amount left. Quote both. Then give the customer the three real options: fewer replicas, less CPU or memory per instance, or ask Datum - to raise the project's quota. + to raise the project's quota. If the message leaves out what is left, + `quota_get` has it. 4. **For `PendingEvaluation`**, check how long. Minutes is normal. If it stays there, the checking service itself is stuck — treat it as @@ -52,14 +53,17 @@ burn time trying. ## When the numbers are not there -Step 3 rests entirely on the status message. Nothing else in these tools carries -the project's compute quota, how much of it is in use, or how much is left. So -when a `QuotaExceeded` message arrives without figures — or carries what was -requested but not what remains — you cannot tell the customer how much smaller -to go, and "ask for less" without a number is not something they can act on. - -Say which half you have and which is missing, then file `InsufficientDetail` -against the tool you read it from, quoting the message you were given: +When a `QuotaExceeded` message arrives without figures — or carries what was +requested but not what remains — call `quota_get` with service +`compute.datumapis.com`. It reports the project's compute quota per resource +type: the limit, how much is in use, and how much is left. That is the number +the customer needs to know how much smaller to go. + +If `quota_get` cannot answer either, you cannot tell the customer how much +smaller to go, and "ask for less" without a number is not something they can act +on. Say which half you have and which is missing, then file +`InsufficientDetail` against the tool you read it from, quoting the message you +were given: "capability": "how much of the project's compute quota is left", "kind": "InsufficientDetail", diff --git a/docs/agent/skills/workload-create.md b/docs/agent/skills/workload-create.md index 2ddd1b58..9bf11ae9 100644 --- a/docs/agent/skills/workload-create.md +++ b/docs/agent/skills/workload-create.md @@ -2,15 +2,14 @@ Use when someone asks to deploy, run, or create something on Datum — a new Workload, or a change to one that does not exist yet — and whenever you are -about to call `compute_workload_render`, `compute_workload_validate`, `compute_workload_plan` or -`compute_workload_apply`. +about to call `compute_workload_render`, or `resources_plan` or +`resources_apply` with a Workload in the manifests. ## The one thing to know -**You never write a workload directly. You render it, validate it, show it, and -apply only what the user agreed to.** `compute_workload_apply` takes the manifest -`compute_workload_plan` returned and that plan's token, and nothing else. The token is -a hash of that manifest — the same one you put in front of the user. Change the +**You never write a workload directly. You render it, plan it, show it, and +apply only what the user agreed to.** `resources_apply` takes the manifests +`resources_plan` returned and that plan's token, and nothing else. Change a manifest by one character and the token stops matching, so what gets created is exactly what was shown and agreed to, or nothing at all. @@ -33,17 +32,17 @@ different answer: | Check | Tool | If it fails | |---|---|---| -| Compute is enabled for the project | `compute_locations_list` | Nothing can be placed. Datum's to enable — the user runs `datumctl compute access request`, and approval is a manual step on Datum's side. | -| Somewhere to run it | `compute_locations_list` | The location names it returns are the only ones a placement may name; they come from compute's own availability records, so a location missing from the list is one compute is not offered in. An empty list means nothing is available to this project yet; that is Datum's, not something the user can add. | -| A network | `compute_networks_list` | `default` by convention. If it is missing, `compute_workload_plan` says so and `compute_workload_apply` creates it alongside the workload — say so when you show the plan, because it is a second object being created. | -| Quota | `compute_quota_get` | Quota is granted by Datum and cannot be self-served. A project with none can still create a workload; its instances then sit at `QuotaGranted=False` with `QuotaNoBudget` and never start. | +| Compute is offered to the project | `locations_list` with service `compute` | Nothing can be placed. Datum's to enable — the user runs `datumctl compute access request`, and approval is a manual step on Datum's side. | +| Somewhere to run it | `locations_list` with service `compute` | The location names it returns are the only ones a placement may name. A location missing from the list is one compute is not offered in. An empty list means nothing is available to this project yet; that is Datum's, not something the user can add. | +| A network | `resources_list` for kind `Network` in `networking.datumapis.com/v1alpha` | `default` by convention. If the one the workload names is missing, add a Network manifest of that name to the same `resources_plan` call — the plan orders it ahead of the Workload. Say so when you show the plan, because it is a second object being created. | +| Quota | `quota_get` with service `compute.datumapis.com` | Quota is granted by Datum and cannot be self-served. A project with none can still create a workload; its instances then sit at `QuotaGranted=False` with `QuotaNoBudget` and never start. | Do the quota arithmetic before you apply, not after. Replicas times the instance -type against what `compute_quota_get` says is left tells you whether this will start. -If it will not, say so *before* asking for confirmation — a workload that -creates cleanly and then sits at `QuotaExceeded` looks like a success and is -not. Load `quota-triage` for the difference between being over quota and having -none. +type's size from `compute_instance_types_list`, against what `quota_get` says is +left, tells you whether this will start. If it will not, say so *before* asking +for confirmation — a workload that creates cleanly and then sits at +`QuotaExceeded` looks like a success and is not. Load `quota-triage` for the +difference between being over quota and having none. ## 2. Container or virtual machine @@ -75,26 +74,33 @@ nobody has pushed. ## 3. Gather the inputs -Ask for what is missing rather than inventing it. `compute_workload_render` takes: +Ask for what is missing rather than inventing it. `compute_workload_render` +takes: - **name** — a DNS label (lowercase letters, digits and `-`). It is the object's name and cannot be changed later. - **image** — fully qualified, per above. - **placements** — where the instances run, and how many. A placement says where in exactly one of two ways: - - **`locations`** — location names, taken verbatim from - `compute_locations_list`. Use this when the user named specific places. A - name that is not in that list can never be satisfied, so never invent one - and never pass a city code here. - - **`locationSelector`** — a selector over the topology - `compute_locations_list` reports for each location, such as - `topology.datum.net/city-code: DFW`. This is how you say "every location in - Dallas" or "every location in a region" without naming them, and it picks - up locations added later on its own. + - **`locations`** — location names, taken verbatim from `locations_list`. Use + this when the user named specific places. A name that is not in that list + can never be satisfied, so never invent one and never pass a city code here. + - **`locationSelector`** — a selector over the topology `locations_list` + reports for each location, such as `topology.datum.net/city-code: DFW`. This + is how you say "every location in Dallas" or "every location in a region" + without naming them, and it picks up locations added later on its own. Group locations that scale together into one placement. - **replicas** — `minReplicas` must be at least 1. There is no scaling from zero, and the ceiling is 1000. +- **instance type** — from `compute_instance_types_list`. Leave it unset to take + the default. +- **runtime class** — only if the user named an execution tier. The choices are + the RuntimeClass objects `resources_list` returns for + `compute.datumapis.com/v1alpha`. Leave it unset otherwise; the platform picks + its default, and the tier cannot be changed later. +- **network** — the name of a Network from `resources_list`, or leave it unset + for `default`. - **port** — optional, and named. A port is how anything reaches the workload; ask whether it serves traffic rather than guessing. - **environment variables** — literal values, or drawn from a ConfigMap or a @@ -109,10 +115,11 @@ Ask for what is missing rather than inventing it. `compute_workload_render` take ## 4. The traps These are the ones that cost a round trip. Check the rendered manifest against -this list before you validate. +this list before you plan. 1. **One instance type.** `datumcloud/d1-standard-2` is the only one accepted - today. `compute_instance_types_list` is the check; anything else is rejected outright. + today. `compute_instance_types_list` is the check; anything else is rejected + outright. 2. **Per-container CPU and memory are not accepted.** A `resources` block on a container is rejected, and so are adjustments to the instance type's own @@ -166,33 +173,44 @@ this list before you validate. Follow it in order. Each step exists because of a failure the next one cannot catch. -1. **`compute_workload_render`** — inputs in, a full manifest out. It writes nothing and - reaches nothing. Read what came back rather than assuming it matches what you - asked for. - -2. **`compute_workload_validate`** — the server checks the manifest without creating - anything. This is where the traps above surface as real rejections, and it is - also where you learn whether a workload of this name already exists: for an - existing one, validate returns the diff instead. - -3. **Show the user the manifest and, if there is one, the diff.** Whole, not - summarised. Then say in plain words what will be created, where, how many, - and what it will cost against their quota. If the plan says the network has - to be created too, say that: it is a second object. - -4. **`compute_workload_plan`** — validates again, settles whether this is a create or an - update, says whether the network has to be created too, and mints the token - over the manifest it returns. Show that manifest, not your own draft. - -5. **Get an explicit yes.** A question about the plan is not a yes. "Looks +1. **`compute_workload_render`** — inputs in, a full Workload manifest out. It + writes nothing and reaches nothing. Read what came back rather than assuming + it matches what you asked for, and read its notes. + +2. **`resources_plan`** — pass the rendered manifest, and a Network manifest + ahead of it if step 1 found the network missing: + + apiVersion: networking.datumapis.com/v1alpha + kind: Network + metadata: + name: default + namespace: default + spec: + ipam: + mode: Auto + + The plan validates everything without creating it, settles create versus + update for each manifest, reports what would change, and returns the + manifests with a plan token. This is where the traps above surface as real + rejections, and where you learn whether a workload of this name already + exists. + +3. **Show the user the planned manifests and the diff.** Whole, not + summarised, and the plan's own manifests rather than your draft. Then say in + plain words what will be created, where, how many, and what it will cost + against their quota. If a Network is in the plan, say that: it is a second + object. + +4. **Get an explicit yes.** A question about the plan is not a yes. "Looks right" is. If the user asks for any change, go back to step 1 — a token - minted for the old manifest is not valid for the new one, and must not be + minted for the old manifests is not valid for new ones, and must not be applied because it was close. -6. **`compute_workload_apply`** with the plan's manifest and its token. +5. **`resources_apply`** with the plan's manifests, in the plan's order, and its + token. -7. **`compute_workload_diagnose`** for the rollout. Creation succeeding means the - request was accepted, not that anything is running. Tell the user what to +6. **`compute_workload_diagnose`** for the rollout. Creation succeeding means + the request was accepted, not that anything is running. Tell the user what to expect: instances appear, then start, and the first pull of a large image takes a while. If it is not serving, that is `workload-not-available`'s procedure, not this one. @@ -203,29 +221,25 @@ catch. name. Do not fill it in with a plausible default; a guessed port or location is a workload that runs in the wrong place. -- **Validate rejects it** — this is the server's own answer, in its own words, +- **The plan rejects it** — this is the server's own answer, in its own words, and it names the exact field. Quote the field path verbatim and translate the rule beside it: `spec.template.spec.volumes[1].name: volume must be attached at least 1 time` is "the `config` volume is declared but never mounted". Fix - it, render again, validate again. Never apply something that failed validate. + it, render again, plan again. A rejected plan has no token, so there is + nothing to apply. -- **Validate returns a diff you did not expect** — a workload of that name is +- **The plan says update when you expected create** — a workload of that name is already there. Stop and say so. Ask whether the user meant to change the existing one, and check the diff for anything immutable from trap 5 before going on, because those rejections arrive at apply and not before. -- **Plan fails** — the manifest was rejected on the second look, or the - workload moved underneath you between validate and plan. A failed plan mints - no token, so there is nothing to apply. Re-read, re-render, and show the user - again. Do not retry a plan you do not understand the failure of. - - **Apply refuses the token** — something changed after the plan. That refusal - is the mechanism working. Re-plan, show the new manifest, and ask again. + is the mechanism working. Re-plan, show the new manifests, and ask again. Never work around it. -- **Apply succeeds and nothing starts** — hand it to `compute_workload_diagnose` and - follow the skill it names. Quota and image problems both look like this and - lead to opposite advice. +- **Apply succeeds and nothing starts** — hand it to `compute_workload_diagnose` + and follow the skill it names. Quota and image problems both look like this + and lead to opposite advice. ## If the user has a shell @@ -260,10 +274,10 @@ as more than it is. `report_capability_gap__compute-datumapis-com` is for cases where these tools could not get a legitimate creation done: -- A field the user needs that `compute_workload_render` has no input for, where the API - clearly supports it — `InsufficientDetail`, quoting the field and what you - tried. -- A validate rejection whose message does not name what to change, so the user +- A field the user needs that `compute_workload_render` has no input for, where + the API clearly supports it — `InsufficientDetail`, quoting the field and what + you tried. +- A plan rejection whose message does not name what to change, so the user cannot act on it — `UnactionableGuidance`, quoting the message verbatim. Not gaps, however awkward the turn: @@ -272,6 +286,6 @@ Not gaps, however awkward the turn: - **No quota, or Compute not enabled.** Those are grants, and the tools reporting them accurately is the tools working. - **A rejection that was right.** An unsupported instance type or an unattached - volume is validate doing its job — that is the answer, and it saved a broken + volume is the plan doing its job — that is the answer, and it saved a broken workload. - **The user declined to confirm.** Not applying is the correct outcome.