From 02ea6d7e62b1bac0e3e435aa692c019040af91a5 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 13:07:39 +0100 Subject: [PATCH 01/21] docs(provisioning): define external provisioner architecture --- README.md | 3 +- ...oner-and-service-principal-architecture.md | 708 ++++++++++++++++++ 2 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 docs/2026-03-11-external-provisioner-and-service-principal-architecture.md diff --git a/README.md b/README.md index 80b6782..ce968e8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The backend inside a workspace is pluggable. OpenClaw is one example runtime. An > Spritz is in active development and should be treated as alpha software. APIs, CRDs, Helm values, and UI details may still change while the deployment model is being hardened. -[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [OpenClaw Integration](OPENCLAW.md) +[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [External Provisioners](docs/2026-03-11-external-provisioner-and-service-principal-architecture.md) · [OpenClaw Integration](OPENCLAW.md) ## Vision @@ -182,4 +182,5 @@ Spritz is intended to remain portable and standalone: - `docs/2026-02-24-simplest-spritz-deployment-spec.md` - `docs/2026-02-24-portable-authentication-and-account-architecture.md` - `docs/2026-03-09-acp-port-and-agent-chat-architecture.md` +- `docs/2026-03-11-external-provisioner-and-service-principal-architecture.md` - `OPENCLAW.md` diff --git a/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md b/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md new file mode 100644 index 0000000..25f3739 --- /dev/null +++ b/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md @@ -0,0 +1,708 @@ +--- +date: 2026-03-11 +author: Onur +title: External Provisioner and Service Principal Architecture +tags: [spritz, provisioning, auth, cli, lifecycle, architecture] +--- + +## Overview + +This document defines the target architecture for letting external automation +create Spritz workspaces for human users. + +Typical examples include: + +- a chat bot, +- an assistant running in another system, +- a workflow engine, +- a support or onboarding automation. + +The target model is: + +- Spritz remains the only control plane, +- the external system acts as a narrow service principal, +- the created workspace is owned by the human user, +- the external system cannot later mutate or delete that workspace unless it is + granted a separate lifecycle role, +- Spritz returns the canonical access URL and lifecycle metadata at creation + time. + +The existing `spz` CLI should be the official machine client for this flow. + +## Problem Statement + +Spritz already supports authenticated browser users and a CLI/API surface for +creating workspaces. What is missing is a production-ready model for external +systems to create workspaces for someone else without turning those systems into +full administrators or hidden impersonators. + +The system must satisfy all of these requirements: + +- an external system can create a workspace for a real user, +- the user later accesses that workspace with their normal Spritz login, +- the external system does not need Kubernetes access, +- the external system does not construct access URLs on its own, +- the created workspace has both an idle lifetime and a hard maximum lifetime, +- all policy, audit, and ownership decisions stay centralized in Spritz, +- the design stays portable and backend-agnostic. + +## Non-goals + +- Letting external systems act as the user after the workspace is created. +- Giving bots direct Kubernetes or CRD write access. +- Making images the main external-facing abstraction. +- Duplicating provisioning logic in the CLI, UI, or bot. +- Adding environment-specific or organization-specific assumptions to Spritz + core. + +## Design Principles + +### Spritz is the control plane + +Spritz owns: + +- provisioning, +- authentication and authorization, +- lifecycle enforcement, +- canonical access URLs, +- ownership, +- policy evaluation, +- audit logging. + +External systems must not bypass Spritz and must not write CRDs directly. + +### External systems are provisioners, not impersonators + +An external system may request workspace creation for a user, but it must not: + +- become that user, +- inherit that user's access rights, +- edit the workspace after creation, +- delete the workspace after creation, +- open terminal, SSH, or ACP sessions as that user. + +### Presets are the public provisioning abstraction + +External systems should request: + +- `presetId` + +not: + +- raw image references, +- raw env sets, +- raw cluster-specific runtime details. + +Presets are stable, policy-friendly, and portable. Images remain an internal +deployment concern. + +### The CLI stays thin + +`spz` should remain a thin client over the Spritz API. + +It should not: + +- evaluate authorization rules, +- construct URLs, +- generate lifecycle policy, +- implement retry deduplication rules on its own. + +It should: + +- collect inputs, +- call the API, +- print machine-readable results. + +### One runtime path and one ownership model + +The same ownership and lifecycle model should apply regardless of whether the +workspace was created: + +- from the UI, +- from `spz`, +- from an external bot, +- from any future client. + +## Actors and Roles + +### Human principal + +A human principal: + +- authenticates through the normal browser identity flow, +- owns the created workspace, +- can later open, use, chat with, and delete their own workspace subject to + normal policy. + +### Service principal + +A service principal is a non-human caller such as a bot or automation system. + +It authenticates with bearer-style machine credentials and is evaluated against +explicit scopes and provisioner policy. + +It is not a human session and it does not inherit ownership-based rights. + +### Admin principal + +An admin principal is a separate break-glass role with elevated capabilities. + +The external provisioner flow must not depend on admin rights as the normal +path. + +## Auth Model + +Spritz should use two clean auth paths: + +- browser users: gateway-managed browser auth, +- service principals: bearer token auth directly to the API. + +This matches the existing portable auth model: + +- browser requests go through the normal authenticated host, +- service-to-service clients can use bearer auth without depending on browser + login flows. + +### API auth mode + +The preferred API mode for deployments that support both humans and service +principals is: + +- `api.auth.mode=auto` + +That allows: + +- header-derived principals for browser traffic, +- bearer-derived principals for service traffic. + +### Network path + +For in-cluster automation, the preferred path is the internal API service, not +the browser-facing host: + +```text +http://spritz-api..svc.cluster.local:8080/api +``` + +That avoids pushing service clients through browser auth gateways and public +edge routing. + +If external machine clients are needed later, they should use a deliberately +designed machine-auth path, not the browser login surface. + +## Permission Model + +This is the most important part of the design. + +### Core rule + +The external system may create a workspace for a human owner, but it may not +act as that owner later. + +That means Spritz should not use a broad permission such as "act as owner" or +"impersonate owner" for this workflow. + +Instead, the external system should have narrow, action-specific permissions. + +### Provisioner role + +The external system should receive a dedicated service role such as: + +- `spritz.provisioner` + +That role should allow only the minimum actions required for create flows. + +Recommended actions: + +- `spritz.instances.create` +- `spritz.instances.assign_owner` +- `spritz.presets.read` +- `spritz.instances.suggest_name` + +Not allowed by default: + +- `spritz.instances.update` +- `spritz.instances.delete` +- `spritz.instances.open` +- `spritz.instances.terminal` +- `spritz.instances.ssh` +- `spritz.instances.acp_connect` +- `spritz.instances.list_all` +- `spritz.instances.get_arbitrary` + +### Ownership is immutable + +Once a workspace is created: + +- `spec.owner.id` must be treated as immutable + +except for an explicit admin-only break-glass path. + +This prevents ownership hijacking and prevents a provisioner from creating a +workspace and reassigning it later. + +### Create-for-owner is create-time only + +The service principal may set the owner only during the create operation. + +That right must not imply any later rights over the created object. + +### Separate actor from owner + +Every created workspace must record: + +- owner: the human who owns and uses the workspace, +- actor: the service principal that requested creation, +- source: the external integration or channel, +- request id: the external idempotency/request identifier. + +Recommended annotations: + +- `spritz.sh/actor.id` +- `spritz.sh/source` +- `spritz.sh/request-id` + +The actor must never replace the owner as the source of authorization for +normal use. + +## Provisioner Policy Model + +Permissions alone are not enough. The provisioner also needs policy +constraints. + +Provisioner policy should define: + +- allowed preset ids, +- allowed namespace(s), +- whether custom images are allowed, +- whether custom repos are allowed, +- maximum idle TTL, +- maximum hard TTL, +- maximum active workspaces per owner, +- maximum create rate per actor, +- maximum create rate per owner, +- optional repo allowlist or denylist, +- optional default preset if no preset is specified. + +This keeps the external system constrained even if it is compromised or +misconfigured. + +### Prefer presets over arbitrary images + +The default provisioner policy should deny arbitrary images and require preset +selection. + +That keeps runtime selection auditable and keeps environment-specific wiring +inside Spritz deployment overlays rather than inside the bot. + +## API Model + +### Treat create as a first-class provisioning method + +The create surface should be treated as a provisioning API contract, not merely +as a thin CRD write endpoint. + +The API should: + +- validate caller type and policy, +- normalize preset defaults, +- generate names, +- apply lifecycle limits, +- write audit metadata, +- return canonical URLs and lifecycle metadata, +- enforce idempotency. + +This can keep the existing `POST /api/spritzes` path if desired, but its +behavior should be explicitly treated as a provisioning contract rather than a +raw object mirror. + +### Create request contract + +For external provisioners, the preferred create request shape is: + +- `ownerId` +- `presetId` +- `name` or none +- `namePrefix` or none +- `idleTTL` +- `ttl` (hard maximum lifetime) +- `repo` fields only if policy allows them +- `namespace` only if policy allows it +- `idempotencyKey` +- optional source metadata + +The request should remain high-level and policy-friendly. + +The provisioner should not need to send: + +- raw image refs, +- raw secret refs, +- cluster-specific ingress settings, +- backend-specific env wiring. + +### Create response contract + +The create response should include everything the external system needs to hand +the result back to the user without a follow-up read. + +Recommended response fields: + +- workspace name, +- owner id, +- actor id, +- namespace, +- preset id, +- canonical access URL, +- optional chat URL, +- current phase/status, +- created at, +- idle TTL, +- max TTL, +- idle expiry timestamp, +- hard expiry timestamp, +- idempotency key, +- whether the response was newly created or replayed from idempotency state. + +This avoids granting broad read permissions to the external system after +creation. + +### Idempotency is required + +External systems retry. Create must be idempotent. + +The create API should require: + +- `idempotencyKey` + +The same actor submitting the same idempotency key should get the same +provisioning result rather than a second workspace. + +Typical external ids include: + +- chat interaction ids, +- request ids, +- message ids, +- workflow execution ids. + +## `spz` CLI Model + +`spz` should become the official external machine client for Spritz. + +### Why reuse `spz` + +`spz` already exists and already behaves like a thin HTTP client over the +Spritz API. + +Reusing it avoids: + +- a second CLI, +- duplicated auth behavior, +- duplicated request formatting, +- duplicated output conventions. + +### Required `spz` behavior + +`spz` should support service-principal usage cleanly: + +- bearer token auth, +- machine-readable JSON output, +- no interactive prompts in automation mode, +- no local business rule duplication, +- stable exit codes. + +### Recommended CLI shape + +Examples: + +```bash +spz create \ + --owner-id user-123 \ + --preset openclaw \ + --idle-ttl 24h \ + --ttl 168h \ + --idempotency-key req-abc \ + --json +``` + +```bash +spz suggest-name --preset openclaw --json +``` + +The CLI should also support: + +- `--api-url` +- `--token` +- `--namespace` when allowed by policy +- `--repo` and `--branch` only if the provisioner policy permits them + +The CLI should not construct canonical URLs or infer authorization semantics on +its own. + +## URL Model + +Spritz must own the canonical access URL. + +External systems must not build it locally from host assumptions. + +The API should derive the URL from deployment configuration and return it in the +create response. + +This keeps all clients consistent across: + +- staging and production, +- host changes, +- route changes, +- gateway or auth changes. + +The same model applies to: + +- workspace open URLs, +- chat URLs, +- any future terminal or deep-link URLs. + +## Lifecycle Model + +Every externally provisioned workspace should support two lifetime controls. + +### Idle TTL + +Delete the workspace after a period of inactivity. + +Example: + +- `idleTTL = 24h` + +### Hard maximum TTL + +Delete the workspace after a maximum lifetime regardless of activity. + +Example: + +- `maxTTL = 168h` (`7d`) + +The system should enforce both. + +### Data model + +The clean long-term model is: + +- `spec.lifecycle.idleTTL` +- `spec.lifecycle.maxTTL` +- `status.lastActivityAt` +- `status.idleExpiresAt` +- `status.maxExpiresAt` +- `status.lifecyclePhase` +- `status.lifecycleReason` + +The reaper/controller should evaluate: + +- delete if `now - lastActivityAt > idleTTL` +- delete if `now - createdAt > maxTTL` + +### Default policy + +For external provisioners, defaults should be server-owned. + +Recommended defaults: + +- idle TTL default: `24h` +- hard maximum TTL default: `7d` + +Provisioner policy may only tighten those values, not loosen them beyond the +configured maximums. + +## Activity Model + +Idle expiry only works if activity is defined centrally and consistently. + +Spritz should update `lastActivityAt` when it observes real user activity such +as: + +- ACP prompt submission, +- ACP conversation activity that represents user interaction, +- terminal input activity, +- SSH session activity, +- other explicit interactive control-plane actions. + +Spritz should not treat these as activity: + +- health checks, +- metadata refresh, +- ACP capability probes, +- page loads with no real user interaction, +- idempotency lookups. + +This logic must live in one canonical owner, ideally the API/control plane, not +scattered across multiple clients. + +## Naming Model + +Names should remain backend-owned and deterministic. + +If no explicit name is supplied: + +- Spritz should generate a name, +- the name should be prefixed from the preset/image slug, +- the name should remain DNS-safe and unique. + +Examples: + +- `openclaw-tide-wind` +- `claude-code-quiet-harbor` + +External systems may request a name suggestion, but they should not own the +allocation logic. + +## Quotas and Abuse Controls + +Thinking like a large production platform means quotas are mandatory. + +Recommended controls: + +- max active workspaces per owner, +- max creates per owner per time window, +- max creates per service principal per time window, +- optional org/team quotas, +- preset-specific quotas if needed later. + +This prevents: + +- duplicate retry storms, +- external bot abuse, +- user-specific resource explosions. + +## Audit Model + +Every create request should be auditable. + +Audit records should include: + +- actor principal id, +- actor principal kind, +- owner id, +- preset id, +- namespace, +- idle TTL, +- hard TTL, +- source, +- idempotency key, +- result, +- created workspace name, +- canonical access URL, +- policy decisions that affected the request. + +Expiry-driven deletion should also be auditable, including: + +- reason: idle expiry or hard expiry, +- actor: system lifecycle controller, +- original owner, +- original actor if available. + +## Service Principal Representation + +Spritz should treat service principals as a first-class principal kind, not just +as "non-admin bearer callers". + +The long-term principal model should include: + +- `kind`: `human | service | admin` +- `subject` +- `issuer` +- `scopes` +- optional policy binding reference + +This keeps authorization explicit and avoids hidden behavior based only on +caller id string matching. + +## Deployment Model for External Systems + +The preferred deployment path is: + +- package `spz` into the external system image, +- inject credentials at runtime, +- call the internal Spritz API service, +- never bake credentials into the image. + +Credentials should be injected via: + +- secrets, +- workload identity, +- or another deployment-native credential mechanism. + +The external system should not need: + +- Kubernetes credentials, +- CRD write access, +- direct access to workspace pods, +- browser cookies, +- access through the browser login host. + +## End-to-End Flow + +The full target flow is: + +1. A user asks an external system to create a workspace. +2. The external system resolves that user to a stable Spritz owner id. +3. The external system runs `spz create` with: + - owner id, + - preset id, + - idle TTL, + - hard TTL, + - idempotency key. +4. `spz` calls the Spritz API with the service principal token. +5. Spritz validates: + - the caller is a service principal, + - the caller has provisioner permissions, + - the caller may assign the requested owner at create time, + - preset and lifecycle policy are allowed, + - quota and rate-limit checks pass. +6. Spritz creates the workspace with: + - human owner, + - service actor audit metadata, + - canonical lifecycle fields, + - canonical name and URLs. +7. Spritz returns the creation response including the access URL. +8. The external system gives that URL back to the user. +9. The user visits the URL and logs in through the normal Spritz browser auth + path. +10. From that point on, the user uses the workspace as its owner, and the + external system has no lifecycle control over it. + +## Validation Criteria + +The design is correct only if all of the following are true: + +- an external provisioner can create a workspace for a human user, +- the created workspace is owned by the human user, +- the provisioner cannot later edit or delete it, +- the provisioner does not need Kubernetes access, +- the provisioner does not construct access URLs locally, +- the same create request with the same idempotency key does not create + duplicates, +- idle TTL and hard TTL are enforced centrally, +- activity updates are not triggered by probes or passive page loads, +- audit records clearly distinguish owner from actor, +- policy is evaluated over presets and lifecycle inputs, not raw image strings, +- browser auth and service auth remain separate and predictable. + +## Implementation Direction + +The clean implementation sequence is: + +1. Treat `spz` as the official external machine client and add the missing + service-principal capabilities there. +2. Add first-class service principal support and action-based authorization in + the Spritz API. +3. Add provisioner policy configuration and enforce it at create time. +4. Make the create response return canonical URLs and lifecycle metadata. +5. Add required idempotency for external provisioners. +6. Add lifecycle fields, activity tracking, and a reaper/controller. +7. Add quota enforcement and structured audit logging. + +## References + +- `README.md` +- `docs/2026-02-24-portable-authentication-and-account-architecture.md` +- `docs/2026-02-24-simplest-spritz-deployment-spec.md` +- `docs/2026-03-10-acp-adapter-and-runtime-target-architecture.md` +- `cli/src/index.ts` From 37d3dcb1e0ad0b85dd31442fb90bf0a22a7052ff Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 13:17:50 +0100 Subject: [PATCH 02/21] refactor(terminology): replace spritz kind fields with type --- api/random_name.go | 2 +- ...oner-and-service-principal-architecture.md | 6 +- images/examples/openclaw/acp-wrapper.mjs | 2 +- ui/public/acp-page-cache.test.mjs | 12 +-- ui/public/acp-page.js | 12 +-- ui/public/acp-render.js | 98 +++++++++---------- ui/public/acp-render.test.mjs | 10 +- ui/public/app.js | 12 +-- ui/public/styles.css | 4 +- 9 files changed, 79 insertions(+), 79 deletions(-) diff --git a/api/random_name.go b/api/random_name.go index a5faf8d..9302aa4 100644 --- a/api/random_name.go +++ b/api/random_name.go @@ -30,7 +30,7 @@ var spritzNameAdjectives = []string{ "good", "grand", "keen", - "kind", + "bright", "lucky", "marine", "mellow", diff --git a/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md b/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md index 25f3739..fdeb089 100644 --- a/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md +++ b/docs/2026-03-11-external-provisioner-and-service-principal-architecture.md @@ -578,7 +578,7 @@ Every create request should be auditable. Audit records should include: - actor principal id, -- actor principal kind, +- actor principal type, - owner id, - preset id, - namespace, @@ -600,12 +600,12 @@ Expiry-driven deletion should also be auditable, including: ## Service Principal Representation -Spritz should treat service principals as a first-class principal kind, not just +Spritz should treat service principals as a first-class principal type, not just as "non-admin bearer callers". The long-term principal model should include: -- `kind`: `human | service | admin` +- `type`: `human | service | admin` - `subject` - `issuer` - `scopes` diff --git a/images/examples/openclaw/acp-wrapper.mjs b/images/examples/openclaw/acp-wrapper.mjs index 4d2db01..990862c 100644 --- a/images/examples/openclaw/acp-wrapper.mjs +++ b/images/examples/openclaw/acp-wrapper.mjs @@ -105,7 +105,7 @@ function buildHistoryToolCallUpdate(item) { title: `${toolName}`, status: "completed", rawInput, - kind: toolName, + type: toolName, }; } diff --git a/ui/public/acp-page-cache.test.mjs b/ui/public/acp-page-cache.test.mjs index da15c03..2496b5b 100644 --- a/ui/public/acp-page-cache.test.mjs +++ b/ui/public/acp-page-cache.test.mjs @@ -129,7 +129,7 @@ test('ACP page restores cached transcript when revisiting a conversation', async messages: [ { id: 'assistant-1', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', @@ -244,7 +244,7 @@ test('ACP page purges pre-cutover cached transcripts after the namespace cutover messages: [ { id: 'assistant-1', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', @@ -362,7 +362,7 @@ test('ACP page drops cached transcripts that contain raw HTML error documents', messages: [ { id: 'assistant-html', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', @@ -488,7 +488,7 @@ test('ACP page replaces cached transcript with backend replay during bootstrap', messages: [ { id: 'assistant-cached', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', @@ -603,7 +603,7 @@ test('ACP page clears cached transcript when backend replay returns no transcrip messages: [ { id: 'assistant-cached', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', @@ -713,7 +713,7 @@ test('ACP page drops cached HTML error documents during transcript restore', asy messages: [ { id: 'assistant-html', - kind: 'assistant', + type: 'assistant', title: '', status: '', tone: '', diff --git a/ui/public/acp-page.js b/ui/public/acp-page.js index 6f3503f..792a138 100644 --- a/ui/public/acp-page.js +++ b/ui/public/acp-page.js @@ -130,15 +130,15 @@ return htmlError?.text || raw; } - function showACPToast(page, message, kind = 'error') { + function showACPToast(page, message, type = 'error') { const normalized = normalizeACPToastMessage(message); if (!normalized) return; if (typeof page.deps.showToast === 'function') { - page.deps.showToast(normalized, kind); + page.deps.showToast(normalized, type); return; } if (typeof page.deps.showNotice === 'function') { - page.deps.showNotice(normalized, kind); + page.deps.showNotice(normalized, type); } } @@ -293,9 +293,9 @@ return page.transcript.messages.length > 0; } - function reportACPError(page, err, fallback, kind = 'error') { + function reportACPError(page, err, fallback, type = 'error') { if (isBenignACPError(err)) return; - showACPToast(page, err?.message || fallback, kind); + showACPToast(page, err?.message || fallback, type); } function getAgentTitle(agent) { @@ -691,7 +691,7 @@ historical: !page.bootstrapComplete, }); if (result?.toast?.message) { - showACPToast(page, result.toast.message, result.toast.kind || 'error'); + showACPToast(page, result.toast.message, result.toast.type || 'error'); } if (result?.conversationTitle) { patchSelectedConversation(page, { title: result.conversationTitle }).catch(() => {}); diff --git a/ui/public/acp-render.js b/ui/public/acp-render.js index 093f2cf..250e7be 100644 --- a/ui/public/acp-render.js +++ b/ui/public/acp-render.js @@ -35,7 +35,7 @@ function rebuildToolCallIndex(transcript) { transcript.toolCallIndex = new Map(); transcript.messages.forEach((message, index) => { - if (message?.kind === 'tool' && message.toolCallId) { + if (message?.type === 'tool' && message.toolCallId) { transcript.toolCallIndex.set(message.toolCallId, index); } }); @@ -47,7 +47,7 @@ messages: Array.isArray(transcript?.messages) ? transcript.messages.map((message) => ({ id: message.id || '', - kind: message.kind || 'system', + type: message.type || 'system', title: message.title || '', status: message.status || '', tone: message.tone || '', @@ -197,12 +197,12 @@ }; } - function sanitizeHydratedBlock(kind, block) { + function sanitizeHydratedBlock(type, block) { if (!block || typeof block !== 'object') return null; if (block.type === 'text') { const htmlError = detectHtmlErrorDocument(block.text); if (htmlError) { - return kind === 'tool' + return type === 'tool' ? { ...block, type: 'details', title: 'Result', text: htmlError.text, open: false } : null; } @@ -219,7 +219,7 @@ } function sanitizeHydratedMessage(message) { - const kind = message?.kind || 'system'; + const type = message?.type || 'system'; const hadHtmlError = Array.isArray(message?.blocks) ? message.blocks.some((block) => { if (!block || typeof block !== 'object') return false; @@ -228,21 +228,21 @@ }) : false; const blocks = Array.isArray(message?.blocks) - ? message.blocks.map((block) => sanitizeHydratedBlock(kind, block)).filter(Boolean) + ? message.blocks.map((block) => sanitizeHydratedBlock(type, block)).filter(Boolean) : []; - if (!blocks.length && (kind === 'assistant' || kind === 'user')) { + if (!blocks.length && (type === 'assistant' || type === 'user')) { return null; } return { - id: message?.id || createId(kind || 'message'), - kind, + id: message?.id || createId(type || 'message'), + type, title: message?.title || '', status: - kind === 'tool' && hadHtmlError && (!message?.status || message.status === 'completed') + type === 'tool' && hadHtmlError && (!message?.status || message.status === 'completed') ? 'failed' : message?.status || '', tone: - kind === 'tool' && hadHtmlError + type === 'tool' && hadHtmlError ? 'danger' : message?.tone || '', meta: message?.meta || '', @@ -267,8 +267,8 @@ function pushMessage(transcript, message) { transcript.messages.push({ - id: message.id || createId(message.kind || 'message'), - kind: message.kind, + id: message.id || createId(message.type || 'message'), + type: message.type, title: message.title || '', status: message.status || '', tone: message.tone || '', @@ -287,12 +287,12 @@ return [{ type: 'text', text: normalized }]; } - function appendHistoricalText(transcript, kind, text, messageKey = '') { + function appendHistoricalText(transcript, type, text, messageKey = '') { const value = String(text || ''); if (!value) return; const normalizedKey = String(messageKey || '').trim(); const last = transcript.messages[transcript.messages.length - 1]; - if (normalizedKey && last && last.kind === kind && last.historyMessageId === normalizedKey) { + if (normalizedKey && last && last.type === type && last.historyMessageId === normalizedKey) { const textBlock = last.blocks.find((block) => block.type === 'text'); if (textBlock) { textBlock.text += value; @@ -302,18 +302,18 @@ return; } pushMessage(transcript, { - kind, + type, streaming: false, historyMessageId: normalizedKey, blocks: createTextBlocks(value), }); } - function appendStreamingText(transcript, kind, text) { + function appendStreamingText(transcript, type, text) { const chunk = String(text || ''); if (!chunk) return; const last = transcript.messages[transcript.messages.length - 1]; - if (last && last.kind === kind && last.streaming) { + if (last && last.type === type && last.streaming) { const textBlock = last.blocks.find((block) => block.type === 'text'); if (textBlock) { textBlock.text += chunk; @@ -323,7 +323,7 @@ return; } pushMessage(transcript, { - kind, + type, streaming: true, blocks: createTextBlocks(chunk), }); @@ -331,7 +331,7 @@ function finalizeStreaming(transcript) { transcript.messages.forEach((message) => { - if (message.kind === 'assistant' || message.kind === 'user') { + if (message.type === 'assistant' || message.type === 'user') { message.streaming = false; } }); @@ -372,11 +372,11 @@ ? 'failed' : update.status || existing?.status || 'pending'; const next = { - kind: 'tool', + type: 'tool', title, status, tone: status === 'completed' ? 'success' : status === 'failed' ? 'danger' : 'info', - meta: update.kind || existing?.meta || '', + meta: update.type || existing?.meta || '', blocks: normalizedBlocks.blocks, toolCallId, }; @@ -408,22 +408,22 @@ return entries; } - function humanizeUpdateKind(kind) { - return String(kind || 'Update') + function humanizeUpdateType(type) { + return String(type || 'Update') .replace(/_/g, ' ') .replace(/\b\w/g, (match) => match.toUpperCase()); } function applySessionUpdate(transcript, update, options = {}) { - const kind = update?.sessionUpdate || 'unknown'; + const type = update?.sessionUpdate || 'unknown'; const historical = Boolean(options.historical); - if (kind === 'user_message_chunk') { + if (type === 'user_message_chunk') { const text = extractACPText(update.content); const htmlError = detectHtmlErrorDocument(text); if (htmlError) { return { toast: { - kind: 'error', + type: 'error', message: htmlError.text, }, }; @@ -440,13 +440,13 @@ } return null; } - if (kind === 'agent_message_chunk') { + if (type === 'agent_message_chunk') { const text = extractACPText(update.content); const htmlError = detectHtmlErrorDocument(text); if (htmlError) { return { toast: { - kind: 'error', + type: 'error', message: htmlError.text, }, }; @@ -463,27 +463,27 @@ } return null; } - if (kind === 'tool_call' || kind === 'tool_call_update') { + if (type === 'tool_call' || type === 'tool_call_update') { const toolResult = upsertToolCall(transcript, update); if (!historical && toolResult?.isError && toolResult.summary) { return { toast: { - kind: 'error', + type: 'error', message: toolResult.summary, }, }; } return null; } - if (kind === 'available_commands_update') { + if (type === 'available_commands_update') { transcript.availableCommands = Array.isArray(update.availableCommands) ? update.availableCommands : []; return null; } - if (kind === 'current_mode_update') { + if (type === 'current_mode_update') { transcript.currentMode = String(update.mode || update.currentMode || '').trim(); return null; } - if (kind === 'usage_update') { + if (type === 'usage_update') { transcript.usage = { label: String(update.label || 'Usage'), used: typeof update.used === 'number' ? update.used : null, @@ -494,9 +494,9 @@ } return null; } - if (kind === 'plan') { + if (type === 'plan') { pushMessage(transcript, { - kind: 'plan', + type: 'plan', title: 'Plan', blocks: [ { @@ -507,14 +507,14 @@ }); return null; } - if (kind === 'session_info_update') { + if (type === 'session_info_update') { return { conversationTitle: update?.title || update?.sessionInfo?.title || '', }; } - if (kind === 'config_option_update') { + if (type === 'config_option_update') { pushMessage(transcript, { - kind: 'system', + type: 'system', title: 'Setting updated', tone: 'muted', blocks: [ @@ -530,8 +530,8 @@ return null; } pushMessage(transcript, { - kind: 'system', - title: humanizeUpdateKind(kind), + type: 'system', + title: humanizeUpdateType(type), tone: 'muted', blocks: [ { @@ -644,17 +644,17 @@ function renderMessage(message) { const article = document.createElement('article'); - article.className = `acp-message acp-message--${message.kind}`; - article.dataset.kind = message.kind; + article.className = `acp-message acp-message--${message.type}`; + article.dataset.type = message.type; const bubble = document.createElement('div'); - bubble.className = message.kind === 'user' || message.kind === 'assistant' ? 'acp-bubble' : 'acp-event-card'; + bubble.className = message.type === 'user' || message.type === 'assistant' ? 'acp-bubble' : 'acp-event-card'; if (message.title || message.status || message.meta) { const header = document.createElement('div'); header.className = 'acp-message-meta'; const title = document.createElement('strong'); - title.textContent = message.title || (message.kind === 'assistant' ? 'Assistant' : message.kind === 'user' ? 'You' : 'Update'); + title.textContent = message.title || (message.type === 'assistant' ? 'Assistant' : message.type === 'user' ? 'You' : 'Update'); header.appendChild(title); if (message.status || message.meta) { const meta = document.createElement('div'); @@ -690,7 +690,7 @@ for (let index = transcript.messages.length - 1; index >= 0; index -= 1) { const message = transcript.messages[index]; if (!message) continue; - if (message.kind === 'assistant' || message.kind === 'user') { + if (message.type === 'assistant' || message.type === 'user') { const textBlock = message.blocks.find((block) => block.type === 'text' && block.text); if (textBlock) { const htmlError = detectHtmlErrorDocument(textBlock.text); @@ -699,7 +699,7 @@ } } } - if (message.kind === 'tool') { + if (message.type === 'tool') { const resultBlock = message.blocks.find((block) => block.type === 'details' && block.title === 'Result' && block.text); const htmlError = detectHtmlErrorDocument(resultBlock?.text); if (htmlError) { @@ -719,14 +719,14 @@ } function isTranscriptBearingUpdate(update) { - const kind = update?.sessionUpdate || ''; + const type = update?.sessionUpdate || ''; return ![ '', 'available_commands_update', 'current_mode_update', 'usage_update', 'session_info_update', - ].includes(kind); + ].includes(type); } global.SpritzACPRender = { diff --git a/ui/public/acp-render.test.mjs b/ui/public/acp-render.test.mjs index 872e6ad..d2a87c7 100644 --- a/ui/public/acp-render.test.mjs +++ b/ui/public/acp-render.test.mjs @@ -90,7 +90,7 @@ test('ACP render adapter keeps commands out of transcript and upserts tool cards assert.equal(transcript.messages.length, 1); const toolCard = transcript.messages[0]; - assert.equal(toolCard.kind, 'tool'); + assert.equal(toolCard.type, 'tool'); assert.equal(toolCard.title, 'Search workspace'); assert.equal(toolCard.status, 'completed'); assert.equal(toolCard.blocks.some((block) => block.type === 'details' && block.title === 'Input'), true); @@ -147,7 +147,7 @@ test('ACP render adapter drops HTML error pages from assistant text updates', () }); assert.equal(transcript.messages.length, 0); - assert.equal(result?.toast?.kind, 'error'); + assert.equal(result?.toast?.type, 'error'); assert.match(result?.toast?.message || '', /502/i); assert.equal((result?.toast?.message || '').includes(''), false); }); @@ -156,7 +156,7 @@ test('ACP render adapter sanitizes raw HTML error pages at render time', () => { const ACPRender = loadRenderModule(); const node = ACPRender.renderMessage({ - kind: 'assistant', + type: 'assistant', blocks: [ { type: 'text', @@ -198,10 +198,10 @@ test('ACP render adapter treats bootstrap replay chunks as historical messages', ); assert.equal(transcript.messages.length, 2); - assert.equal(transcript.messages[0].kind, 'user'); + assert.equal(transcript.messages[0].type, 'user'); assert.equal(transcript.messages[0].streaming, false); assert.equal(transcript.messages[0].blocks[0].text, 'Earlier user message'); - assert.equal(transcript.messages[1].kind, 'assistant'); + assert.equal(transcript.messages[1].type, 'assistant'); assert.equal(transcript.messages[1].streaming, false); assert.equal(transcript.messages[1].blocks[0].text, 'Earlier assistant message'); }); diff --git a/ui/public/app.js b/ui/public/app.js index c223ce1..e43033b 100644 --- a/ui/public/app.js +++ b/ui/public/app.js @@ -463,28 +463,28 @@ function isJSend(payload) { return payload && typeof payload === 'object' && typeof payload.status === 'string'; } -function showNotice(message, kind = 'error') { +function showNotice(message, type = 'error') { if (!noticeEl) return; if (!message) { noticeEl.hidden = true; noticeEl.textContent = ''; - noticeEl.dataset.kind = ''; + noticeEl.dataset.type = ''; return; } noticeEl.hidden = false; noticeEl.textContent = message; - noticeEl.dataset.kind = kind; + noticeEl.dataset.type = type; } function clearNotice() { showNotice(''); } -function showToast(message, kind = 'error', options = {}) { +function showToast(message, type = 'error', options = {}) { if (!toastRegionEl || !message) return; const toast = document.createElement('div'); toast.className = 'toast'; - toast.dataset.kind = kind; + toast.dataset.type = type; const copy = document.createElement('div'); copy.className = 'toast-copy'; @@ -510,7 +510,7 @@ function showToast(message, kind = 'error', options = {}) { toast.append(copy, dismiss); toastRegionEl.appendChild(toast); - const durationMs = Number(options.durationMs) > 0 ? Number(options.durationMs) : kind === 'error' ? 5200 : 3600; + const durationMs = Number(options.durationMs) > 0 ? Number(options.durationMs) : type === 'error' ? 5200 : 3600; timeoutId = setTimeout(removeToast, durationMs); } diff --git a/ui/public/styles.css b/ui/public/styles.css index 799fdbc..ca05cb3 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -59,7 +59,7 @@ header p { color: #8a1f1f; } -.notice[data-kind="info"] { +.notice[data-type="info"] { background: rgba(55, 130, 255, 0.12); border-color: rgba(55, 130, 255, 0.3); color: #1c3f8a; @@ -90,7 +90,7 @@ header p { pointer-events: auto; } -.toast[data-kind="info"] { +.toast[data-type="info"] { border-color: rgba(88, 138, 255, 0.34); } From 7066adbaac82ef955477ef6455bff53fe757a630 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 14:42:22 +0100 Subject: [PATCH 03/21] feat(provisioning): add service principal provisioner flow --- api/acp_agents.go | 5 +- api/acp_bootstrap.go | 3 + api/acp_conversations.go | 18 +- api/acp_gateway.go | 41 +- api/acp_helpers.go | 2 +- api/acp_test.go | 5 +- api/activity.go | 34 ++ api/auth.go | 171 ++++++- api/auth_middleware_test.go | 46 ++ api/authorization.go | 68 +++ api/main.go | 165 +++++-- api/main_create_owner_test.go | 160 ++++++- api/main_owner_visibility_test.go | 40 +- api/provisioning.go | 552 ++++++++++++++++++++++ api/ssh_mint.go | 5 +- api/terminal.go | 5 +- api/terminal_sessions.go | 2 +- cli/src/index.ts | 79 +++- cli/test/provisioner-create.test.ts | 85 ++++ crd/generated/spritz.sh_spritzes.yaml | 11 + helm/spritz/templates/api-deployment.yaml | 52 ++ helm/spritz/values.yaml | 26 +- operator/api/v1/access_url.go | 45 ++ operator/api/v1/lifecycle.go | 57 +++ operator/api/v1/spritz_types.go | 5 + operator/controllers/lifecycle_test.go | 59 +++ operator/controllers/spritz_controller.go | 69 +-- scripts/verify-helm.sh | 4 + 28 files changed, 1672 insertions(+), 142 deletions(-) create mode 100644 api/activity.go create mode 100644 api/authorization.go create mode 100644 api/provisioning.go create mode 100644 cli/test/provisioner-create.test.ts create mode 100644 operator/api/v1/access_url.go create mode 100644 operator/api/v1/lifecycle.go create mode 100644 operator/controllers/lifecycle_test.go diff --git a/api/acp_agents.go b/api/acp_agents.go index 1286217..e1d88ee 100644 --- a/api/acp_agents.go +++ b/api/acp_agents.go @@ -32,10 +32,13 @@ func (s *server) listACPAgents(c echo.Context) error { if err := s.client.List(c.Request().Context(), list, opts...); err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } records := make([]acpAgentResponse, 0, len(list.Items)) for _, item := range list.Items { - if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil { continue } if !spritzSupportsACPConversations(&item) { diff --git a/api/acp_bootstrap.go b/api/acp_bootstrap.go index 01dae78..727837e 100644 --- a/api/acp_bootstrap.go +++ b/api/acp_bootstrap.go @@ -296,6 +296,9 @@ func (s *server) bootstrapACPConversation(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.requestNamespace(c) if namespace == "" { namespace = "default" diff --git a/api/acp_conversations.go b/api/acp_conversations.go index 3da6a0f..3c63e31 100644 --- a/api/acp_conversations.go +++ b/api/acp_conversations.go @@ -36,6 +36,9 @@ func (s *server) listACPConversations(c echo.Context) error { namespace := s.requestNamespace(c) spritzName := strings.TrimSpace(c.QueryParam("spritz")) + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } list := &spritzv1.SpritzConversationList{} opts := []client.ListOption{} if namespace != "" { @@ -45,7 +48,7 @@ func (s *server) listACPConversations(c echo.Context) error { if spritzName != "" { labels[acpConversationSpritzLabelKey] = spritzName } - if s.auth.enabled() && !principal.IsAdmin { + if s.auth.enabled() { labels[acpConversationOwnerLabelKey] = ownerLabelValue(principal.ID) } opts = append(opts, client.MatchingLabels(labels)) @@ -55,7 +58,7 @@ func (s *server) listACPConversations(c echo.Context) error { items := make([]spritzv1.SpritzConversation, 0, len(list.Items)) for _, item := range list.Items { - if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil { continue } if spritzName != "" && item.Spec.SpritzName != spritzName { @@ -75,6 +78,9 @@ func (s *server) getACPConversation(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id")) if err != nil { return s.writeACPConversationError(c, err) @@ -90,6 +96,9 @@ func (s *server) createACPConversation(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } var body createACPConversationRequest if err := decodeACPBody(c, &body); err != nil { @@ -135,6 +144,9 @@ func (s *server) updateACPConversation(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id")) if err != nil { return s.writeACPConversationError(c, err) @@ -189,7 +201,7 @@ func (s *server) getAuthorizedConversation(ctx context.Context, principal princi if err := s.client.Get(ctx, clientKey(namespace, name), conversation); err != nil { return nil, err } - if s.auth.enabled() && !principal.IsAdmin && conversation.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, conversation.Spec.Owner.ID, s.auth.enabled()); err != nil { return nil, errForbidden } return conversation, nil diff --git a/api/acp_gateway.go b/api/acp_gateway.go index fb97059..241afdd 100644 --- a/api/acp_gateway.go +++ b/api/acp_gateway.go @@ -1,8 +1,11 @@ package main import ( + "encoding/json" "net/http" + "strings" "sync" + "time" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -16,6 +19,9 @@ func (s *server) openACPConversationConnection(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.requestNamespace(c) conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, namespace, c.Param("id")) if err != nil { @@ -47,10 +53,22 @@ func (s *server) openACPConversationConnection(c echo.Context) error { _ = workspaceConn.Close() }() - return proxyWebSockets(browserConn, workspaceConn) + return proxyWebSockets( + browserConn, + workspaceConn, + func(payload []byte) { + if !isACPPromptMessage(payload) { + return + } + if err := s.markSpritzActivity(c.Request().Context(), spritz.Namespace, spritz.Name, time.Now()); err != nil { + c.Logger().Warnf("failed to record acp activity for %s/%s: %v", spritz.Namespace, spritz.Name, err) + } + }, + nil, + ) } -func proxyWebSockets(left, right *websocket.Conn) error { +func proxyWebSockets(left, right *websocket.Conn, onLeftMessage, onRightMessage func([]byte)) error { errCh := make(chan error, 2) closeOnce := sync.Once{} closeBoth := func() { @@ -58,8 +76,8 @@ func proxyWebSockets(left, right *websocket.Conn) error { _ = right.Close() } - go proxyWebSocketDirection(left, right, errCh) - go proxyWebSocketDirection(right, left, errCh) + go proxyWebSocketDirection(left, right, errCh, onLeftMessage) + go proxyWebSocketDirection(right, left, errCh, onRightMessage) err := <-errCh closeOnce.Do(closeBoth) @@ -69,16 +87,29 @@ func proxyWebSockets(left, right *websocket.Conn) error { return err } -func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error) { +func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error, onMessage func([]byte)) { for { msgType, payload, err := src.ReadMessage() if err != nil { errCh <- err return } + if onMessage != nil { + onMessage(payload) + } if err := dst.WriteMessage(msgType, payload); err != nil { errCh <- err return } } } + +func isACPPromptMessage(payload []byte) bool { + var message struct { + Method string `json:"method"` + } + if err := json.Unmarshal(payload, &message); err != nil { + return false + } + return strings.TrimSpace(message.Method) == "session/prompt" +} diff --git a/api/acp_helpers.go b/api/acp_helpers.go index eb9ef59..279aaac 100644 --- a/api/acp_helpers.go +++ b/api/acp_helpers.go @@ -166,7 +166,7 @@ func (s *server) getAuthorizedSpritz(ctx context.Context, principal principal, n } return nil, err } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { return nil, errForbidden } return spritz, nil diff --git a/api/acp_test.go b/api/acp_test.go index 147f072..7f0fa68 100644 --- a/api/acp_test.go +++ b/api/acp_test.go @@ -41,8 +41,9 @@ func newACPTestServer(t *testing.T, objects ...client.Object) *server { scheme: scheme, namespace: "spritz-test", auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerDefaultType: principalTypeHuman, }, internalAuth: internalAuthConfig{enabled: false}, acp: acpConfig{ diff --git a/api/activity.go b/api/activity.go new file mode 100644 index 0000000..1d30489 --- /dev/null +++ b/api/activity.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + + spritzv1 "spritz.sh/operator/api/v1" +) + +func (s *server) markSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error { + if strings.TrimSpace(name) == "" { + return nil + } + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &spritzv1.Spritz{} + if err := s.client.Get(ctx, clientKey(namespace, name), current); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + timestamp := metav1.NewTime(when.UTC()) + if current.Status.LastActivityAt != nil && !current.Status.LastActivityAt.Time.Before(timestamp.Time) { + return nil + } + current.Status.LastActivityAt = ×tamp + return s.client.Status().Update(ctx, current) + }) +} diff --git a/api/auth.go b/api/auth.go index abb6652..41d45e1 100644 --- a/api/auth.go +++ b/api/auth.go @@ -45,6 +45,9 @@ type authConfig struct { headerID string headerEmail string headerTeams string + headerType string + headerScopes string + headerDefaultType principalType adminIDs map[string]struct{} adminTeams map[string]struct{} bearerIntrospectionURL string @@ -56,6 +59,9 @@ type authConfig struct { bearerIDPaths []string bearerEmailPaths []string bearerTeamsPaths []string + bearerTypePaths []string + bearerScopesPaths []string + bearerDefaultType principalType bearerAuthorizationHeader string bearerJWKSURL string bearerJWKSIssuer string @@ -76,15 +82,30 @@ type principal struct { ID string Email string Teams []string + Type principalType + Subject string + Issuer string + Scopes []string IsAdmin bool } +type principalType string + +const ( + principalTypeHuman principalType = "human" + principalTypeService principalType = "service" + principalTypeAdmin principalType = "admin" +) + func newAuthConfig() authConfig { return authConfig{ mode: normalizeAuthMode(os.Getenv("SPRITZ_AUTH_MODE")), headerID: envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id"), headerEmail: envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email"), headerTeams: envOrDefault("SPRITZ_AUTH_HEADER_TEAMS", "X-Spritz-User-Teams"), + headerType: envOrDefault("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type"), + headerScopes: envOrDefault("SPRITZ_AUTH_HEADER_SCOPES", "X-Spritz-Principal-Scopes"), + headerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_HEADER_DEFAULT_TYPE", string(principalTypeHuman)), principalTypeHuman), adminIDs: splitSet(os.Getenv("SPRITZ_AUTH_ADMIN_IDS")), adminTeams: splitSet(os.Getenv("SPRITZ_AUTH_ADMIN_TEAMS")), bearerIntrospectionURL: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL")), @@ -96,6 +117,9 @@ func newAuthConfig() authConfig { bearerIDPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_ID_PATHS"), []string{"sub"}), bearerEmailPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_EMAIL_PATHS"), []string{"email"}), bearerTeamsPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TEAMS_PATHS"), nil), + bearerTypePaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TYPE_PATHS"), nil), + bearerScopesPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS"), []string{"scope", "scopes", "scp"}), + bearerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_BEARER_DEFAULT_TYPE", string(principalTypeHuman)), principalTypeHuman), bearerAuthorizationHeader: envOrDefault("SPRITZ_AUTH_BEARER_HEADER", "Authorization"), bearerJWKSURL: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_JWKS_URL")), bearerJWKSIssuer: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_ISSUER")), @@ -129,6 +153,64 @@ func (a *authConfig) enabled() bool { return a.mode != authModeNone } +func normalizePrincipalType(raw string, fallback principalType) principalType { + switch principalType(strings.ToLower(strings.TrimSpace(raw))) { + case principalTypeHuman: + return principalTypeHuman + case principalTypeService: + return principalTypeService + case principalTypeAdmin: + return principalTypeAdmin + default: + return fallback + } +} + +func finalizePrincipal(id, email string, teams []string, subject, issuer string, principalTypeValue principalType, scopes []string, admin bool) principal { + isAdmin := admin || principalTypeValue == principalTypeAdmin + if subject == "" { + subject = id + } + if isAdmin { + principalTypeValue = principalTypeAdmin + } + return principal{ + ID: id, + Email: email, + Teams: teams, + Type: principalTypeValue, + Subject: subject, + Issuer: strings.TrimSpace(issuer), + Scopes: dedupeStrings(scopes), + IsAdmin: isAdmin, + } +} + +func (p principal) isHuman() bool { + return p.Type == principalTypeHuman +} + +func (p principal) isService() bool { + return p.Type == principalTypeService +} + +func (p principal) isAdminPrincipal() bool { + return p.IsAdmin || p.Type == principalTypeAdmin +} + +func (p principal) hasScope(scope string) bool { + scope = strings.TrimSpace(scope) + if scope == "" { + return false + } + for _, candidate := range p.Scopes { + if strings.EqualFold(strings.TrimSpace(candidate), scope) { + return true + } + } + return false +} + func (a *authConfig) principal(r *http.Request) (principal, error) { if !a.enabled() { return principal{}, nil @@ -142,23 +224,31 @@ func (a *authConfig) principal(r *http.Request) (principal, error) { } email := strings.TrimSpace(r.Header.Get(a.headerEmail)) teams := splitList(r.Header.Get(a.headerTeams)) - return principal{ - ID: id, - Email: email, - Teams: teams, - IsAdmin: a.isAdmin(id, teams), - }, nil + return finalizePrincipal( + id, + email, + teams, + id, + "", + normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), + splitList(r.Header.Get(a.headerScopes)), + a.isAdmin(id, teams), + ), nil case authModeAuto: id := strings.TrimSpace(r.Header.Get(a.headerID)) if id != "" { email := strings.TrimSpace(r.Header.Get(a.headerEmail)) teams := splitList(r.Header.Get(a.headerTeams)) - return principal{ - ID: id, - Email: email, - Teams: teams, - IsAdmin: a.isAdmin(id, teams), - }, nil + return finalizePrincipal( + id, + email, + teams, + id, + "", + normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), + splitList(r.Header.Get(a.headerScopes)), + a.isAdmin(id, teams), + ), nil } if a.bearerIntrospectionURL == "" && a.bearerJWKSURL == "" { return principal{}, errUnauthenticated @@ -251,13 +341,16 @@ func (a *authConfig) introspectToken(ctx context.Context, token string) (princip email := firstStringPath(payload, a.bearerEmailPaths) teams := firstStringListPath(payload, a.bearerTeamsPaths) - - return principal{ - ID: id, - Email: email, - Teams: teams, - IsAdmin: a.isAdmin(id, teams), - }, nil + return finalizePrincipal( + id, + email, + teams, + firstStringPath(payload, []string{"sub"}), + firstStringPath(payload, []string{"iss", "issuer"}), + normalizePrincipalType(firstStringPath(payload, a.bearerTypePaths), a.bearerDefaultType), + firstStringListPath(payload, a.bearerScopesPaths), + a.isAdmin(id, teams), + ), nil } func (a *authConfig) jwks() (*keyfunc.JWKS, error) { @@ -334,12 +427,16 @@ func (a *authConfig) principalFromJWT(ctx context.Context, token string) (princi } email := firstStringPath(claims, a.bearerEmailPaths) teams := firstStringListPath(claims, a.bearerTeamsPaths) - return principal{ - ID: id, - Email: email, - Teams: teams, - IsAdmin: a.isAdmin(id, teams), - }, nil + return finalizePrincipal( + id, + email, + teams, + firstStringPath(claims, []string{"sub"}), + firstStringPath(claims, []string{"iss", "issuer"}), + normalizePrincipalType(firstStringPath(claims, a.bearerTypePaths), a.bearerDefaultType), + firstStringListPath(claims, a.bearerScopesPaths), + a.isAdmin(id, teams), + ), nil } func verifyAudience(claims jwt.MapClaims, audiences []string) bool { @@ -513,6 +610,30 @@ func splitListOrDefault(value string, fallback []string) []string { return items } +func dedupeStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + func parseDurationEnv(key string, fallback time.Duration) time.Duration { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index be4cdd9..45ebee0 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -80,3 +80,49 @@ func TestAuthMiddlewareSetsPrincipal(t *testing.T) { t.Fatalf("expected email to be user@example.com, got %q", payload["email"]) } } + +func TestAuthMiddlewareSetsPrincipalTypeAndScopes(t *testing.T) { + t.Setenv("SPRITZ_AUTH_MODE", "header") + t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") + t.Setenv("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type") + t.Setenv("SPRITZ_AUTH_HEADER_SCOPES", "X-Spritz-Principal-Scopes") + + s := &server{auth: newAuthConfig()} + e := echo.New() + + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "id": p.ID, + "type": p.Type, + "scopes": p.Scopes, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeService) { + t.Fatalf("expected service principal type, got %#v", payload["type"]) + } + scopes, _ := payload["scopes"].([]any) + if len(scopes) != 2 { + t.Fatalf("expected two scopes, got %#v", payload["scopes"]) + } +} diff --git a/api/authorization.go b/api/authorization.go new file mode 100644 index 0000000..f7d5648 --- /dev/null +++ b/api/authorization.go @@ -0,0 +1,68 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +func ensureAuthenticated(principal principal, enabled bool) error { + if !enabled { + return nil + } + if stringsTrim(principal.ID) == "" { + return errUnauthenticated + } + return nil +} + +func authorizeHumanOwnedAccess(principal principal, ownerID string, enabled bool) error { + if !enabled { + return nil + } + if principal.isAdminPrincipal() { + return nil + } + if !principalCanAccessOwner(principal, ownerID) { + return errForbidden + } + return nil +} + +func authorizeHumanOnly(principal principal, enabled bool) error { + if !enabled { + return nil + } + if principal.isAdminPrincipal() { + return nil + } + if !principal.isHuman() { + return errForbidden + } + return nil +} + +func authorizeServiceAction(principal principal, scope string, enabled bool) error { + if !enabled { + return nil + } + if principal.isAdminPrincipal() { + return nil + } + if !principal.isService() { + return errForbidden + } + if !principal.hasScope(scope) { + return errForbidden + } + return nil +} + +func stringsTrim(value string) string { + return strings.TrimSpace(value) +} + +func writeForbidden(c echo.Context) error { + return writeError(c, http.StatusForbidden, "forbidden") +} diff --git a/api/main.go b/api/main.go index 5c391d6..163f65f 100644 --- a/api/main.go +++ b/api/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -41,6 +42,8 @@ type server struct { sshDefaults sshDefaults sshMintLimiter *sshMintLimiter acp acpConfig + presets presetCatalog + provisioners provisionerPolicy defaultMetadata map[string]string sharedMounts sharedMountsConfig sharedMountsStore *sharedMountsStore @@ -80,6 +83,12 @@ func main() { ingressDefaults := newIngressDefaults() terminal := newTerminalConfig() acp := newACPConfig() + presets, err := newPresetCatalog() + if err != nil { + fmt.Fprintf(os.Stderr, "invalid preset config: %v\n", err) + os.Exit(1) + } + provisioners := newProvisionerPolicy() sshDefaults := newSSHDefaults() sshGateway, err := newSSHGatewayConfig() if err != nil { @@ -126,6 +135,8 @@ func main() { sshDefaults: sshDefaults, sshMintLimiter: sshMintLimiter, acp: acp, + presets: presets, + provisioners: provisioners, defaultMetadata: defaultAnnotations, sharedMounts: sharedMounts, sharedMountsStore: sharedStore, @@ -189,6 +200,7 @@ func (s *server) registerRoutes(e *echo.Echo) { internal.PUT("/shared-mounts/owner/:owner/:mount/revisions/:revision", s.putSharedMountRevision) internal.PUT("/shared-mounts/owner/:owner/:mount/latest", s.putSharedMountLatest) secured := group.Group("", s.authMiddleware()) + secured.GET("/presets", s.listPresets) secured.GET("/spritzes", s.listSpritzes) secured.POST("/spritzes/suggest-name", s.suggestSpritzName) secured.POST("/spritzes", s.createSpritz) @@ -214,18 +226,26 @@ func (s *server) handleHealthz(c echo.Context) error { } type createRequest struct { - Name string `json:"name"` - NamePrefix string `json:"namePrefix,omitempty"` - Namespace string `json:"namespace,omitempty"` - Spec spritzv1.SpritzSpec `json:"spec"` - UserConfig json.RawMessage `json:"userConfig,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + Name string `json:"name"` + NamePrefix string `json:"namePrefix,omitempty"` + Namespace string `json:"namespace,omitempty"` + PresetID string `json:"presetId,omitempty"` + OwnerID string `json:"ownerId,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + TTL string `json:"ttl,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + Source string `json:"source,omitempty"` + RequestID string `json:"requestId,omitempty"` + Spec spritzv1.SpritzSpec `json:"spec"` + UserConfig json.RawMessage `json:"userConfig,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` } type suggestNameRequest struct { Namespace string `json:"namespace,omitempty"` Image string `json:"image,omitempty"` + PresetID string `json:"presetId,omitempty"` NamePrefix string `json:"namePrefix,omitempty"` } @@ -243,27 +263,40 @@ func (s *server) resolveSpritzNamespace(requested string) (string, error) { return namespace, nil } +func (s *server) listPresets(c echo.Context) error { + principal, ok := principalFromContext(c) + if s.auth.enabled() && (!ok || principal.ID == "") { + return writeError(c, http.StatusUnauthorized, "unauthenticated") + } + if principal.isService() && !principal.hasScope(scopePresetsRead) && !principal.isAdminPrincipal() { + return writeError(c, http.StatusForbidden, "forbidden") + } + return writeJSON(c, http.StatusOK, map[string]any{"items": s.presets.all()}) +} + func (s *server) suggestSpritzName(c echo.Context) error { principal, ok := principalFromContext(c) if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if principal.isService() && !principal.hasScope(scopeInstancesSuggestName) && !principal.isAdminPrincipal() { + return writeError(c, http.StatusForbidden, "forbidden") + } var body suggestNameRequest if err := c.Bind(&body); err != nil { return writeError(c, http.StatusBadRequest, "invalid json") } - body.Image = strings.TrimSpace(body.Image) - if body.Image == "" { - return writeError(c, http.StatusBadRequest, "image is required") + metadata, err := s.resolveSuggestNameMetadata(body) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) } namespace, err := s.resolveSpritzNamespace(body.Namespace) if err != nil { return writeError(c, http.StatusForbidden, err.Error()) } - namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Image) - generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) + generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, metadata.namePrefix) if err != nil { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } @@ -281,17 +314,33 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusBadRequest, "invalid json") } body.Name = strings.TrimSpace(body.Name) + body.NamePrefix = strings.TrimSpace(body.NamePrefix) + applyTopLevelCreateFields(&body) + + requestedNamespace := strings.TrimSpace(body.Namespace) != "" + requestedImage := strings.TrimSpace(body.Spec.Image) != "" + requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 + preset, err := s.applyCreatePreset(&body) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } userConfigKeys, userConfigPayload, err := parseUserConfig(body.UserConfig) if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) } + var normalizedUserConfig json.RawMessage if len(userConfigKeys) > 0 { normalized, err := normalizeUserConfig(s.userConfigPolicy, userConfigKeys, userConfigPayload) if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) } userConfigPayload = normalized + encodedUserConfig, err := json.Marshal(userConfigPayload) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid userConfig") + } + normalizedUserConfig = encodedUserConfig applyUserConfig(&body.Spec, userConfigKeys, userConfigPayload) } @@ -324,10 +373,53 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusForbidden, err.Error()) } + owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + body.Spec.Owner = owner + + if principal.isService() { + fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace) + if err != nil { + if errors.Is(err, errForbidden) { + return writeError(c, http.StatusForbidden, "forbidden") + } + return writeError(c, http.StatusBadRequest, err.Error()) + } + existing, err := findIdempotentSpritz(c.Request().Context(), s.client, namespace, principal.ID, body.IdempotencyKey) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if existing != nil { + if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { + return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") + } + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + body.Annotations = mergeStringMap(body.Annotations, map[string]string{ + actorIDAnnotationKey: principal.ID, + actorTypeAnnotationKey: string(principal.Type), + sourceAnnotationKey: provisionerSource(&body), + requestIDAnnotationKey: body.RequestID, + idempotencyKeyAnnotationKey: body.IdempotencyKey, + idempotencyHashAnnotationKey: fingerprint, + }) + } else if s.auth.enabled() && !principal.isAdminPrincipal() && owner.ID != principal.ID { + return writeError(c, http.StatusForbidden, "owner mismatch") + } + + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, principal.isService()); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + nameProvided := body.Name != "" var nameGenerator func() string if !nameProvided { namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Spec.Image) + if namePrefix == "" && preset != nil { + namePrefix = resolveSpritzNamePrefix(preset.NamePrefix, preset.Image) + } generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) if err != nil { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") @@ -339,21 +431,16 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } - owner := body.Spec.Owner - if owner.ID == "" { - if s.auth.enabled() { - owner.ID = principal.ID - } else { - return writeError(c, http.StatusBadRequest, "spec.owner.id is required") - } - } - if s.auth.enabled() && !principal.IsAdmin && owner.ID != principal.ID { - return writeError(c, http.StatusForbidden, "owner mismatch") - } - labels := map[string]string{ ownerLabelKey: ownerLabelValue(owner.ID), } + if principal.isService() { + labels[actorLabelKey] = actorLabelValue(principal.ID) + labels[idempotencyLabelKey] = idempotencyLabelValue(body.IdempotencyKey) + } + if body.PresetID != "" { + labels[presetLabelKey] = body.PresetID + } for k, v := range body.Labels { labels[k] = v } @@ -369,8 +456,12 @@ func (s *server) createSpritz(c echo.Context) error { }) } } + if body.PresetID != "" { + annotations = mergeStringMap(annotations, map[string]string{ + presetIDAnnotationKey: body.PresetID, + }) + } - body.Spec.Owner = owner applySSHDefaults(&body.Spec, s.sshDefaults, namespace) baseSpec := body.Spec @@ -422,7 +513,7 @@ func (s *server) createSpritz(c echo.Context) error { } return writeError(c, http.StatusInternalServerError, err.Error()) } - return writeJSON(c, http.StatusCreated, spritz) + return writeJSON(c, http.StatusCreated, summarizeCreateResponse(spritz, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, false)) } return writeError(c, http.StatusInternalServerError, "failed to generate unique spritz name") @@ -433,6 +524,9 @@ func (s *server) listSpritzes(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.namespace if namespace == "" { @@ -449,10 +543,10 @@ func (s *server) listSpritzes(c echo.Context) error { return writeError(c, http.StatusInternalServerError, err.Error()) } - if s.auth.enabled() && !principal.IsAdmin { + if s.auth.enabled() { filtered := make([]spritzv1.Spritz, 0, len(list.Items)) for _, item := range list.Items { - if item.Spec.Owner.ID == principal.ID { + if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, true); err == nil { filtered = append(filtered, item) } } @@ -471,6 +565,9 @@ func (s *server) getSpritz(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.namespace if namespace == "" { @@ -484,7 +581,7 @@ func (s *server) getSpritz(c echo.Context) error { if err := s.client.Get(c.Request().Context(), client.ObjectKey{Name: name, Namespace: namespace}, spritz); err != nil { return writeError(c, http.StatusNotFound, err.Error()) } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { return writeError(c, http.StatusForbidden, "forbidden") } @@ -500,6 +597,9 @@ func (s *server) updateUserConfig(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.namespace if namespace == "" { @@ -513,7 +613,7 @@ func (s *server) updateUserConfig(c echo.Context) error { if err := s.client.Get(c.Request().Context(), client.ObjectKey{Name: name, Namespace: namespace}, spritz); err != nil { return writeError(c, http.StatusNotFound, err.Error()) } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { return writeError(c, http.StatusForbidden, "forbidden") } @@ -588,6 +688,9 @@ func (s *server) deleteSpritz(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } + if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil { + return writeForbidden(c) + } namespace := s.namespace if namespace == "" { @@ -601,7 +704,7 @@ func (s *server) deleteSpritz(c echo.Context) error { if err := s.client.Get(c.Request().Context(), client.ObjectKey{Name: name, Namespace: namespace}, spritz); err != nil { return writeError(c, http.StatusNotFound, err.Error()) } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { return writeError(c, http.StatusForbidden, "forbidden") } diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index df39eec..13cc084 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/labstack/echo/v4" "k8s.io/apimachinery/pkg/runtime" @@ -32,15 +33,37 @@ func newCreateSpritzTestServer(t *testing.T) *server { scheme: scheme, namespace: "spritz-test", auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", - headerEmail: "X-Spritz-User-Email", + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerEmail: "X-Spritz-User-Email", + headerType: "X-Spritz-Principal-Type", + headerScopes: "X-Spritz-Principal-Scopes", + headerDefaultType: principalTypeHuman, }, internalAuth: internalAuthConfig{enabled: false}, userConfigPolicy: userConfigPolicy{}, } } +func configureProvisionerTestServer(s *server) { + s.presets = presetCatalog{ + byID: []runtimePreset{{ + ID: "openclaw", + Name: "OpenClaw", + Image: "example.com/spritz-openclaw:latest", + NamePrefix: "openclaw", + }}, + } + s.provisioners = provisionerPolicy{ + allowedPresetIDs: map[string]struct{}{"openclaw": {}}, + defaultIdleTTL: 24 * time.Hour, + maxIdleTTL: 24 * time.Hour, + defaultTTL: 168 * time.Hour, + maxTTL: 168 * time.Hour, + rateWindow: time.Hour, + } +} + func TestCreateSpritzOwnerUsesIDAndOmitsEmail(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() @@ -68,7 +91,11 @@ func TestCreateSpritzOwnerUsesIDAndOmitsEmail(t *testing.T) { if !ok { t.Fatalf("expected data object in response, got %#v", payload["data"]) } - spec, ok := data["spec"].(map[string]any) + spritz, ok := data["spritz"].(map[string]any) + if !ok { + t.Fatalf("expected spritz object in response, got %#v", data["spritz"]) + } + spec, ok := spritz["spec"].(map[string]any) if !ok { t.Fatalf("expected spec object in response, got %#v", data["spec"]) } @@ -164,7 +191,11 @@ func TestCreateSpritzGeneratesPrefixedNameFromImage(t *testing.T) { if !ok { t.Fatalf("expected data object in response, got %#v", payload["data"]) } - name, _ := data["metadata"].(map[string]any)["name"].(string) + spritz, ok := data["spritz"].(map[string]any) + if !ok { + t.Fatalf("expected spritz object in response, got %#v", data["spritz"]) + } + name, _ := spritz["metadata"].(map[string]any)["name"].(string) if name == "" { t.Fatal("expected generated metadata.name") } @@ -172,3 +203,122 @@ func TestCreateSpritzGeneratesPrefixedNameFromImage(t *testing.T) { t.Fatalf("expected generated name to start with %q, got %q", "claude-code-", name) } } + +func TestCreateSpritzAllowsProvisionerToAssignOwnerOnce(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-1","source":"discord","requestId":"cmd-1"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response json: %v", err) + } + data := payload["data"].(map[string]any) + if data["ownerId"] != "user-123" { + t.Fatalf("expected ownerId user-123, got %#v", data["ownerId"]) + } + if data["actorType"] != string(principalTypeService) { + t.Fatalf("expected actorType service, got %#v", data["actorType"]) + } + if data["presetId"] != "openclaw" { + t.Fatalf("expected presetId openclaw, got %#v", data["presetId"]) + } + + spritzData := data["spritz"].(map[string]any) + annotations := spritzData["metadata"].(map[string]any)["annotations"].(map[string]any) + if annotations[actorIDAnnotationKey] != "zenobot" { + t.Fatalf("expected actor annotation, got %#v", annotations[actorIDAnnotationKey]) + } + if annotations[idempotencyKeyAnnotationKey] != "discord-1" { + t.Fatalf("expected idempotency annotation, got %#v", annotations[idempotencyKeyAnnotationKey]) + } +} + +func TestCreateSpritzReplaysIdempotentProvisionerRequest(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-2"}`) + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec2.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode replay response: %v", err) + } + data := payload["data"].(map[string]any) + if replayed, _ := data["replayed"].(bool); !replayed { + t.Fatalf("expected replayed response, got %#v", data["replayed"]) + } +} + +func TestCreateSpritzRejectsIdempotentProvisionerPayloadMismatch(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-3"}`) + second := []byte(`{"presetId":"openclaw","ownerId":"user-999","idempotencyKey":"discord-3"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusConflict { + t.Fatalf("expected conflict status 409, got %d: %s", rec2.Code, rec2.Body.String()) + } +} diff --git a/api/main_owner_visibility_test.go b/api/main_owner_visibility_test.go index c0c7543..f0baa87 100644 --- a/api/main_owner_visibility_test.go +++ b/api/main_owner_visibility_test.go @@ -26,8 +26,10 @@ func newListSpritzTestServer(t *testing.T, objects ...client.Object) *server { scheme: scheme, namespace: "spritz-test", auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerType: "X-Spritz-Principal-Type", + headerDefaultType: principalTypeHuman, }, internalAuth: internalAuthConfig{enabled: false}, } @@ -84,3 +86,37 @@ func TestListSpritzesUsesSpecOwnerWhenOwnerLabelMissing(t *testing.T) { t.Fatalf("expected tidy-otter, got %q", payload.Data.Items[0].Name) } } + +func TestListSpritzesRejectsServicePrincipal(t *testing.T) { + s := newListSpritzTestServer(t, spritzForOwner("tidy-otter", "user-1", nil)) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", s.listSpritzes) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteSpritzRejectsServicePrincipal(t *testing.T) { + s := newListSpritzTestServer(t, spritzForOwner("tidy-otter", "user-1", nil)) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.DELETE("/api/spritzes/:name", s.deleteSpritz) + + req := httptest.NewRequest(http.MethodDelete, "/api/spritzes/tidy-otter", nil) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/api/provisioning.go b/api/provisioning.go new file mode 100644 index 0000000..7cd17c9 --- /dev/null +++ b/api/provisioning.go @@ -0,0 +1,552 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + spritzv1 "spritz.sh/operator/api/v1" +) + +const ( + scopeInstancesCreate = "spritz.instances.create" + scopeInstancesAssignOwner = "spritz.instances.assign_owner" + scopePresetsRead = "spritz.presets.read" + scopeInstancesSuggestName = "spritz.instances.suggest_name" + + actorIDAnnotationKey = "spritz.sh/actor.id" + actorTypeAnnotationKey = "spritz.sh/actor.type" + sourceAnnotationKey = "spritz.sh/source" + requestIDAnnotationKey = "spritz.sh/request-id" + idempotencyKeyAnnotationKey = "spritz.sh/idempotency-key" + idempotencyHashAnnotationKey = "spritz.sh/idempotency-hash" + presetIDAnnotationKey = "spritz.sh/preset-id" + actorLabelKey = "spritz.sh/actor" + idempotencyLabelKey = "spritz.sh/idempotency" + presetLabelKey = "spritz.sh/preset" + defaultProvisionerSource = "external" + defaultProvisionerIdleTTL = 24 * time.Hour + defaultProvisionerMaxTTL = 7 * 24 * time.Hour +) + +type runtimePreset struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Image string `json:"image,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + Branch string `json:"branch,omitempty"` + TTL string `json:"ttl,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + NamePrefix string `json:"namePrefix,omitempty"` + Env []corev1.EnvVar `json:"env,omitempty"` +} + +type presetCatalog struct { + byID []runtimePreset +} + +type provisionerPolicy struct { + allowedPresetIDs map[string]struct{} + defaultPresetID string + allowCustomImage bool + allowCustomRepo bool + allowNamespaceOverride bool + allowedNamespaces map[string]struct{} + defaultIdleTTL time.Duration + maxIdleTTL time.Duration + defaultTTL time.Duration + maxTTL time.Duration + maxActivePerOwner int + maxCreatesPerActor int + maxCreatesPerOwner int + rateWindow time.Duration +} + +type createSpritzResponse struct { + Spritz *spritzv1.Spritz `json:"spritz"` + AccessURL string `json:"accessUrl,omitempty"` + Namespace string `json:"namespace,omitempty"` + OwnerID string `json:"ownerId,omitempty"` + ActorID string `json:"actorId,omitempty"` + ActorType string `json:"actorType,omitempty"` + PresetID string `json:"presetId,omitempty"` + Source string `json:"source,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + Replayed bool `json:"replayed,omitempty"` + CreatedAt *metav1.Time `json:"createdAt,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + TTL string `json:"ttl,omitempty"` + IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` + MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` +} + +type suggestNameMetadata struct { + presetID string + namePrefix string + image string +} + +func (s *server) applyCreatePreset(body *createRequest) (*runtimePreset, error) { + body.PresetID = sanitizeSpritzNameToken(body.PresetID) + if body.PresetID == "" { + return nil, nil + } + preset, ok := s.presets.get(body.PresetID) + if !ok { + return nil, fmt.Errorf("preset not found: %s", body.PresetID) + } + buildPresetIntoSpec(&body.Spec, preset) + if strings.TrimSpace(body.NamePrefix) == "" { + body.NamePrefix = preset.NamePrefix + } + return preset, nil +} + +func applyTopLevelCreateFields(body *createRequest) { + if strings.TrimSpace(body.OwnerID) != "" && strings.TrimSpace(body.Spec.Owner.ID) == "" { + body.Spec.Owner.ID = strings.TrimSpace(body.OwnerID) + } + if strings.TrimSpace(body.IdleTTL) != "" && strings.TrimSpace(body.Spec.IdleTTL) == "" { + body.Spec.IdleTTL = strings.TrimSpace(body.IdleTTL) + } + if strings.TrimSpace(body.TTL) != "" && strings.TrimSpace(body.Spec.TTL) == "" { + body.Spec.TTL = strings.TrimSpace(body.TTL) + } + body.Source = strings.TrimSpace(body.Source) + body.RequestID = strings.TrimSpace(body.RequestID) + body.IdempotencyKey = strings.TrimSpace(body.IdempotencyKey) +} + +func normalizeCreateOwner(body *createRequest, principal principal, authEnabled bool) (spritzv1.SpritzOwner, error) { + owner := body.Spec.Owner + if owner.ID == "" { + if authEnabled { + owner.ID = principal.ID + } else { + return owner, fmt.Errorf("spec.owner.id is required") + } + } + return owner, nil +} + +func provisionerSource(body *createRequest) string { + source := strings.TrimSpace(body.Source) + if source == "" { + source = strings.TrimSpace(body.RequestID) + } + if source == "" { + source = defaultProvisionerSource + } + return source +} + +func (p provisionerPolicy) validateNamespace(namespace string) error { + if len(p.allowedNamespaces) == 0 { + return nil + } + if _, ok := p.allowedNamespaces[namespace]; ok { + return nil + } + return fmt.Errorf("namespace is not allowed: %s", namespace) +} + +func (p provisionerPolicy) validatePreset(presetID string) error { + if presetID == "" { + return nil + } + if len(p.allowedPresetIDs) == 0 { + return nil + } + if _, ok := p.allowedPresetIDs[presetID]; ok { + return nil + } + return fmt.Errorf("preset is not allowed: %s", presetID) +} + +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool) (string, error) { + if !principalCanUseProvisionerFlow(principal) { + return "", errForbidden + } + if err := authorizeServiceAction(principal, scopeInstancesCreate, true); err != nil { + return "", err + } + if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { + return "", err + } + if strings.TrimSpace(body.Spec.Owner.ID) == "" { + return "", fmt.Errorf("ownerId is required") + } + if requestedNamespace && !s.provisioners.allowNamespaceOverride { + return "", fmt.Errorf("namespace override is not allowed") + } + if err := s.provisioners.validateNamespace(namespace); err != nil { + return "", err + } + if body.PresetID == "" && s.provisioners.defaultPresetID != "" { + body.PresetID = s.provisioners.defaultPresetID + if _, err := s.applyCreatePreset(body); err != nil { + return "", err + } + } + if body.PresetID != "" { + if err := s.provisioners.validatePreset(body.PresetID); err != nil { + return "", err + } + } + if requestedImage && !s.provisioners.allowCustomImage { + return "", fmt.Errorf("custom image is not allowed") + } + if requestedRepo && !s.provisioners.allowCustomRepo { + return "", fmt.Errorf("custom repo is not allowed") + } + if body.IdempotencyKey == "" { + return "", fmt.Errorf("idempotencyKey is required") + } + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { + return "", err + } + if err := s.enforceProvisionerQuotas(ctx, namespace, principal, body.Spec.Owner.ID); err != nil { + return "", err + } + return createFingerprint(body.Spec.Owner.ID, body.PresetID, body.Name, namespace, provisionerSource(body), body.Spec, userConfig) +} + +func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, principal principal, ownerID string) error { + list := &spritzv1.SpritzList{} + if err := s.client.List(ctx, list, client.InNamespace(namespace)); err != nil { + return err + } + activeForOwner := 0 + actorCreates := 0 + ownerCreates := 0 + cutoff := time.Now().Add(-s.provisioners.rateWindow) + for _, item := range list.Items { + if item.DeletionTimestamp != nil { + continue + } + if item.Spec.Owner.ID == ownerID && item.Status.Phase != "Expired" { + activeForOwner++ + } + if s.provisioners.rateWindow > 0 && item.CreationTimestamp.Time.Before(cutoff) { + continue + } + if item.Annotations[actorIDAnnotationKey] == principal.ID { + actorCreates++ + } + if item.Spec.Owner.ID == ownerID { + ownerCreates++ + } + } + if s.provisioners.maxActivePerOwner > 0 && activeForOwner >= s.provisioners.maxActivePerOwner { + return fmt.Errorf("owner active workspace limit reached") + } + if s.provisioners.maxCreatesPerActor > 0 && actorCreates >= s.provisioners.maxCreatesPerActor { + return fmt.Errorf("actor create rate limit reached") + } + if s.provisioners.maxCreatesPerOwner > 0 && ownerCreates >= s.provisioners.maxCreatesPerOwner { + return fmt.Errorf("owner create rate limit reached") + } + return nil +} + +func newPresetCatalog() (presetCatalog, error) { + raw := strings.TrimSpace(envOrDefault("SPRITZ_PRESETS", "")) + if raw == "" { + return presetCatalog{}, nil + } + var items []runtimePreset + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return presetCatalog{}, fmt.Errorf("invalid SPRITZ_PRESETS: %w", err) + } + normalized := make([]runtimePreset, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + item.Image = strings.TrimSpace(item.Image) + if item.Image == "" { + continue + } + item.Name = strings.TrimSpace(item.Name) + item.Description = strings.TrimSpace(item.Description) + item.TTL = strings.TrimSpace(item.TTL) + item.IdleTTL = strings.TrimSpace(item.IdleTTL) + item.NamePrefix = resolveSpritzNamePrefix(item.NamePrefix, item.Image) + item.ID = normalizePresetID(item) + if item.ID == "" { + continue + } + if _, ok := seen[item.ID]; ok { + return presetCatalog{}, fmt.Errorf("duplicate preset id: %s", item.ID) + } + seen[item.ID] = struct{}{} + normalized = append(normalized, item) + } + return presetCatalog{byID: normalized}, nil +} + +func normalizePresetID(preset runtimePreset) string { + if id := sanitizeSpritzNameToken(preset.ID); id != "" { + return id + } + if id := sanitizeSpritzNameToken(preset.Name); id != "" { + return id + } + return deriveSpritzNamePrefixFromImage(preset.Image) +} + +func (c presetCatalog) all() []runtimePreset { + if len(c.byID) == 0 { + return nil + } + items := append([]runtimePreset(nil), c.byID...) + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + return items +} + +func (c presetCatalog) get(id string) (*runtimePreset, bool) { + id = sanitizeSpritzNameToken(id) + if id == "" { + return nil, false + } + for i := range c.byID { + if c.byID[i].ID == id { + copy := c.byID[i] + return ©, true + } + } + return nil, false +} + +func newProvisionerPolicy() provisionerPolicy { + defaultIdle := parseDurationEnv("SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL", defaultProvisionerIdleTTL) + maxIdle := parseDurationEnv("SPRITZ_PROVISIONER_MAX_IDLE_TTL", defaultIdle) + defaultTTL := parseDurationEnv("SPRITZ_PROVISIONER_DEFAULT_TTL", defaultProvisionerMaxTTL) + maxTTL := parseDurationEnv("SPRITZ_PROVISIONER_MAX_TTL", defaultTTL) + return provisionerPolicy{ + allowedPresetIDs: splitSet(osEnvString("SPRITZ_PROVISIONER_ALLOWED_PRESET_IDS")), + defaultPresetID: sanitizeSpritzNameToken(osEnvString("SPRITZ_PROVISIONER_DEFAULT_PRESET_ID")), + allowCustomImage: parseBoolEnv("SPRITZ_PROVISIONER_ALLOW_CUSTOM_IMAGE", false), + allowCustomRepo: parseBoolEnv("SPRITZ_PROVISIONER_ALLOW_CUSTOM_REPO", false), + allowNamespaceOverride: parseBoolEnv("SPRITZ_PROVISIONER_ALLOW_NAMESPACE_OVERRIDE", false), + allowedNamespaces: splitSet(osEnvString("SPRITZ_PROVISIONER_ALLOWED_NAMESPACES")), + defaultIdleTTL: defaultIdle, + maxIdleTTL: maxIdle, + defaultTTL: defaultTTL, + maxTTL: maxTTL, + maxActivePerOwner: parseIntEnvAllowZero("SPRITZ_PROVISIONER_MAX_ACTIVE_PER_OWNER", 0), + maxCreatesPerActor: parseIntEnvAllowZero("SPRITZ_PROVISIONER_MAX_CREATES_PER_ACTOR", 0), + maxCreatesPerOwner: parseIntEnvAllowZero("SPRITZ_PROVISIONER_MAX_CREATES_PER_OWNER", 0), + rateWindow: parseDurationEnv("SPRITZ_PROVISIONER_RATE_WINDOW", time.Hour), + } +} + +func osEnvString(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func principalCanAccessOwner(principal principal, ownerID string) bool { + if principal.isAdminPrincipal() { + return true + } + return principal.isHuman() && principal.ID == ownerID +} + +func principalCanUseProvisionerFlow(principal principal) bool { + return principal.isService() || principal.isAdminPrincipal() +} + +func buildPresetIntoSpec(spec *spritzv1.SpritzSpec, preset *runtimePreset) { + if preset == nil || spec == nil { + return + } + if strings.TrimSpace(spec.Image) == "" { + spec.Image = preset.Image + } + if strings.TrimSpace(spec.TTL) == "" && preset.TTL != "" { + spec.TTL = preset.TTL + } + if strings.TrimSpace(spec.IdleTTL) == "" && preset.IdleTTL != "" { + spec.IdleTTL = preset.IdleTTL + } + if spec.Repo == nil && len(spec.Repos) == 0 && strings.TrimSpace(preset.RepoURL) != "" { + spec.Repo = &spritzv1.SpritzRepo{ + URL: preset.RepoURL, + Branch: strings.TrimSpace(preset.Branch), + } + } + if len(spec.Env) == 0 && len(preset.Env) > 0 { + spec.Env = append([]corev1.EnvVar(nil), preset.Env...) + } +} + +func resolveCreateLifetimes(spec *spritzv1.SpritzSpec, policy provisionerPolicy, servicePrincipal bool) error { + if spec == nil { + return nil + } + if servicePrincipal { + if strings.TrimSpace(spec.IdleTTL) == "" && policy.defaultIdleTTL > 0 { + spec.IdleTTL = policy.defaultIdleTTL.String() + } + if strings.TrimSpace(spec.TTL) == "" && policy.defaultTTL > 0 { + spec.TTL = policy.defaultTTL.String() + } + } + if spec.IdleTTL != "" { + parsed, err := time.ParseDuration(spec.IdleTTL) + if err != nil { + return fmt.Errorf("invalid idleTtl") + } + if parsed <= 0 { + return fmt.Errorf("idleTtl must be greater than zero") + } + if servicePrincipal && policy.maxIdleTTL > 0 && parsed > policy.maxIdleTTL { + return fmt.Errorf("idleTtl exceeds max idle ttl of %s", policy.maxIdleTTL) + } + } + if spec.TTL != "" { + parsed, err := time.ParseDuration(spec.TTL) + if err != nil { + return fmt.Errorf("invalid ttl") + } + if parsed <= 0 { + return fmt.Errorf("ttl must be greater than zero") + } + if servicePrincipal && policy.maxTTL > 0 && parsed > policy.maxTTL { + return fmt.Errorf("ttl exceeds max ttl of %s", policy.maxTTL) + } + } + return nil +} + +func actorLabelValue(id string) string { + return hashLabelValue("actor", id) +} + +func idempotencyLabelValue(key string) string { + return hashLabelValue("idem", key) +} + +func hashLabelValue(prefix, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%s-%x", prefix, sum[:12]) +} + +func createFingerprint(ownerID, presetID, name, namespace, source string, spec spritzv1.SpritzSpec, userConfig json.RawMessage) (string, error) { + specCopy := spec + specCopy.Annotations = nil + specCopy.Labels = nil + payload := struct { + OwnerID string `json:"ownerId"` + PresetID string `json:"presetId,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Source string `json:"source,omitempty"` + Spec spritzv1.SpritzSpec `json:"spec"` + UserConfig json.RawMessage `json:"userConfig,omitempty"` + }{ + OwnerID: ownerID, + PresetID: presetID, + Name: name, + Namespace: namespace, + Source: source, + Spec: specCopy, + UserConfig: userConfig, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return fmt.Sprintf("%x", sum[:]), nil +} + +func findIdempotentSpritz(ctx context.Context, k8sClient client.Client, namespace, actorID, idempotencyKey string) (*spritzv1.Spritz, error) { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(idempotencyKey) == "" { + return nil, nil + } + list := &spritzv1.SpritzList{} + opts := []client.ListOption{ + client.InNamespace(namespace), + client.MatchingLabels(map[string]string{ + actorLabelKey: actorLabelValue(actorID), + idempotencyLabelKey: idempotencyLabelValue(idempotencyKey), + }), + } + if err := k8sClient.List(ctx, list, opts...); err != nil { + return nil, err + } + if len(list.Items) == 0 { + return nil, nil + } + item := list.Items[0].DeepCopy() + return item, nil +} + +func summarizeCreateResponse(spritz *spritzv1.Spritz, principal principal, presetID, source, idempotencyKey string, replayed bool) createSpritzResponse { + createdAt := spritz.CreationTimestamp.DeepCopy() + idleExpiresAt, maxExpiresAt, expiresAt := lifecycleExpiryTimes(spritz, time.Now()) + return createSpritzResponse{ + Spritz: spritz, + AccessURL: spritzv1.AccessURLForSpritz(spritz), + Namespace: spritz.Namespace, + OwnerID: spritz.Spec.Owner.ID, + ActorID: principal.ID, + ActorType: string(principal.Type), + PresetID: presetID, + Source: source, + IdempotencyKey: idempotencyKey, + Replayed: replayed, + CreatedAt: createdAt, + IdleTTL: strings.TrimSpace(spritz.Spec.IdleTTL), + TTL: strings.TrimSpace(spritz.Spec.TTL), + IdleExpiresAt: idleExpiresAt, + MaxExpiresAt: maxExpiresAt, + ExpiresAt: expiresAt, + } +} + +func lifecycleExpiryTimes(spritz *spritzv1.Spritz, _ time.Time) (*metav1.Time, *metav1.Time, *metav1.Time) { + idleExpiresAt, maxExpiresAt, effectiveExpiresAt, _, err := spritzv1.LifecycleExpiryTimes(spritz) + if err != nil { + return nil, nil, nil + } + return idleExpiresAt, maxExpiresAt, effectiveExpiresAt +} + +func (s *server) resolveSuggestNameMetadata(body suggestNameRequest) (suggestNameMetadata, error) { + metadata := suggestNameMetadata{ + presetID: sanitizeSpritzNameToken(body.PresetID), + } + if metadata.presetID != "" { + preset, ok := s.presets.get(metadata.presetID) + if !ok { + return suggestNameMetadata{}, fmt.Errorf("preset not found: %s", metadata.presetID) + } + metadata.image = preset.Image + metadata.namePrefix = resolveSpritzNamePrefix(body.NamePrefix, preset.NamePrefix) + if metadata.namePrefix == "" { + metadata.namePrefix = resolveSpritzNamePrefix("", preset.Image) + } + return metadata, nil + } + metadata.image = strings.TrimSpace(body.Image) + if metadata.image == "" { + return suggestNameMetadata{}, fmt.Errorf("image or presetId is required") + } + metadata.namePrefix = resolveSpritzNamePrefix(body.NamePrefix, metadata.image) + return metadata, nil +} diff --git a/api/ssh_mint.go b/api/ssh_mint.go index 04a0477..abb600c 100644 --- a/api/ssh_mint.go +++ b/api/ssh_mint.go @@ -68,7 +68,7 @@ func (s *server) mintSSHCert(c echo.Context) error { log.Printf("spritz ssh: spritz not found name=%s namespace=%s user_id=%s err=%v", name, namespace, principal.ID, err) return writeError(c, http.StatusNotFound, "spritz not found") } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { log.Printf("spritz ssh: owner mismatch name=%s namespace=%s user_id=%s owner_id=%s", name, namespace, principal.ID, spritz.Spec.Owner.ID) return writeError(c, http.StatusForbidden, "owner mismatch") } @@ -90,6 +90,9 @@ func (s *server) mintSSHCert(c echo.Context) error { knownHosts := formatKnownHosts(s.sshGateway.publicHost, s.sshGateway.publicPort, s.sshGateway.hostPublicKey) expiresAt := time.Unix(int64(cert.ValidBefore), 0).UTC().Format(time.RFC3339) log.Printf("spritz ssh: cert issued name=%s namespace=%s user_id=%s expires_at=%s", name, namespace, principal.ID, expiresAt) + if err := s.markSpritzActivity(c.Request().Context(), namespace, name, time.Now()); err != nil { + log.Printf("spritz ssh: failed to record activity name=%s namespace=%s user_id=%s err=%v", name, namespace, principal.ID, err) + } resp := sshMintResponse{ Host: s.sshGateway.publicHost, Port: s.sshGateway.publicPort, diff --git a/api/terminal.go b/api/terminal.go index b1cfd14..16c240b 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -132,7 +132,7 @@ func (s *server) openTerminal(c echo.Context) error { return writeError(c, http.StatusNotFound, "spritz not found") } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { log.Printf("spritz terminal: owner mismatch name=%s namespace=%s user_id=%s owner_id=%s", name, namespace, principal.ID, spritz.Spec.Owner.ID) return writeError(c, http.StatusForbidden, "owner mismatch") } @@ -159,6 +159,9 @@ func (s *server) openTerminal(c echo.Context) error { if err != nil { return err } + if err := s.markSpritzActivity(c.Request().Context(), namespace, name, time.Now()); err != nil { + log.Printf("spritz terminal: failed to record activity name=%s namespace=%s user_id=%s err=%v", name, namespace, principal.ID, err) + } if usingZmx { log.Printf("spritz terminal: zmx attach name=%s namespace=%s session=%s user_id=%s", name, namespace, resolvedSession, principal.ID) } diff --git a/api/terminal_sessions.go b/api/terminal_sessions.go index 37d1be7..852007b 100644 --- a/api/terminal_sessions.go +++ b/api/terminal_sessions.go @@ -48,7 +48,7 @@ func (s *server) listTerminalSessions(c echo.Context) error { return writeError(c, http.StatusNotFound, "spritz not found") } - if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID { + if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil { log.Printf("spritz terminal sessions: owner mismatch name=%s namespace=%s user_id=%s owner_id=%s", name, namespace, principal.ID, spritz.Spec.Owner.ID) return writeError(c, http.StatusForbidden, "owner mismatch") } diff --git a/cli/src/index.ts b/cli/src/index.ts index 13a94b1..b8d6d2e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -362,7 +362,8 @@ function usage() { Usage: spritz list [--namespace ] - spritz create --image [--repo ] [--branch ] [--ttl ] [--namespace ] + spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] + spritz suggest-name [--preset ] [--image ] [--name-prefix ] [--namespace ] spritz delete [--namespace ] spritz open [--namespace ] spritz terminal [--namespace ] [--session ] [--transport ] [--print] @@ -379,6 +380,7 @@ Alias: Environment: SPRITZ_API_URL (default: ${process.env.SPRITZ_API_URL || defaultApiBase}) + SPRITZ_BEARER_TOKEN SPRITZ_USER_ID, SPRITZ_USER_EMAIL, SPRITZ_USER_TEAMS, SPRITZ_OWNER_ID SPRITZ_API_HEADER_ID, SPRITZ_API_HEADER_EMAIL, SPRITZ_API_HEADER_TEAMS SPRITZ_TERMINAL_TRANSPORT (default: ${terminalTransportDefault}) @@ -405,6 +407,22 @@ function hasFlag(flag: string): boolean { return rest.includes(flag); } +function positionalArgs(): string[] { + const values: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (token.startsWith('--')) { + const next = rest[index + 1]; + if (next && !next.startsWith('--')) { + index += 1; + } + continue; + } + values.push(token); + } + return values; +} + function normalizeHeaders(headers?: HeadersInit): Record { if (!headers) return {}; if (headers instanceof Headers) { @@ -534,6 +552,10 @@ function isJSend(payload: any): payload is { status: string; data?: any; message } async function authHeaders(): Promise> { + const token = argValue('--token') || process.env.SPRITZ_BEARER_TOKEN; + if (token?.trim()) { + return { Authorization: `Bearer ${token.trim()}` }; + } const { profile } = await resolveProfile({ allowFlag: true }); const headers: Record = {}; const userId = process.env.SPRITZ_USER_ID || profile?.userId || process.env.USER; @@ -1023,40 +1045,57 @@ async function main() { return; } + if (command === 'suggest-name') { + const ns = await resolveNamespace(); + const data = await request('/spritzes/suggest-name', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + namespace: ns, + presetId: argValue('--preset'), + image: argValue('--image'), + namePrefix: argValue('--name-prefix'), + }), + }); + console.log(JSON.stringify(data, null, 2)); + return; + } + if (command === 'create') { - const name = rest[0]; - if (!name) throw new Error('name is required'); + const name = positionalArgs()[0]; + const presetId = argValue('--preset'); const image = argValue('--image'); - if (!image) throw new Error('--image is required'); + if (!presetId && !image) throw new Error('--preset or --image is required'); const repo = argValue('--repo'); const branch = argValue('--branch'); + const ownerId = argValue('--owner-id') || process.env.SPRITZ_OWNER_ID; + const idleTtl = argValue('--idle-ttl'); const ttl = argValue('--ttl'); + const idempotencyKey = argValue('--idempotency-key'); + const source = argValue('--source'); + const requestId = argValue('--request-id'); + const namePrefix = argValue('--name-prefix'); const ns = await resolveNamespace(); - const { profile } = await resolveProfile({ allowFlag: true }); - const ownerId = - process.env.SPRITZ_OWNER_ID || - process.env.SPRITZ_USER_ID || - profile?.userId || - process.env.USER; - if (!ownerId) { - throw new Error('SPRITZ_OWNER_ID, SPRITZ_USER_ID, or USER environment variable must be set'); - } - const body: any = { - name, namespace: ns, - spec: { - image, - owner: { id: ownerId }, - }, + spec: {}, }; + if (name) body.name = name; + if (namePrefix) body.namePrefix = namePrefix; + if (presetId) body.presetId = presetId; + if (ownerId) body.ownerId = ownerId; + if (idleTtl) body.idleTtl = idleTtl; + if (ttl) body.ttl = ttl; + if (idempotencyKey) body.idempotencyKey = idempotencyKey; + if (source) body.source = source; + if (requestId) body.requestId = requestId; + if (image) body.spec.image = image; if (repo) { body.spec.repo = { url: repo }; if (branch) body.spec.repo.branch = branch; } - if (ttl) body.spec.ttl = ttl; const data = await request('/spritzes', { method: 'POST', diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts new file mode 100644 index 0000000..2fa3d2d --- /dev/null +++ b/cli/test/provisioner-create.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import test from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, '..', 'src', 'index.ts'); + +test('create uses bearer auth and provisioner fields for preset-based creation', async (t) => { + let requestBody: any = null; + let requestHeaders: http.IncomingHttpHeaders | null = null; + + const server = http.createServer((req, res) => { + requestHeaders = req.headers; + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'success', + data: { + accessUrl: 'https://console.example.com/w/openclaw-tide-wind/', + ownerId: 'user-123', + presetId: 'openclaw', + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--preset', 'openclaw', '--owner-id', 'user-123', '--idle-ttl', '24h', '--ttl', '168h', '--idempotency-key', 'discord-123', '--source', 'discord', '--request-id', 'interaction-1'], + { + env: { + ...process.env, + SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`, + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(exitCode, 0, `spz create should succeed: ${stderr}`); + + assert.equal(requestHeaders?.authorization, 'Bearer service-token'); + assert.equal(requestHeaders?.['x-spritz-user-id'], undefined); + assert.deepEqual(requestBody, { + presetId: 'openclaw', + ownerId: 'user-123', + idleTtl: '24h', + ttl: '168h', + idempotencyKey: 'discord-123', + source: 'discord', + requestId: 'interaction-1', + spec: {}, + }); + + const payload = JSON.parse(stdout); + assert.equal(payload.accessUrl, 'https://console.example.com/w/openclaw-tide-wind/'); + assert.equal(payload.ownerId, 'user-123'); + assert.equal(payload.presetId, 'openclaw'); +}); diff --git a/crd/generated/spritz.sh_spritzes.yaml b/crd/generated/spritz.sh_spritzes.yaml index 3a1b392..d041214 100644 --- a/crd/generated/spritz.sh_spritzes.yaml +++ b/crd/generated/spritz.sh_spritzes.yaml @@ -227,6 +227,9 @@ spec: default: true type: boolean type: object + idleTtl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string image: pattern: ^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(:[0-9]+)?(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*(@sha256:[a-f0-9]{64}|:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?$ type: string @@ -658,9 +661,17 @@ spec: expiresAt: format: date-time type: string + idleExpiresAt: + format: date-time + type: string lastActivityAt: format: date-time type: string + lifecycleReason: + type: string + maxExpiresAt: + format: date-time + type: string message: type: string phase: diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 3763662..05ae0ce 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -54,6 +54,12 @@ spec: value: {{ .Values.api.auth.headerEmail | quote }} - name: SPRITZ_AUTH_HEADER_TEAMS value: {{ .Values.api.auth.headerTeams | quote }} + - name: SPRITZ_AUTH_HEADER_TYPE + value: {{ .Values.api.auth.headerType | quote }} + - name: SPRITZ_AUTH_HEADER_SCOPES + value: {{ .Values.api.auth.headerScopes | quote }} + - name: SPRITZ_AUTH_HEADER_DEFAULT_TYPE + value: {{ .Values.api.auth.headerDefaultType | quote }} {{- if .Values.api.auth.adminIds }} - name: SPRITZ_AUTH_ADMIN_IDS value: {{ join "," .Values.api.auth.adminIds | quote }} @@ -98,6 +104,16 @@ spec: - name: SPRITZ_AUTH_BEARER_TEAMS_PATHS value: {{ join "," .Values.api.auth.bearer.teamsPaths | quote }} {{- end }} + {{- if .Values.api.auth.bearer.typePaths }} + - name: SPRITZ_AUTH_BEARER_TYPE_PATHS + value: {{ join "," .Values.api.auth.bearer.typePaths | quote }} + {{- end }} + {{- if .Values.api.auth.bearer.scopesPaths }} + - name: SPRITZ_AUTH_BEARER_SCOPES_PATHS + value: {{ join "," .Values.api.auth.bearer.scopesPaths | quote }} + {{- end }} + - name: SPRITZ_AUTH_BEARER_DEFAULT_TYPE + value: {{ .Values.api.auth.bearer.defaultType | quote }} {{- if .Values.api.auth.bearer.jwks.url }} - name: SPRITZ_AUTH_BEARER_JWKS_URL value: {{ .Values.api.auth.bearer.jwks.url | quote }} @@ -200,6 +216,42 @@ spec: value: {{ .Values.acp.port | quote }} - name: SPRITZ_ACP_PATH value: {{ .Values.acp.path | quote }} + {{- if .Values.ui.presets }} + - name: SPRITZ_PRESETS + value: {{ .Values.ui.presets | toJson | quote }} + {{- end }} + - name: SPRITZ_PROVISIONER_DEFAULT_PRESET_ID + value: {{ .Values.api.provisioners.defaultPresetId | quote }} + {{- if .Values.api.provisioners.allowedPresetIds }} + - name: SPRITZ_PROVISIONER_ALLOWED_PRESET_IDS + value: {{ join "," .Values.api.provisioners.allowedPresetIds | quote }} + {{- end }} + - name: SPRITZ_PROVISIONER_ALLOW_CUSTOM_IMAGE + value: {{ .Values.api.provisioners.allowCustomImage | quote }} + - name: SPRITZ_PROVISIONER_ALLOW_CUSTOM_REPO + value: {{ .Values.api.provisioners.allowCustomRepo | quote }} + - name: SPRITZ_PROVISIONER_ALLOW_NAMESPACE_OVERRIDE + value: {{ .Values.api.provisioners.allowNamespaceOverride | quote }} + {{- if .Values.api.provisioners.allowedNamespaces }} + - name: SPRITZ_PROVISIONER_ALLOWED_NAMESPACES + value: {{ join "," .Values.api.provisioners.allowedNamespaces | quote }} + {{- end }} + - name: SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL + value: {{ .Values.api.provisioners.defaultIdleTtl | quote }} + - name: SPRITZ_PROVISIONER_MAX_IDLE_TTL + value: {{ .Values.api.provisioners.maxIdleTtl | quote }} + - name: SPRITZ_PROVISIONER_DEFAULT_TTL + value: {{ .Values.api.provisioners.defaultTtl | quote }} + - name: SPRITZ_PROVISIONER_MAX_TTL + value: {{ .Values.api.provisioners.maxTtl | quote }} + - name: SPRITZ_PROVISIONER_MAX_ACTIVE_PER_OWNER + value: {{ .Values.api.provisioners.maxActivePerOwner | quote }} + - name: SPRITZ_PROVISIONER_MAX_CREATES_PER_ACTOR + value: {{ .Values.api.provisioners.maxCreatesPerActor | quote }} + - name: SPRITZ_PROVISIONER_MAX_CREATES_PER_OWNER + value: {{ .Values.api.provisioners.maxCreatesPerOwner | quote }} + - name: SPRITZ_PROVISIONER_RATE_WINDOW + value: {{ .Values.api.provisioners.rateWindow | quote }} {{- if .Values.api.acp.origins }} - name: SPRITZ_ACP_ORIGINS value: {{ join "," .Values.api.acp.origins | quote }} diff --git a/helm/spritz/values.yaml b/helm/spritz/values.yaml index b0d2694..fa2f232 100644 --- a/helm/spritz/values.yaml +++ b/helm/spritz/values.yaml @@ -129,6 +129,9 @@ api: headerId: X-Spritz-User-Id headerEmail: X-Spritz-User-Email headerTeams: X-Spritz-User-Teams + headerType: X-Spritz-Principal-Type + headerScopes: X-Spritz-Principal-Scopes + headerDefaultType: human adminIds: [] adminTeams: [] bearer: @@ -143,6 +146,7 @@ api: emailPaths: - email teamsPaths: [] + typePaths: [] jwks: url: "" issuer: "" @@ -154,9 +158,29 @@ api: refreshTimeout: 5s rateLimit: 10s fallbackToIntrospection: false + scopesPaths: + - scope + - scopes + - scp + defaultType: human + provisioners: + defaultPresetId: "" + allowedPresetIds: [] + allowCustomImage: false + allowCustomRepo: false + allowNamespaceOverride: false + allowedNamespaces: [] + defaultIdleTtl: 24h + maxIdleTtl: 24h + defaultTtl: 168h + maxTtl: 168h + maxActivePerOwner: 0 + maxCreatesPerActor: 0 + maxCreatesPerOwner: 0 + rateWindow: 1h cors: origins: [] - allowHeaders: Content-Type,Authorization,X-Spritz-User-Id,X-Spritz-User-Email,X-Spritz-User-Teams + allowHeaders: Content-Type,Authorization,X-Spritz-User-Id,X-Spritz-User-Email,X-Spritz-User-Teams,X-Spritz-Principal-Type,X-Spritz-Principal-Scopes allowMethods: GET,POST,PUT,PATCH,DELETE,OPTIONS allowCredentials: true defaultIngress: diff --git a/operator/api/v1/access_url.go b/operator/api/v1/access_url.go new file mode 100644 index 0000000..4b84bfa --- /dev/null +++ b/operator/api/v1/access_url.go @@ -0,0 +1,45 @@ +package v1 + +import "fmt" + +const defaultWebPort = int32(8080) + +// AccessURLForSpritz returns the canonical access URL for a spritz based on its +// ingress or primary service port configuration. +func AccessURLForSpritz(spritz *Spritz) string { + if spritz == nil { + return "" + } + if spritz.Spec.Ingress != nil && spritz.Spec.Ingress.Host != "" { + path := spritz.Spec.Ingress.Path + if path == "" { + path = "/" + } + if path != "/" && path[len(path)-1] != '/' { + path += "/" + } + return fmt.Sprintf("https://%s%s", spritz.Spec.Ingress.Host, path) + } + + if len(spritz.Spec.Ports) == 0 { + if !IsWebEnabled(spritz.Spec) { + return "" + } + return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", spritz.Name, spritz.Namespace, defaultWebPort) + } + + port := spritz.Spec.Ports[0] + servicePort := port.ContainerPort + if port.ServicePort != 0 { + servicePort = port.ServicePort + } + return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", spritz.Name, spritz.Namespace, servicePort) +} + +// IsWebEnabled reports whether the web surface should be exposed for a spritz. +func IsWebEnabled(spec SpritzSpec) bool { + if spec.Features == nil || spec.Features.Web == nil { + return true + } + return *spec.Features.Web +} diff --git a/operator/api/v1/lifecycle.go b/operator/api/v1/lifecycle.go new file mode 100644 index 0000000..223a63b --- /dev/null +++ b/operator/api/v1/lifecycle.go @@ -0,0 +1,57 @@ +package v1 + +import ( + "fmt" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + LifecycleReasonIdleTTL = "IdleTTL" + LifecycleReasonTTL = "TTL" +) + +// LifecycleExpiryTimes returns the idle expiry, max expiry, effective expiry, +// and the reason for the effective expiry for a spritz lifecycle configuration. +func LifecycleExpiryTimes(spritz *Spritz) (*metav1.Time, *metav1.Time, *metav1.Time, string, error) { + if spritz == nil { + return nil, nil, nil, "", nil + } + + var idleExpiresAt *metav1.Time + if value := strings.TrimSpace(spritz.Spec.IdleTTL); value != "" { + idleTTL, err := time.ParseDuration(value) + if err != nil { + return nil, nil, nil, "", fmt.Errorf("invalid idle ttl format") + } + base := spritz.CreationTimestamp.Time + if spritz.Status.LastActivityAt != nil && spritz.Status.LastActivityAt.Time.After(base) { + base = spritz.Status.LastActivityAt.Time + } + expires := metav1.NewTime(base.Add(idleTTL)) + idleExpiresAt = &expires + } + + var maxExpiresAt *metav1.Time + if value := strings.TrimSpace(spritz.Spec.TTL); value != "" { + maxTTL, err := time.ParseDuration(value) + if err != nil { + return nil, nil, nil, "", fmt.Errorf("invalid ttl format") + } + expires := metav1.NewTime(spritz.CreationTimestamp.Add(maxTTL)) + maxExpiresAt = &expires + } + + switch { + case idleExpiresAt == nil: + return nil, maxExpiresAt, maxExpiresAt, LifecycleReasonTTL, nil + case maxExpiresAt == nil: + return idleExpiresAt, nil, idleExpiresAt, LifecycleReasonIdleTTL, nil + case idleExpiresAt.Before(maxExpiresAt): + return idleExpiresAt, maxExpiresAt, idleExpiresAt, LifecycleReasonIdleTTL, nil + default: + return idleExpiresAt, maxExpiresAt, maxExpiresAt, LifecycleReasonTTL, nil + } +} diff --git a/operator/api/v1/spritz_types.go b/operator/api/v1/spritz_types.go index 8a24618..d55c3dc 100644 --- a/operator/api/v1/spritz_types.go +++ b/operator/api/v1/spritz_types.go @@ -29,6 +29,8 @@ type SpritzSpec struct { SharedMounts []sharedmounts.MountSpec `json:"sharedMounts,omitempty"` // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" TTL string `json:"ttl,omitempty"` + // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" + IdleTTL string `json:"idleTtl,omitempty"` Resources corev1.ResourceRequirements `json:"resources,omitempty"` Owner SpritzOwner `json:"owner"` Labels map[string]string `json:"labels,omitempty"` @@ -139,7 +141,10 @@ type SpritzStatus struct { SSH *SpritzSSHInfo `json:"ssh,omitempty"` Message string `json:"message,omitempty"` LastActivityAt *metav1.Time `json:"lastActivityAt,omitempty"` + IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` + MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + LifecycleReason string `json:"lifecycleReason,omitempty"` ReadyAt *metav1.Time `json:"readyAt,omitempty"` Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/operator/controllers/lifecycle_test.go b/operator/controllers/lifecycle_test.go new file mode 100644 index 0000000..96273d0 --- /dev/null +++ b/operator/controllers/lifecycle_test.go @@ -0,0 +1,59 @@ +package controllers + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + spritzv1 "spritz.sh/operator/api/v1" +) + +func TestComputeSpritzLifecycleWindowChoosesEarlierIdleExpiry(t *testing.T) { + createdAt := time.Date(2026, 3, 11, 9, 0, 0, 0, time.UTC) + lastActivity := metav1.NewTime(createdAt.Add(30 * time.Minute)) + spritz := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(createdAt), + }, + Spec: spritzv1.SpritzSpec{ + IdleTTL: "1h", + TTL: "168h", + }, + Status: spritzv1.SpritzStatus{ + LastActivityAt: &lastActivity, + }, + } + + idleExpiresAt, maxExpiresAt, effectiveExpiresAt, reason, err := spritzv1.LifecycleExpiryTimes(spritz) + if err != nil { + t.Fatalf("LifecycleExpiryTimes returned error: %v", err) + } + if reason != spritzv1.LifecycleReasonIdleTTL { + t.Fatalf("expected idle ttl lifecycle reason, got %q", reason) + } + if idleExpiresAt == nil || !idleExpiresAt.Time.Equal(createdAt.Add(90*time.Minute)) { + t.Fatalf("unexpected idle expiry: %#v", idleExpiresAt) + } + if maxExpiresAt == nil || !maxExpiresAt.Time.Equal(createdAt.Add(168*time.Hour)) { + t.Fatalf("unexpected max expiry: %#v", maxExpiresAt) + } + if effectiveExpiresAt == nil || !effectiveExpiresAt.Time.Equal(idleExpiresAt.Time) { + t.Fatalf("expected effective expiry to match idle expiry") + } +} + +func TestComputeSpritzLifecycleWindowRejectsInvalidIdleTTL(t *testing.T) { + spritz := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Date(2026, 3, 11, 9, 0, 0, 0, time.UTC)), + }, + Spec: spritzv1.SpritzSpec{ + IdleTTL: "tomorrow", + }, + } + + if _, _, _, _, err := spritzv1.LifecycleExpiryTimes(spritz); err == nil { + t.Fatal("expected invalid idle ttl error") + } +} diff --git a/operator/controllers/spritz_controller.go b/operator/controllers/spritz_controller.go index 8dca4e6..c9fd21a 100644 --- a/operator/controllers/spritz_controller.go +++ b/operator/controllers/spritz_controller.go @@ -622,18 +622,29 @@ func (r *SpritzReconciler) reconcileStatus(ctx context.Context, spritz *spritzv1 } var statusRequeue *time.Duration - if spritz.Spec.TTL != "" { - ttl, err := time.ParseDuration(spritz.Spec.TTL) - if err != nil { - return nil, r.setStatus(ctx, spritz, "Error", "", sshInfo, "InvalidTTL", "invalid ttl format", deepCopyACPStatus(spritz.Status.ACP)) - } - expiry := spritz.CreationTimestamp.Add(ttl) - expiresAt := metav1.NewTime(expiry) - spritz.Status.ExpiresAt = &expiresAt + idleExpiresAt, maxExpiresAt, effectiveExpiresAt, lifecycleReason, err := spritzv1.LifecycleExpiryTimes(spritz) + if err != nil { + switch err.Error() { + case "invalid idle ttl format": + return nil, r.setStatus(ctx, spritz, "Error", "", sshInfo, "InvalidIdleTTL", err.Error(), deepCopyACPStatus(spritz.Status.ACP)) + default: + return nil, r.setStatus(ctx, spritz, "Error", "", sshInfo, "InvalidTTL", err.Error(), deepCopyACPStatus(spritz.Status.ACP)) + } + } + spritz.Status.IdleExpiresAt = idleExpiresAt + spritz.Status.MaxExpiresAt = maxExpiresAt + spritz.Status.ExpiresAt = effectiveExpiresAt + spritz.Status.LifecycleReason = lifecycleReason + if effectiveExpiresAt != nil { + expiry := effectiveExpiresAt.Time grace := ttlGracePeriod() deleteAt := expiry.Add(grace) if now.After(deleteAt) { - if err := r.setStatus(ctx, spritz, "Expired", "", sshInfo, "Expired", "ttl expired", deepCopyACPStatus(spritz.Status.ACP)); err != nil { + message := "maximum lifetime expired" + if lifecycleReason == spritzv1.LifecycleReasonIdleTTL { + message = "idle lifetime expired" + } + if err := r.setStatus(ctx, spritz, "Expired", "", sshInfo, "Expired", message, deepCopyACPStatus(spritz.Status.ACP)); err != nil { logger.Error(err, "failed to set expired status") } return nil, r.Delete(ctx, spritz) @@ -643,15 +654,16 @@ func (r *SpritzReconciler) reconcileStatus(ctx context.Context, spritz *spritzv1 if remaining < 0 { remaining = 0 } - message := fmt.Sprintf("ttl expired; deleting in %s", remaining.Round(time.Second)) + message := fmt.Sprintf("maximum lifetime expired; deleting in %s", remaining.Round(time.Second)) + if lifecycleReason == spritzv1.LifecycleReasonIdleTTL { + message = fmt.Sprintf("idle lifetime expired; deleting in %s", remaining.Round(time.Second)) + } if err := r.setStatus(ctx, spritz, "Expiring", spritzURL(spritz), sshInfo, "Expiring", message, deepCopyACPStatus(spritz.Status.ACP)); err != nil { return nil, err } return &remaining, nil } statusRequeue = durationPtr(time.Until(expiry)) - } else { - spritz.Status.ExpiresAt = nil } var deploy appsv1.Deployment @@ -778,30 +790,7 @@ func durationPtr(value time.Duration) *time.Duration { } func spritzURL(spritz *spritzv1.Spritz) string { - if spritz.Spec.Ingress != nil && spritz.Spec.Ingress.Host != "" { - path := spritz.Spec.Ingress.Path - if path == "" { - path = "/" - } - if path != "/" && !strings.HasSuffix(path, "/") { - path += "/" - } - return fmt.Sprintf("https://%s%s", spritz.Spec.Ingress.Host, path) - } - - if len(spritz.Spec.Ports) == 0 { - if !isWebEnabled(spritz) { - return "" - } - return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", spritz.Name, spritz.Namespace, defaultWebPort) - } - - port := spritz.Spec.Ports[0] - servicePort := port.ContainerPort - if port.ServicePort != 0 { - servicePort = port.ServicePort - } - return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", spritz.Name, spritz.Namespace, servicePort) + return spritzv1.AccessURLForSpritz(spritz) } func (r *SpritzReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -1263,13 +1252,7 @@ func emptyDirSizeLimit(key string, fallback resource.Quantity) *resource.Quantit } func isWebEnabled(spritz *spritzv1.Spritz) bool { - if spritz.Spec.Features == nil { - return true - } - if spritz.Spec.Features.Web == nil { - return true - } - return *spritz.Spec.Features.Web + return spritzv1.IsWebEnabled(spritz.Spec) } func isSSHEnabled(spritz *spritzv1.Spritz) bool { diff --git a/scripts/verify-helm.sh b/scripts/verify-helm.sh index ce5235f..aa9a760 100755 --- a/scripts/verify-helm.sh +++ b/scripts/verify-helm.sh @@ -83,6 +83,10 @@ expect_contains "${auth_annotations_render}" "authonly: enabled" "auth ingress c expect_contains "${acp_network_policy_render}" "kind: NetworkPolicy" "ACP network policy when enabled" expect_contains "${acp_network_policy_render}" "name: spritz-acp" "ACP network policy name when enabled" expect_contains "${default_render}" 'resources: ["spritzes/status", "spritzconversations/status"]' "status RBAC for spritz conversations" +expect_contains "${default_render}" "name: SPRITZ_AUTH_HEADER_TYPE" "principal type auth header wiring" +expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_SCOPES_PATHS" "bearer scope path wiring" +expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL" "default provisioner idle ttl wiring" +expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_TTL" "default provisioner ttl wiring" expect_failure \ "api.auth.mode must be header or auto when authGateway.enabled=true" \ From 6592f1d65e2c45bd8288a4d646982b0de9dedcfd Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 15:06:23 +0100 Subject: [PATCH 04/21] fix(provisioning): address service principal review findings --- api/acp_conversations.go | 2 +- api/acp_test.go | 39 ++++++++ api/auth.go | 51 ++++++++++- api/auth_middleware_test.go | 52 +++++++++++ api/main.go | 83 +++++++++++------ api/main_create_owner_test.go | 70 +++++++++++++++ api/provisioning.go | 134 ++++++++++++++++++++-------- cli/src/index.ts | 14 ++- cli/test/provisioner-create.test.ts | 63 +++++++++++++ helm/spritz/templates/api-rbac.yaml | 3 + scripts/verify-helm.sh | 1 + ui/public/acp-render.js | 2 +- ui/public/acp-render.test.mjs | 25 ++++++ 13 files changed, 470 insertions(+), 69 deletions(-) diff --git a/api/acp_conversations.go b/api/acp_conversations.go index 3c63e31..bfbfbff 100644 --- a/api/acp_conversations.go +++ b/api/acp_conversations.go @@ -48,7 +48,7 @@ func (s *server) listACPConversations(c echo.Context) error { if spritzName != "" { labels[acpConversationSpritzLabelKey] = spritzName } - if s.auth.enabled() { + if s.auth.enabled() && !principal.isAdminPrincipal() { labels[acpConversationOwnerLabelKey] = ownerLabelValue(principal.ID) } opts = append(opts, client.MatchingLabels(labels)) diff --git a/api/acp_test.go b/api/acp_test.go index 7f0fa68..ff96651 100644 --- a/api/acp_test.go +++ b/api/acp_test.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/websocket" "github.com/labstack/echo/v4" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -26,6 +27,9 @@ func newACPTestScheme(t *testing.T) *runtime.Scheme { if err := spritzv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to register spritz scheme: %v", err) } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to register core scheme: %v", err) + } return scheme } @@ -428,6 +432,41 @@ func TestListAndPatchACPConversationsByID(t *testing.T) { } } +func TestListACPConversationsAllowsAdminToSeeAllOwners(t *testing.T) { + now := metav1.Now() + spritz := readyACPSpritz("tidy-otter", "user-1") + ownerOne := conversationFor("tidy-otter-user-1", "tidy-otter", "user-1", "Owner one", now) + ownerTwo := conversationFor("tidy-otter-user-2", "tidy-otter", "user-2", "Owner two", now) + + s := newACPTestServer(t, spritz, ownerOne, ownerTwo) + s.auth.adminIDs = map[string]struct{}{"admin-1": {}} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/acp/conversations", s.listACPConversations) + + req := httptest.NewRequest(http.MethodGet, "/api/acp/conversations?spritz=tidy-otter", nil) + req.Header.Set("X-Spritz-User-Id", "admin-1") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload struct { + Status string `json:"status"` + Data struct { + Items []spritzv1.SpritzConversation `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode list response: %v", err) + } + if len(payload.Data.Items) != 2 { + t.Fatalf("expected 2 visible conversations for admin, got %d", len(payload.Data.Items)) + } +} + func TestPatchACPConversationRejectsSessionIDMutation(t *testing.T) { spritz := readyACPSpritz("tidy-otter", "user-1") conversation := conversationFor("tidy-otter-new", "tidy-otter", "user-1", "Latest", metav1.Now()) diff --git a/api/auth.go b/api/auth.go index 41d45e1..139bc75 100644 --- a/api/auth.go +++ b/api/auth.go @@ -231,7 +231,7 @@ func (a *authConfig) principal(r *http.Request) (principal, error) { id, "", normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), - splitList(r.Header.Get(a.headerScopes)), + splitScopes(r.Header.Get(a.headerScopes)), a.isAdmin(id, teams), ), nil case authModeAuto: @@ -246,7 +246,7 @@ func (a *authConfig) principal(r *http.Request) (principal, error) { id, "", normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), - splitList(r.Header.Get(a.headerScopes)), + splitScopes(r.Header.Get(a.headerScopes)), a.isAdmin(id, teams), ), nil } @@ -348,7 +348,7 @@ func (a *authConfig) introspectToken(ctx context.Context, token string) (princip firstStringPath(payload, []string{"sub"}), firstStringPath(payload, []string{"iss", "issuer"}), normalizePrincipalType(firstStringPath(payload, a.bearerTypePaths), a.bearerDefaultType), - firstStringListPath(payload, a.bearerScopesPaths), + firstScopeListPath(payload, a.bearerScopesPaths), a.isAdmin(id, teams), ), nil } @@ -434,7 +434,7 @@ func (a *authConfig) principalFromJWT(ctx context.Context, token string) (princi firstStringPath(claims, []string{"sub"}), firstStringPath(claims, []string{"iss", "issuer"}), normalizePrincipalType(firstStringPath(claims, a.bearerTypePaths), a.bearerDefaultType), - firstStringListPath(claims, a.bearerScopesPaths), + firstScopeListPath(claims, a.bearerScopesPaths), a.isAdmin(id, teams), ), nil } @@ -602,6 +602,23 @@ func splitList(value string) []string { return out } +func splitScopes(value string) []string { + if value == "" { + return nil + } + raw := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\r' || r == '\t' + }) + out := make([]string, 0, len(raw)) + for _, item := range raw { + item = strings.TrimSpace(item) + if item != "" { + out = append(out, item) + } + } + return out +} + func splitListOrDefault(value string, fallback []string) []string { items := splitList(value) if len(items) == 0 { @@ -724,6 +741,32 @@ func firstStringListPath(payload map[string]any, paths []string) []string { return nil } +func firstScopeListPath(payload map[string]any, paths []string) []string { + for _, path := range paths { + value, ok := lookupPath(payload, path) + if !ok { + continue + } + switch typed := value.(type) { + case []string: + return typed + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + items = append(items, s) + } + } + if len(items) > 0 { + return items + } + case string: + return splitScopes(typed) + } + } + return nil +} + func lookupPath(payload map[string]any, path string) (any, bool) { path = strings.TrimSpace(path) if path == "" { diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 45ebee0..7d2f04e 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -126,3 +126,55 @@ func TestAuthMiddlewareSetsPrincipalTypeAndScopes(t *testing.T) { t.Fatalf("expected two scopes, got %#v", payload["scopes"]) } } + +func TestBearerAuthParsesSpaceDelimitedScopes(t *testing.T) { + introspection := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "zenobot", + "type": "service", + "scope": "spritz.instances.create spritz.instances.assign_owner", + }) + })) + defer introspection.Close() + + t.Setenv("SPRITZ_AUTH_MODE", "bearer") + t.Setenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL", introspection.URL) + t.Setenv("SPRITZ_AUTH_BEARER_ID_PATHS", "sub") + t.Setenv("SPRITZ_AUTH_BEARER_TYPE_PATHS", "type") + t.Setenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS", "scope") + + s := &server{auth: newAuthConfig()} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "type": p.Type, + "scopes": p.Scopes, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeService) { + t.Fatalf("expected service principal type, got %#v", payload["type"]) + } + scopes, _ := payload["scopes"].([]any) + if len(scopes) != 2 { + t.Fatalf("expected two scopes, got %#v", payload["scopes"]) + } +} diff --git a/api/main.go b/api/main.go index 163f65f..80d1387 100644 --- a/api/main.go +++ b/api/main.go @@ -379,15 +379,45 @@ func (s *server) createSpritz(c echo.Context) error { } body.Spec.Owner = owner + nameProvided := body.Name != "" + var nameGenerator func() string + namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Spec.Image) + if namePrefix == "" && preset != nil { + namePrefix = resolveSpritzNamePrefix(preset.NamePrefix, preset.Image) + } + if !nameProvided { + generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + } + nameGenerator = generator + body.Name = nameGenerator() + } + if body.Name == "" { + return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + } + if principal.isService() { - fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace) + fingerprintName := body.Name + if !nameProvided { + fingerprintName = "" + } + fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName) if err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") } return writeError(c, http.StatusBadRequest, err.Error()) } - existing, err := findIdempotentSpritz(c.Request().Context(), s.client, namespace, principal.ID, body.IdempotencyKey) + reservedName, completed, err := s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, fingerprint, body.Name) + if err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + body.Name = reservedName + existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) if err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) } @@ -397,12 +427,18 @@ func (s *server) createSpritz(c echo.Context) error { } return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } + if completed { + return writeError(c, http.StatusConflict, "idempotencyKey already used") + } + if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } body.Annotations = mergeStringMap(body.Annotations, map[string]string{ - actorIDAnnotationKey: principal.ID, - actorTypeAnnotationKey: string(principal.Type), - sourceAnnotationKey: provisionerSource(&body), - requestIDAnnotationKey: body.RequestID, - idempotencyKeyAnnotationKey: body.IdempotencyKey, + actorIDAnnotationKey: principal.ID, + actorTypeAnnotationKey: string(principal.Type), + sourceAnnotationKey: provisionerSource(&body), + requestIDAnnotationKey: body.RequestID, + idempotencyKeyAnnotationKey: body.IdempotencyKey, idempotencyHashAnnotationKey: fingerprint, }) } else if s.auth.enabled() && !principal.isAdminPrincipal() && owner.ID != principal.ID { @@ -413,24 +449,6 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusBadRequest, err.Error()) } - nameProvided := body.Name != "" - var nameGenerator func() string - if !nameProvided { - namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Spec.Image) - if namePrefix == "" && preset != nil { - namePrefix = resolveSpritzNamePrefix(preset.NamePrefix, preset.Image) - } - generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) - if err != nil { - return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") - } - nameGenerator = generator - body.Name = nameGenerator() - } - if body.Name == "" { - return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") - } - labels := map[string]string{ ownerLabelKey: ownerLabelValue(owner.ID), } @@ -508,11 +526,26 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusBadRequest, err.Error()) } if err := s.client.Create(c.Request().Context(), spritz); err != nil { + if principal.isService() && apierrors.IsAlreadyExists(err) { + existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) + if getErr != nil { + return writeError(c, http.StatusInternalServerError, getErr.Error()) + } + if existing != nil && strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) == strings.TrimSpace(annotations[idempotencyHashAnnotationKey]) { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") + } if !nameProvided && apierrors.IsAlreadyExists(err) { continue } return writeError(c, http.StatusInternalServerError, err.Error()) } + if principal.isService() { + if err := s.completeIdempotencyReservation(c.Request().Context(), namespace, principal.ID, body.IdempotencyKey, spritz); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } return writeJSON(c, http.StatusCreated, summarizeCreateResponse(spritz, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, false)) } diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 13cc084..ba707f7 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/labstack/echo/v4" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -22,6 +23,9 @@ func newTestSpritzScheme(t *testing.T) *runtime.Scheme { if err := spritzv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to register spritz scheme: %v", err) } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to register core scheme: %v", err) + } return scheme } @@ -250,6 +254,31 @@ func TestCreateSpritzAllowsProvisionerToAssignOwnerOnce(t *testing.T) { } } +func TestCreateSpritzRejectsProvisionerWithoutOwnerID(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","idempotencyKey":"discord-missing-owner"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "ownerId is required") { + t.Fatalf("expected ownerId validation error, got %s", rec.Body.String()) + } +} + func TestCreateSpritzReplaysIdempotentProvisionerRequest(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -280,10 +309,19 @@ func TestCreateSpritzReplaysIdempotentProvisionerRequest(t *testing.T) { t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) } + var firstPayload map[string]any + if err := json.Unmarshal(rec1.Body.Bytes(), &firstPayload); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } var payload map[string]any if err := json.Unmarshal(rec2.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to decode replay response: %v", err) } + firstName := firstPayload["data"].(map[string]any)["spritz"].(map[string]any)["metadata"].(map[string]any)["name"] + replayedName := payload["data"].(map[string]any)["spritz"].(map[string]any)["metadata"].(map[string]any)["name"] + if firstName != replayedName { + t.Fatalf("expected idempotent replay to keep the same name, got first=%#v replay=%#v", firstName, replayedName) + } data := payload["data"].(map[string]any) if replayed, _ := data["replayed"].(bool); !replayed { t.Fatalf("expected replayed response, got %#v", data["replayed"]) @@ -322,3 +360,35 @@ func TestCreateSpritzRejectsIdempotentProvisionerPayloadMismatch(t *testing.T) { t.Fatalf("expected conflict status 409, got %d: %s", rec2.Code, rec2.Body.String()) } } + +func TestCreateSpritzReplaysIdempotentProvisionerRequestBeforeQuotaCheck(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.maxActivePerOwner = 1 + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-quota"}`) + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index 7cd17c9..674bae0 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -11,6 +11,7 @@ import ( "time" 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" @@ -33,6 +34,10 @@ const ( actorLabelKey = "spritz.sh/actor" idempotencyLabelKey = "spritz.sh/idempotency" presetLabelKey = "spritz.sh/preset" + idempotencyReservationPrefix = "spritz-idempotency-" + idempotencyReservationHashKey = "fingerprint" + idempotencyReservationNameKey = "spritzName" + idempotencyReservationDoneKey = "completed" defaultProvisionerSource = "external" defaultProvisionerIdleTTL = 24 * time.Hour defaultProvisionerMaxTTL = 7 * 24 * time.Hour @@ -56,20 +61,20 @@ type presetCatalog struct { } type provisionerPolicy struct { - allowedPresetIDs map[string]struct{} - defaultPresetID string - allowCustomImage bool - allowCustomRepo bool + allowedPresetIDs map[string]struct{} + defaultPresetID string + allowCustomImage bool + allowCustomRepo bool allowNamespaceOverride bool - allowedNamespaces map[string]struct{} - defaultIdleTTL time.Duration - maxIdleTTL time.Duration - defaultTTL time.Duration - maxTTL time.Duration - maxActivePerOwner int - maxCreatesPerActor int - maxCreatesPerOwner int - rateWindow time.Duration + allowedNamespaces map[string]struct{} + defaultIdleTTL time.Duration + maxIdleTTL time.Duration + defaultTTL time.Duration + maxTTL time.Duration + maxActivePerOwner int + maxCreatesPerActor int + maxCreatesPerOwner int + rateWindow time.Duration } type createSpritzResponse struct { @@ -130,6 +135,9 @@ func applyTopLevelCreateFields(body *createRequest) { func normalizeCreateOwner(body *createRequest, principal principal, authEnabled bool) (spritzv1.SpritzOwner, error) { owner := body.Spec.Owner + if principal.isService() && strings.TrimSpace(body.OwnerID) == "" && strings.TrimSpace(owner.ID) == "" { + return owner, fmt.Errorf("ownerId is required") + } if owner.ID == "" { if authEnabled { owner.ID = principal.ID @@ -174,7 +182,7 @@ func (p provisionerPolicy) validatePreset(presetID string) error { return fmt.Errorf("preset is not allowed: %s", presetID) } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool) (string, error) { +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { if !principalCanUseProvisionerFlow(principal) { return "", errForbidden } @@ -184,9 +192,6 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { return "", err } - if strings.TrimSpace(body.Spec.Owner.ID) == "" { - return "", fmt.Errorf("ownerId is required") - } if requestedNamespace && !s.provisioners.allowNamespaceOverride { return "", fmt.Errorf("namespace override is not allowed") } @@ -216,10 +221,7 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { return "", err } - if err := s.enforceProvisionerQuotas(ctx, namespace, principal, body.Spec.Owner.ID); err != nil { - return "", err - } - return createFingerprint(body.Spec.Owner.ID, body.PresetID, body.Name, namespace, provisionerSource(body), body.Spec, userConfig) + return createFingerprint(body.Spec.Owner.ID, body.PresetID, nameForFingerprint, namespace, provisionerSource(body), body.Spec, userConfig) } func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, principal principal, ownerID string) error { @@ -474,26 +476,84 @@ func createFingerprint(ownerID, presetID, name, namespace, source string, spec s return fmt.Sprintf("%x", sum[:]), nil } -func findIdempotentSpritz(ctx context.Context, k8sClient client.Client, namespace, actorID, idempotencyKey string) (*spritzv1.Spritz, error) { - if strings.TrimSpace(actorID) == "" || strings.TrimSpace(idempotencyKey) == "" { - return nil, nil +func idempotencyReservationName(actorID, key string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(actorID) + ":" + strings.TrimSpace(key))) + return fmt.Sprintf("%s%x", idempotencyReservationPrefix, sum[:16]) +} + +func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace string, principal principal, key, fingerprint, desiredName string) (string, bool, error) { + if strings.TrimSpace(key) == "" { + return desiredName, false, nil + } + reservationName := idempotencyReservationName(principal.ID, key) + record := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: reservationName, + Namespace: namespace, + Labels: map[string]string{ + actorLabelKey: actorLabelValue(principal.ID), + idempotencyLabelKey: idempotencyLabelValue(key), + }, + }, + Data: map[string]string{ + idempotencyReservationHashKey: fingerprint, + idempotencyReservationNameKey: desiredName, + idempotencyReservationDoneKey: "false", + }, + } + if err := s.client.Create(ctx, record); err != nil { + if !apierrors.IsAlreadyExists(err) { + return "", false, err + } + existing := &corev1.ConfigMap{} + if getErr := s.client.Get(ctx, clientKey(namespace, reservationName), existing); getErr != nil { + return "", false, getErr + } + if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != fingerprint { + return "", false, fmt.Errorf("idempotencyKey already used with a different request") + } + name := strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) + if name == "" { + name = desiredName + } + done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") + return name, done, nil } - list := &spritzv1.SpritzList{} - opts := []client.ListOption{ - client.InNamespace(namespace), - client.MatchingLabels(map[string]string{ - actorLabelKey: actorLabelValue(actorID), - idempotencyLabelKey: idempotencyLabelValue(idempotencyKey), - }), - } - if err := k8sClient.List(ctx, list, opts...); err != nil { - return nil, err + return desiredName, false, nil +} + +func (s *server) completeIdempotencyReservation(ctx context.Context, namespace, actorID, key string, spritz *spritzv1.Spritz) error { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" || spritz == nil { + return nil + } + reservationName := idempotencyReservationName(actorID, key) + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(namespace, reservationName), current); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err } - if len(list.Items) == 0 { + if current.Data == nil { + current.Data = map[string]string{} + } + current.Data[idempotencyReservationNameKey] = spritz.Name + current.Data[idempotencyReservationDoneKey] = "true" + return s.client.Update(ctx, current) +} + +func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) (*spritzv1.Spritz, error) { + if strings.TrimSpace(name) == "" { return nil, nil } - item := list.Items[0].DeepCopy() - return item, nil + spritz := &spritzv1.Spritz{} + if err := s.client.Get(ctx, clientKey(namespace, name), spritz); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return spritz, nil } func summarizeCreateResponse(spritz *spritzv1.Spritz, principal principal, presetID, source, idempotencyKey string, replayed bool) createSpritzResponse { diff --git a/cli/src/index.ts b/cli/src/index.ts index b8d6d2e..b403d73 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -567,6 +567,17 @@ async function authHeaders(): Promise> { return headers; } +async function resolveDefaultOwnerId(): Promise { + const { profile } = await resolveProfile({ allowFlag: true }); + return ( + process.env.SPRITZ_OWNER_ID || + process.env.SPRITZ_USER_ID || + profile?.userId || + process.env.USER || + undefined + ); +} + async function request(path: string, init?: RequestInit) { const controller = new AbortController(); const timeoutMs = Number.isFinite(requestTimeoutMs) ? requestTimeoutMs : 10000; @@ -1069,7 +1080,8 @@ async function main() { const repo = argValue('--repo'); const branch = argValue('--branch'); - const ownerId = argValue('--owner-id') || process.env.SPRITZ_OWNER_ID; + const token = argValue('--token') || process.env.SPRITZ_BEARER_TOKEN; + const ownerId = argValue('--owner-id') || (token?.trim() ? process.env.SPRITZ_OWNER_ID : await resolveDefaultOwnerId()); const idleTtl = argValue('--idle-ttl'); const ttl = argValue('--ttl'); const idempotencyKey = argValue('--idempotency-key'); diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index 2fa3d2d..0eee284 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -83,3 +83,66 @@ test('create uses bearer auth and provisioner fields for preset-based creation', assert.equal(payload.ownerId, 'user-123'); assert.equal(payload.presetId, 'openclaw'); }); + +test('create falls back to local owner identity without bearer auth', async (t) => { + let requestBody: any = null; + let requestHeaders: http.IncomingHttpHeaders | null = null; + + const server = http.createServer((req, res) => { + requestHeaders = req.headers; + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'success', + data: { + accessUrl: 'http://localhost:8080/w/claude-code-tender-otter/', + ownerId: 'local-user', + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const configDir = mkdtempSync(path.join(os.tmpdir(), 'spz-config-')); + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--image', 'example.com/spritz-claude-code:latest'], + { + env: { + ...process.env, + SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`, + SPRITZ_USER_ID: 'local-user', + SPRITZ_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(exitCode, 0, `spz create should succeed: ${stderr}`); + + assert.equal(requestHeaders?.authorization, undefined); + assert.equal(requestHeaders?.['x-spritz-user-id'], 'local-user'); + assert.equal(requestBody.ownerId, 'local-user'); + assert.equal(requestBody.spec.image, 'example.com/spritz-claude-code:latest'); + + const payload = JSON.parse(stdout); + assert.equal(payload.ownerId, 'local-user'); +}); diff --git a/helm/spritz/templates/api-rbac.yaml b/helm/spritz/templates/api-rbac.yaml index 8ef5468..c54601a 100644 --- a/helm/spritz/templates/api-rbac.yaml +++ b/helm/spritz/templates/api-rbac.yaml @@ -13,6 +13,9 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "create", "update"] - apiGroups: [""] resources: ["pods/exec"] verbs: ["create"] diff --git a/scripts/verify-helm.sh b/scripts/verify-helm.sh index aa9a760..547d2a8 100755 --- a/scripts/verify-helm.sh +++ b/scripts/verify-helm.sh @@ -87,6 +87,7 @@ expect_contains "${default_render}" "name: SPRITZ_AUTH_HEADER_TYPE" "principal t expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_SCOPES_PATHS" "bearer scope path wiring" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL" "default provisioner idle ttl wiring" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_TTL" "default provisioner ttl wiring" +expect_contains "${default_render}" 'resources: ["configmaps"]' "configmap RBAC for idempotency reservations" expect_failure \ "api.auth.mode must be header or auto when authGateway.enabled=true" \ diff --git a/ui/public/acp-render.js b/ui/public/acp-render.js index 250e7be..20a9150 100644 --- a/ui/public/acp-render.js +++ b/ui/public/acp-render.js @@ -219,7 +219,7 @@ } function sanitizeHydratedMessage(message) { - const type = message?.type || 'system'; + const type = message?.type || message?.kind || 'system'; const hadHtmlError = Array.isArray(message?.blocks) ? message.blocks.some((block) => { if (!block || typeof block !== 'object') return false; diff --git a/ui/public/acp-render.test.mjs b/ui/public/acp-render.test.mjs index d2a87c7..88725b3 100644 --- a/ui/public/acp-render.test.mjs +++ b/ui/public/acp-render.test.mjs @@ -232,3 +232,28 @@ test('ACP render adapter coalesces bootstrap replay chunks for the same historic assert.equal(transcript.messages.length, 1); assert.equal(transcript.messages[0].blocks[0].text, 'Earlier assistant message'); }); + +test('ACP render adapter hydrates legacy cached messages that used kind', () => { + const ACPRender = loadRenderModule(); + const transcript = ACPRender.hydrateTranscript({ + messages: [ + { + id: 'legacy-user', + kind: 'user', + blocks: [{ type: 'text', text: 'Legacy user message' }], + toolCallId: '', + }, + { + id: 'legacy-tool', + kind: 'tool', + blocks: [{ type: 'details', title: 'Result', text: 'done', open: true }], + toolCallId: 'tool-legacy', + }, + ], + }); + + assert.equal(transcript.messages.length, 2); + assert.equal(transcript.messages[0].type, 'user'); + assert.equal(transcript.messages[1].type, 'tool'); + assert.equal(transcript.toolCallIndex.get('tool-legacy'), 1); +}); From dc41888928aa9a0400022d0419b2f0ee61a868c2 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 15:25:27 +0100 Subject: [PATCH 05/21] fix(provisioning): address local review findings --- api/auth.go | 2 +- api/auth_middleware_test.go | 46 ++++++++++++++++++ api/main.go | 44 ++++++++++-------- api/main_create_owner_test.go | 78 +++++++++++++++++++++++++++++++ api/random_name.go | 3 ++ api/terminal.go | 17 +++++-- api/terminal_test.go | 87 +++++++++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 api/terminal_test.go diff --git a/api/auth.go b/api/auth.go index 139bc75..34a08c3 100644 --- a/api/auth.go +++ b/api/auth.go @@ -119,7 +119,7 @@ func newAuthConfig() authConfig { bearerTeamsPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TEAMS_PATHS"), nil), bearerTypePaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TYPE_PATHS"), nil), bearerScopesPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS"), []string{"scope", "scopes", "scp"}), - bearerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_BEARER_DEFAULT_TYPE", string(principalTypeHuman)), principalTypeHuman), + bearerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_BEARER_DEFAULT_TYPE", string(principalTypeService)), principalTypeService), bearerAuthorizationHeader: envOrDefault("SPRITZ_AUTH_BEARER_HEADER", "Authorization"), bearerJWKSURL: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_JWKS_URL")), bearerJWKSIssuer: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_ISSUER")), diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 7d2f04e..001e9d5 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -178,3 +178,49 @@ func TestBearerAuthParsesSpaceDelimitedScopes(t *testing.T) { t.Fatalf("expected two scopes, got %#v", payload["scopes"]) } } + +func TestBearerAuthDefaultsToServiceTypeWithoutTypeClaim(t *testing.T) { + introspection := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "zenobot", + "scope": "spritz.instances.create spritz.instances.assign_owner", + }) + })) + defer introspection.Close() + + t.Setenv("SPRITZ_AUTH_MODE", "auto") + t.Setenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL", introspection.URL) + t.Setenv("SPRITZ_AUTH_BEARER_ID_PATHS", "sub") + t.Setenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS", "scope") + + s := &server{auth: newAuthConfig()} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "type": p.Type, + "scopes": p.Scopes, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeService) { + t.Fatalf("expected default bearer principal type to be service, got %#v", payload["type"]) + } +} diff --git a/api/main.go b/api/main.go index 80d1387..5883f26 100644 --- a/api/main.go +++ b/api/main.go @@ -29,26 +29,27 @@ import ( ) type server struct { - client client.Client - clientset *kubernetes.Clientset - restConfig *rest.Config - scheme *runtime.Scheme - namespace string - auth authConfig - internalAuth internalAuthConfig - ingressDefaults ingressDefaults - terminal terminalConfig - sshGateway sshGatewayConfig - sshDefaults sshDefaults - sshMintLimiter *sshMintLimiter - acp acpConfig - presets presetCatalog - provisioners provisionerPolicy - defaultMetadata map[string]string - sharedMounts sharedMountsConfig - sharedMountsStore *sharedMountsStore - sharedMountsLive *sharedMountsLatestNotifier - userConfigPolicy userConfigPolicy + client client.Client + clientset *kubernetes.Clientset + restConfig *rest.Config + scheme *runtime.Scheme + namespace string + auth authConfig + internalAuth internalAuthConfig + ingressDefaults ingressDefaults + terminal terminalConfig + sshGateway sshGatewayConfig + sshDefaults sshDefaults + sshMintLimiter *sshMintLimiter + acp acpConfig + presets presetCatalog + provisioners provisionerPolicy + defaultMetadata map[string]string + sharedMounts sharedMountsConfig + sharedMountsStore *sharedMountsStore + sharedMountsLive *sharedMountsLatestNotifier + userConfigPolicy userConfigPolicy + nameGeneratorFactory func(context.Context, string, string) (func() string, error) } func main() { @@ -534,6 +535,9 @@ func (s *server) createSpritz(c echo.Context) error { if existing != nil && strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) == strings.TrimSpace(annotations[idempotencyHashAnnotationKey]) { return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } + if !nameProvided { + continue + } return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } if !nameProvided && apierrors.IsAlreadyExists(err) { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index ba707f7..ff4dbd5 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -11,7 +12,10 @@ import ( "github.com/labstack/echo/v4" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "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" spritzv1 "spritz.sh/operator/api/v1" @@ -49,6 +53,20 @@ func newCreateSpritzTestServer(t *testing.T) *server { } } +type createInterceptClient struct { + client.Client + onCreate func(context.Context, client.Object) error +} + +func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if c.onCreate != nil { + if err := c.onCreate(ctx, obj); err != nil { + return err + } + } + return c.Client.Create(ctx, obj, opts...) +} + func configureProvisionerTestServer(s *server) { s.presets = presetCatalog{ byID: []runtimePreset{{ @@ -392,3 +410,63 @@ func TestCreateSpritzReplaysIdempotentProvisionerRequestBeforeQuotaCheck(t *test t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) } } + +func TestCreateSpritzRetriesGeneratedServiceNameAfterAlreadyExists(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + baseClient := s.client + s.client = &createInterceptClient{ + Client: baseClient, + onCreate: func(_ context.Context, obj client.Object) error { + spritz, ok := obj.(*spritzv1.Spritz) + if !ok { + return nil + } + if spritz.Name == "openclaw-first" { + return apierrors.NewAlreadyExists(schema.GroupResource{ + Group: spritzv1.GroupVersion.Group, + Resource: "spritzes", + }, spritz.Name) + } + return nil + }, + } + s.nameGeneratorFactory = func(context.Context, string, string) (func() string, error) { + names := []string{"openclaw-first", "openclaw-second"} + index := 0 + return func() string { + name := names[index] + if index < len(names)-1 { + index++ + } + return name + }, nil + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-race"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201 after autogenerated name retry, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + name := payload["data"].(map[string]any)["spritz"].(map[string]any)["metadata"].(map[string]any)["name"] + if name != "openclaw-second" { + t.Fatalf("expected second generated name after race, got %#v", name) + } +} diff --git a/api/random_name.go b/api/random_name.go index 9302aa4..41dd6f2 100644 --- a/api/random_name.go +++ b/api/random_name.go @@ -296,6 +296,9 @@ func randomSuffix(length int) string { } func (s *server) newSpritzNameGenerator(ctx context.Context, namespace string, prefix string) (func() string, error) { + if s.nameGeneratorFactory != nil { + return s.nameGeneratorFactory(ctx, namespace, prefix) + } list := &spritzv1.SpritzList{} opts := []client.ListOption{client.InNamespace(namespace)} if err := s.client.List(ctx, list, opts...); err != nil { diff --git a/api/terminal.go b/api/terminal.go index 16c240b..b76ab0a 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -165,7 +165,7 @@ func (s *server) openTerminal(c echo.Context) error { if usingZmx { log.Printf("spritz terminal: zmx attach name=%s namespace=%s session=%s user_id=%s", name, namespace, resolvedSession, principal.ID) } - if err := s.streamTerminal(c.Request().Context(), pod, conn, command); err != nil { + if err := s.streamTerminal(c.Request().Context(), namespace, name, pod, conn, command); err != nil { if errors.Is(err, context.Canceled) { return nil } @@ -202,7 +202,7 @@ func (s *server) findRunningPod(ctx context.Context, namespace, name, container return nil, fmt.Errorf("spritz not ready") } -func (s *server) streamTerminal(ctx context.Context, pod *corev1.Pod, conn *websocket.Conn, command []string) error { +func (s *server) streamTerminal(ctx context.Context, namespace, name string, pod *corev1.Pod, conn *websocket.Conn, command []string) error { if len(command) == 0 { return errors.New("terminal command missing") } @@ -236,7 +236,13 @@ func (s *server) streamTerminal(ctx context.Context, pod *corev1.Pod, conn *webs readErr := make(chan error, 1) go func() { - readErr <- readTerminalInput(ctx, conn, stdinWriter, sizeQueue) + readErr <- readTerminalInput(ctx, conn, stdinWriter, sizeQueue, func() { + refreshCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.markSpritzActivity(refreshCtx, namespace, name, time.Now()); err != nil { + log.Printf("spritz terminal: failed to refresh activity name=%s namespace=%s pod=%s err=%v", name, namespace, pod.Name, err) + } + }) }() streamErr := executor.StreamWithContext(ctx, remotecommand.StreamOptions{ @@ -268,7 +274,7 @@ type resizeMessage struct { Rows int `json:"rows"` } -func readTerminalInput(ctx context.Context, conn *websocket.Conn, stdin *io.PipeWriter, sizeQueue *terminalSizeQueue) error { +func readTerminalInput(ctx context.Context, conn *websocket.Conn, stdin *io.PipeWriter, sizeQueue *terminalSizeQueue, onInput func()) error { for { select { case <-ctx.Done(): @@ -290,6 +296,9 @@ func readTerminalInput(ctx context.Context, conn *websocket.Conn, stdin *io.Pipe if _, err := stdin.Write(payload); err != nil { return err } + if onInput != nil { + onInput() + } } } } diff --git a/api/terminal_test.go b/api/terminal_test.go new file mode 100644 index 0000000..ba2b978 --- /dev/null +++ b/api/terminal_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestReadTerminalInputInvokesActivityCallbackOnInput(t *testing.T) { + upgrader := websocket.Upgrader{} + serverConn := make(chan *websocket.Conn, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade failed: %v", err) + } + serverConn <- conn + })) + defer srv.Close() + + wsURL, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("failed to parse server url: %v", err) + } + wsURL.Scheme = "ws" + clientConn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil) + if err != nil { + t.Fatalf("failed to dial websocket: %v", err) + } + defer clientConn.Close() + + conn := <-serverConn + defer conn.Close() + + reader, writer := io.Pipe() + defer reader.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var callbacks atomic.Int32 + done := make(chan error, 1) + go func() { + done <- readTerminalInput(ctx, conn, writer, newTerminalSizeQueue(), func() { + callbacks.Add(1) + }) + }() + + if err := clientConn.WriteMessage(websocket.TextMessage, []byte(`{"type":"resize","cols":80,"rows":24}`)); err != nil { + t.Fatalf("failed to send resize message: %v", err) + } + if callbacks.Load() != 0 { + t.Fatalf("expected resize message to skip activity callback, got %d", callbacks.Load()) + } + + if err := clientConn.WriteMessage(websocket.TextMessage, []byte("ls\n")); err != nil { + t.Fatalf("failed to send terminal input: %v", err) + } + + buf := make([]byte, 3) + if _, err := io.ReadFull(reader, buf); err != nil { + t.Fatalf("failed to read stdin payload: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for callbacks.Load() != 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if callbacks.Load() != 1 { + t.Fatalf("expected one activity callback for terminal input, got %d", callbacks.Load()) + } + + cancel() + _ = clientConn.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for terminal reader to exit") + } +} From cff2ef51de92e033c12137310277fec440bce44d Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 15:43:06 +0100 Subject: [PATCH 06/21] fix(provisioning): tighten service principal policy --- api/main.go | 11 +++- api/main_create_owner_test.go | 64 +++++++++++++++++++++++ api/provisioning.go | 60 ++++++++++++++++++++- api/terminal.go | 59 +++++++++++++++------ api/terminal_test.go | 20 +++++++ helm/spritz/templates/api-deployment.yaml | 4 ++ helm/spritz/values.yaml | 3 +- scripts/verify-helm.sh | 3 ++ 8 files changed, 204 insertions(+), 20 deletions(-) diff --git a/api/main.go b/api/main.go index 5883f26..25b44ed 100644 --- a/api/main.go +++ b/api/main.go @@ -330,6 +330,9 @@ func (s *server) createSpritz(c echo.Context) error { if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) } + if principal.isService() && len(userConfigKeys) > 0 { + return writeError(c, http.StatusBadRequest, "userConfig is not allowed for service principals") + } var normalizedUserConfig json.RawMessage if len(userConfigKeys) > 0 { normalized, err := normalizeUserConfig(s.userConfigPolicy, userConfigKeys, userConfigPayload) @@ -343,6 +346,12 @@ func (s *server) createSpritz(c echo.Context) error { } normalizedUserConfig = encodedUserConfig applyUserConfig(&body.Spec, userConfigKeys, userConfigPayload) + if _, ok := userConfigKeys["image"]; ok { + requestedImage = strings.TrimSpace(body.Spec.Image) != "" + } + if _, ok := userConfigKeys["repo"]; ok { + requestedRepo = body.Spec.Repo != nil || len(body.Spec.Repos) > 0 + } } if body.Spec.Image == "" { @@ -403,7 +412,7 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { fingerprintName = "" } - fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName) + fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, userConfigKeys, requestedImage, requestedRepo, requestedNamespace, fingerprintName) if err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index ff4dbd5..d81ea09 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -470,3 +470,67 @@ func TestCreateSpritzRetriesGeneratedServiceNameAfterAlreadyExists(t *testing.T) t.Fatalf("expected second generated name after race, got %#v", name) } } + +func TestCreateSpritzRejectsProvisionerLowLevelSpecFields(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{ + "presetId":"openclaw", + "ownerId":"user-123", + "idempotencyKey":"discord-low-level", + "spec":{ + "env":[{"name":"SHOULD_NOT_PASS","value":"1"}] + } + }`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "spec.env is not allowed") { + t.Fatalf("expected low-level spec validation error, got %s", rec.Body.String()) + } +} + +func TestCreateSpritzRejectsProvisionerUserConfigOverrides(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{ + "presetId":"openclaw", + "ownerId":"user-123", + "idempotencyKey":"discord-user-config", + "userConfig":{ + "repo":{"url":"https://example.com/private.git"} + } + }`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "userConfig is not allowed") { + t.Fatalf("expected service userConfig validation error, got %s", rec.Body.String()) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index 674bae0..7e448d6 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "reflect" "sort" "strings" "time" @@ -148,6 +149,58 @@ func normalizeCreateOwner(body *createRequest, principal principal, authEnabled return owner, nil } +func validateProvisionerRequestSurface(body *createRequest, userConfigKeys map[string]json.RawMessage) error { + if body == nil { + return nil + } + if len(userConfigKeys) > 0 { + return fmt.Errorf("userConfig is not allowed for service principals") + } + if body.Spec.Owner.Team != "" { + return fmt.Errorf("spec.owner.team is not allowed") + } + if len(body.Labels) > 0 { + return fmt.Errorf("labels are not allowed for service principals") + } + if len(body.Annotations) > 0 { + return fmt.Errorf("annotations are not allowed for service principals") + } + if len(body.Spec.Labels) > 0 { + return fmt.Errorf("spec.labels are not allowed") + } + if len(body.Spec.Annotations) > 0 { + return fmt.Errorf("spec.annotations are not allowed") + } + if len(body.Spec.Env) > 0 { + return fmt.Errorf("spec.env is not allowed") + } + if len(body.Spec.Repos) > 0 { + return fmt.Errorf("spec.repos is not allowed") + } + if body.Spec.Repo != nil && body.Spec.Repo.Auth != nil { + return fmt.Errorf("spec.repo.auth is not allowed") + } + if len(body.Spec.SharedMounts) > 0 { + return fmt.Errorf("spec.sharedMounts is not allowed") + } + if !reflect.DeepEqual(body.Spec.Resources, corev1.ResourceRequirements{}) { + return fmt.Errorf("spec.resources is not allowed") + } + if body.Spec.Features != nil { + return fmt.Errorf("spec.features is not allowed") + } + if body.Spec.SSH != nil { + return fmt.Errorf("spec.ssh is not allowed") + } + if len(body.Spec.Ports) > 0 { + return fmt.Errorf("spec.ports is not allowed") + } + if body.Spec.Ingress != nil { + return fmt.Errorf("spec.ingress is not allowed") + } + return nil +} + func provisionerSource(body *createRequest) string { source := strings.TrimSpace(body.Source) if source == "" { @@ -182,7 +235,7 @@ func (p provisionerPolicy) validatePreset(presetID string) error { return fmt.Errorf("preset is not allowed: %s", presetID) } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, userConfigKeys map[string]json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { if !principalCanUseProvisionerFlow(principal) { return "", errForbidden } @@ -192,6 +245,11 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { return "", err } + if principal.isService() { + if err := validateProvisionerRequestSurface(body, userConfigKeys); err != nil { + return "", err + } + } if requestedNamespace && !s.provisioners.allowNamespaceOverride { return "", fmt.Errorf("namespace override is not allowed") } diff --git a/api/terminal.go b/api/terminal.go index b76ab0a..5682ce3 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -27,11 +27,12 @@ import ( ) type terminalConfig struct { - enabled bool - containerName string - command []string - allowedOrigins map[string]struct{} - sessionMode terminalSessionMode + enabled bool + containerName string + command []string + allowedOrigins map[string]struct{} + sessionMode terminalSessionMode + activityDebounce time.Duration } type terminalSessionMode string @@ -43,11 +44,12 @@ const ( func newTerminalConfig() terminalConfig { return terminalConfig{ - enabled: parseBoolEnv("SPRITZ_TERMINAL_ENABLED", true), - containerName: envOrDefault("SPRITZ_TERMINAL_CONTAINER", "spritz"), - command: splitCommand(envOrDefault("SPRITZ_TERMINAL_COMMAND", "bash -l")), - allowedOrigins: splitSet(os.Getenv("SPRITZ_TERMINAL_ORIGINS")), - sessionMode: parseTerminalSessionMode(os.Getenv("SPRITZ_TERMINAL_SESSION_MODE")), + enabled: parseBoolEnv("SPRITZ_TERMINAL_ENABLED", true), + containerName: envOrDefault("SPRITZ_TERMINAL_CONTAINER", "spritz"), + command: splitCommand(envOrDefault("SPRITZ_TERMINAL_COMMAND", "bash -l")), + allowedOrigins: splitSet(os.Getenv("SPRITZ_TERMINAL_ORIGINS")), + sessionMode: parseTerminalSessionMode(os.Getenv("SPRITZ_TERMINAL_SESSION_MODE")), + activityDebounce: parseDurationEnv("SPRITZ_TERMINAL_ACTIVITY_DEBOUNCE", 5*time.Second), } } @@ -233,16 +235,17 @@ func (s *server) streamTerminal(ctx context.Context, namespace, name string, pod stdinReader, stdinWriter := io.Pipe() sizeQueue := newTerminalSizeQueue() wsWriter := &terminalWSWriter{conn: conn} + reportActivity := debounceTerminalActivity(s.terminal.activityDebounce, func() { + refreshCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.markSpritzActivity(refreshCtx, namespace, name, time.Now()); err != nil { + log.Printf("spritz terminal: failed to refresh activity name=%s namespace=%s pod=%s err=%v", name, namespace, pod.Name, err) + } + }) readErr := make(chan error, 1) go func() { - readErr <- readTerminalInput(ctx, conn, stdinWriter, sizeQueue, func() { - refreshCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := s.markSpritzActivity(refreshCtx, namespace, name, time.Now()); err != nil { - log.Printf("spritz terminal: failed to refresh activity name=%s namespace=%s pod=%s err=%v", name, namespace, pod.Name, err) - } - }) + readErr <- readTerminalInput(ctx, conn, stdinWriter, sizeQueue, reportActivity) }() streamErr := executor.StreamWithContext(ctx, remotecommand.StreamOptions{ @@ -303,6 +306,28 @@ func readTerminalInput(ctx context.Context, conn *websocket.Conn, stdin *io.Pipe } } +func debounceTerminalActivity(interval time.Duration, report func()) func() { + if report == nil { + return func() {} + } + if interval <= 0 { + return report + } + var mu sync.Mutex + var last time.Time + return func() { + now := time.Now() + mu.Lock() + if !last.IsZero() && now.Sub(last) < interval { + mu.Unlock() + return + } + last = now + mu.Unlock() + report() + } +} + func handleResize(payload []byte, sizeQueue *terminalSizeQueue) bool { var msg resizeMessage if err := json.Unmarshal(payload, &msg); err != nil { diff --git a/api/terminal_test.go b/api/terminal_test.go index ba2b978..f57c156 100644 --- a/api/terminal_test.go +++ b/api/terminal_test.go @@ -85,3 +85,23 @@ func TestReadTerminalInputInvokesActivityCallbackOnInput(t *testing.T) { t.Fatal("timed out waiting for terminal reader to exit") } } + +func TestDebounceTerminalActivityCoalescesRapidInput(t *testing.T) { + var callbacks atomic.Int32 + report := debounceTerminalActivity(50*time.Millisecond, func() { + callbacks.Add(1) + }) + + report() + report() + report() + if callbacks.Load() != 1 { + t.Fatalf("expected rapid calls to coalesce into one activity write, got %d", callbacks.Load()) + } + + time.Sleep(60 * time.Millisecond) + report() + if callbacks.Load() != 2 { + t.Fatalf("expected a second activity write after debounce window, got %d", callbacks.Load()) + } +} diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 05ae0ce..5178dd3 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -209,6 +209,10 @@ spec: - name: SPRITZ_TERMINAL_ORIGINS value: {{ join "," .Values.api.terminal.origins | quote }} {{- end }} + {{- if .Values.api.terminal.activityDebounce }} + - name: SPRITZ_TERMINAL_ACTIVITY_DEBOUNCE + value: {{ .Values.api.terminal.activityDebounce | quote }} + {{- end }} {{- end }} - name: SPRITZ_ACP_ENABLED value: {{ .Values.acp.enabled | quote }} diff --git a/helm/spritz/values.yaml b/helm/spritz/values.yaml index fa2f232..04fff27 100644 --- a/helm/spritz/values.yaml +++ b/helm/spritz/values.yaml @@ -162,7 +162,7 @@ api: - scope - scopes - scp - defaultType: human + defaultType: service provisioners: defaultPresetId: "" allowedPresetIds: [] @@ -196,6 +196,7 @@ api: container: spritz command: "bash -l" origins: [] + activityDebounce: 5s acp: origins: [] sshGateway: diff --git a/scripts/verify-helm.sh b/scripts/verify-helm.sh index 547d2a8..5e14658 100755 --- a/scripts/verify-helm.sh +++ b/scripts/verify-helm.sh @@ -85,8 +85,11 @@ expect_contains "${acp_network_policy_render}" "name: spritz-acp" "ACP network p expect_contains "${default_render}" 'resources: ["spritzes/status", "spritzconversations/status"]' "status RBAC for spritz conversations" expect_contains "${default_render}" "name: SPRITZ_AUTH_HEADER_TYPE" "principal type auth header wiring" expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_SCOPES_PATHS" "bearer scope path wiring" +expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_DEFAULT_TYPE" "bearer default type wiring" +expect_contains "${default_render}" "value: \"service\"" "service default bearer principal type in chart render" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL" "default provisioner idle ttl wiring" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_TTL" "default provisioner ttl wiring" +expect_contains "${default_render}" "name: SPRITZ_TERMINAL_ACTIVITY_DEBOUNCE" "terminal activity debounce wiring" expect_contains "${default_render}" 'resources: ["configmaps"]' "configmap RBAC for idempotency reservations" expect_failure \ From 2c735528ac54058b17863863715a7e02bf61f586 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 15:52:50 +0100 Subject: [PATCH 07/21] fix(provisioning): preserve preset and bearer defaults --- api/auth.go | 9 +++-- api/auth_middleware_test.go | 44 +++++++++++++++++++++++ api/main.go | 7 +++- api/main_create_owner_test.go | 23 ++++++++++++ api/provisioning.go | 12 ++----- helm/spritz/templates/api-deployment.yaml | 2 ++ helm/spritz/values.yaml | 2 +- scripts/verify-helm.sh | 3 +- 8 files changed, 86 insertions(+), 16 deletions(-) diff --git a/api/auth.go b/api/auth.go index 34a08c3..85c4a2d 100644 --- a/api/auth.go +++ b/api/auth.go @@ -98,8 +98,13 @@ const ( ) func newAuthConfig() authConfig { + mode := normalizeAuthMode(os.Getenv("SPRITZ_AUTH_MODE")) + bearerDefaultType := principalTypeHuman + if mode == authModeAuto { + bearerDefaultType = principalTypeService + } return authConfig{ - mode: normalizeAuthMode(os.Getenv("SPRITZ_AUTH_MODE")), + mode: mode, headerID: envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id"), headerEmail: envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email"), headerTeams: envOrDefault("SPRITZ_AUTH_HEADER_TEAMS", "X-Spritz-User-Teams"), @@ -119,7 +124,7 @@ func newAuthConfig() authConfig { bearerTeamsPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TEAMS_PATHS"), nil), bearerTypePaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_TYPE_PATHS"), nil), bearerScopesPaths: splitListOrDefault(os.Getenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS"), []string{"scope", "scopes", "scp"}), - bearerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_BEARER_DEFAULT_TYPE", string(principalTypeService)), principalTypeService), + bearerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_BEARER_DEFAULT_TYPE", string(bearerDefaultType)), bearerDefaultType), bearerAuthorizationHeader: envOrDefault("SPRITZ_AUTH_BEARER_HEADER", "Authorization"), bearerJWKSURL: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_JWKS_URL")), bearerJWKSIssuer: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_ISSUER")), diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 001e9d5..de67906 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -224,3 +224,47 @@ func TestBearerAuthDefaultsToServiceTypeWithoutTypeClaim(t *testing.T) { t.Fatalf("expected default bearer principal type to be service, got %#v", payload["type"]) } } + +func TestBearerAuthDefaultsToHumanTypeWithoutTypeClaimInBearerMode(t *testing.T) { + introspection := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "user-123", + "email": "user@example.com", + }) + })) + defer introspection.Close() + + t.Setenv("SPRITZ_AUTH_MODE", "bearer") + t.Setenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL", introspection.URL) + t.Setenv("SPRITZ_AUTH_BEARER_ID_PATHS", "sub") + + s := &server{auth: newAuthConfig()} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "type": p.Type, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeHuman) { + t.Fatalf("expected default bearer principal type to stay human in bearer mode, got %#v", payload["type"]) + } +} diff --git a/api/main.go b/api/main.go index 25b44ed..70ba9fa 100644 --- a/api/main.go +++ b/api/main.go @@ -317,6 +317,11 @@ func (s *server) createSpritz(c echo.Context) error { body.Name = strings.TrimSpace(body.Name) body.NamePrefix = strings.TrimSpace(body.NamePrefix) applyTopLevelCreateFields(&body) + if principal.isService() { + if err := validateProvisionerRequestSurface(&body); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + } requestedNamespace := strings.TrimSpace(body.Namespace) != "" requestedImage := strings.TrimSpace(body.Spec.Image) != "" @@ -412,7 +417,7 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { fingerprintName = "" } - fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, userConfigKeys, requestedImage, requestedRepo, requestedNamespace, fingerprintName) + fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName) if err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index d81ea09..beff3b7 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -534,3 +534,26 @@ func TestCreateSpritzRejectsProvisionerUserConfigOverrides(t *testing.T) { t.Fatalf("expected service userConfig validation error, got %s", rec.Body.String()) } } + +func TestCreateSpritzAllowsProvisionerPresetWithInjectedEnv(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.presets.byID[0].Env = []corev1.EnvVar{{Name: "OPENCLAW_CONFIG_JSON", Value: "{}"}} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-preset-env"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected preset-backed service create to succeed, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index 7e448d6..86c1967 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -149,13 +149,10 @@ func normalizeCreateOwner(body *createRequest, principal principal, authEnabled return owner, nil } -func validateProvisionerRequestSurface(body *createRequest, userConfigKeys map[string]json.RawMessage) error { +func validateProvisionerRequestSurface(body *createRequest) error { if body == nil { return nil } - if len(userConfigKeys) > 0 { - return fmt.Errorf("userConfig is not allowed for service principals") - } if body.Spec.Owner.Team != "" { return fmt.Errorf("spec.owner.team is not allowed") } @@ -235,7 +232,7 @@ func (p provisionerPolicy) validatePreset(presetID string) error { return fmt.Errorf("preset is not allowed: %s", presetID) } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, userConfigKeys map[string]json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { if !principalCanUseProvisionerFlow(principal) { return "", errForbidden } @@ -245,11 +242,6 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { return "", err } - if principal.isService() { - if err := validateProvisionerRequestSurface(body, userConfigKeys); err != nil { - return "", err - } - } if requestedNamespace && !s.provisioners.allowNamespaceOverride { return "", fmt.Errorf("namespace override is not allowed") } diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 5178dd3..5d9574c 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -112,8 +112,10 @@ spec: - name: SPRITZ_AUTH_BEARER_SCOPES_PATHS value: {{ join "," .Values.api.auth.bearer.scopesPaths | quote }} {{- end }} + {{- if .Values.api.auth.bearer.defaultType }} - name: SPRITZ_AUTH_BEARER_DEFAULT_TYPE value: {{ .Values.api.auth.bearer.defaultType | quote }} + {{- end }} {{- if .Values.api.auth.bearer.jwks.url }} - name: SPRITZ_AUTH_BEARER_JWKS_URL value: {{ .Values.api.auth.bearer.jwks.url | quote }} diff --git a/helm/spritz/values.yaml b/helm/spritz/values.yaml index 04fff27..1e5f9bf 100644 --- a/helm/spritz/values.yaml +++ b/helm/spritz/values.yaml @@ -162,7 +162,7 @@ api: - scope - scopes - scp - defaultType: service + defaultType: "" provisioners: defaultPresetId: "" allowedPresetIds: [] diff --git a/scripts/verify-helm.sh b/scripts/verify-helm.sh index 5e14658..761f99c 100755 --- a/scripts/verify-helm.sh +++ b/scripts/verify-helm.sh @@ -85,8 +85,7 @@ expect_contains "${acp_network_policy_render}" "name: spritz-acp" "ACP network p expect_contains "${default_render}" 'resources: ["spritzes/status", "spritzconversations/status"]' "status RBAC for spritz conversations" expect_contains "${default_render}" "name: SPRITZ_AUTH_HEADER_TYPE" "principal type auth header wiring" expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_SCOPES_PATHS" "bearer scope path wiring" -expect_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_DEFAULT_TYPE" "bearer default type wiring" -expect_contains "${default_render}" "value: \"service\"" "service default bearer principal type in chart render" +expect_not_contains "${default_render}" "name: SPRITZ_AUTH_BEARER_DEFAULT_TYPE" "forced bearer default type wiring when chart leaves it unset" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL" "default provisioner idle ttl wiring" expect_contains "${default_render}" "name: SPRITZ_PROVISIONER_DEFAULT_TTL" "default provisioner ttl wiring" expect_contains "${default_render}" "name: SPRITZ_TERMINAL_ACTIVITY_DEBOUNCE" "terminal activity debounce wiring" From 37c6ffe9f741649e5ac9cf2fb490d37c3ef71a68 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 16:11:29 +0100 Subject: [PATCH 08/21] fix(provisioning): honor default presets and retry pending ids --- api/main.go | 43 ++++++------ api/main_create_owner_test.go | 129 ++++++++++++++++++++++++++++++++++ api/provisioning.go | 52 ++++++++++++-- 3 files changed, 196 insertions(+), 28 deletions(-) diff --git a/api/main.go b/api/main.go index 70ba9fa..f6778fb 100644 --- a/api/main.go +++ b/api/main.go @@ -326,6 +326,7 @@ func (s *server) createSpritz(c echo.Context) error { requestedNamespace := strings.TrimSpace(body.Namespace) != "" requestedImage := strings.TrimSpace(body.Spec.Image) != "" requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 + s.applyProvisionerDefaultPreset(&body, principal) preset, err := s.applyCreatePreset(&body) if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) @@ -425,29 +426,29 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusBadRequest, err.Error()) } reservedName, completed, err := s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, fingerprint, body.Name) - if err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) + if err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - body.Name = reservedName - existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) - if err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) - } - if existing != nil { - if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { - return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") + body.Name = reservedName + if completed { + existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if existing != nil { + if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { + return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") + } + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + return writeError(c, http.StatusConflict, "idempotencyKey already used") + } + if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) } - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) - } - if completed { - return writeError(c, http.StatusConflict, "idempotencyKey already used") - } - if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } body.Annotations = mergeStringMap(body.Annotations, map[string]string{ actorIDAnnotationKey: principal.ID, actorTypeAnnotationKey: string(principal.Type), diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index beff3b7..268d7cb 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -15,6 +15,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -557,3 +558,131 @@ func TestCreateSpritzAllowsProvisionerPresetWithInjectedEnv(t *testing.T) { t.Fatalf("expected preset-backed service create to succeed, got %d: %s", rec.Code, rec.Body.String()) } } + +func TestCreateSpritzUsesProvisionerDefaultPresetWhenPresetOmitted(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"ownerId":"user-123","idempotencyKey":"discord-default-preset"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response json: %v", err) + } + data := payload["data"].(map[string]any) + if presetID := data["presetId"]; presetID != "openclaw" { + t.Fatalf("expected presetId openclaw, got %#v", presetID) + } + spritz := data["spritz"].(map[string]any) + metadata := spritz["metadata"].(map[string]any) + if name, _ := metadata["name"].(string); !strings.HasPrefix(name, "openclaw-") { + t.Fatalf("expected default preset name prefix, got %#v", name) + } + spec := spritz["spec"].(map[string]any) + if image := spec["image"]; image != "example.com/spritz-openclaw:latest" { + t.Fatalf("expected preset image, got %#v", image) + } +} + +func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.nameGeneratorFactory = func(context.Context, string, string) (func() string, error) { + return func() string { + return "openclaw-fresh-name" + }, nil + } + + body := createRequest{ + OwnerID: "user-123", + IdempotencyKey: "discord-pending", + PresetID: "openclaw", + } + applyTopLevelCreateFields(&body) + owner, err := normalizeCreateOwner(&body, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + body.Spec.Owner = owner + if _, err := s.applyCreatePreset(&body); err != nil { + t.Fatalf("applyCreatePreset failed: %v", err) + } + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { + t.Fatalf("resolveCreateLifetimes failed: %v", err) + } + fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", s.namespace, provisionerSource(&body), body.Spec, nil) + if err != nil { + t.Fatalf("createFingerprint failed: %v", err) + } + + conflictingName := "openclaw-blocked-name" + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", body.IdempotencyKey), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: fingerprint, + idempotencyReservationNameKey: conflictingName, + idempotencyReservationDoneKey: "false", + }, + }); err != nil { + t.Fatalf("failed to seed reservation: %v", err) + } + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: conflictingName, + Namespace: s.namespace, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-other:latest", + Owner: spritzv1.SpritzOwner{ID: "someone-else"}, + }, + }); err != nil { + t.Fatalf("failed to seed conflicting spritz: %v", err) + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + reqBody := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-pending"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response json: %v", err) + } + spritz := payload["data"].(map[string]any)["spritz"].(map[string]any) + metadata := spritz["metadata"].(map[string]any) + if name := metadata["name"]; name == conflictingName { + t.Fatalf("expected create to move past the poisoned reservation name, got %#v", name) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index 86c1967..f84036f 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -119,6 +119,22 @@ func (s *server) applyCreatePreset(body *createRequest) (*runtimePreset, error) return preset, nil } +func (s *server) applyProvisionerDefaultPreset(body *createRequest, principal principal) { + if body == nil || !principal.isService() { + return + } + if strings.TrimSpace(body.PresetID) != "" { + return + } + if strings.TrimSpace(body.Spec.Image) != "" { + return + } + if s.provisioners.defaultPresetID == "" { + return + } + body.PresetID = s.provisioners.defaultPresetID +} + func applyTopLevelCreateFields(body *createRequest) { if strings.TrimSpace(body.OwnerID) != "" && strings.TrimSpace(body.Spec.Owner.ID) == "" { body.Spec.Owner.ID = strings.TrimSpace(body.OwnerID) @@ -248,12 +264,6 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := s.provisioners.validateNamespace(namespace); err != nil { return "", err } - if body.PresetID == "" && s.provisioners.defaultPresetID != "" { - body.PresetID = s.provisioners.defaultPresetID - if _, err := s.applyCreatePreset(body); err != nil { - return "", err - } - } if body.PresetID != "" { if err := s.provisioners.validatePreset(body.PresetID); err != nil { return "", err @@ -562,11 +572,39 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != fingerprint { return "", false, fmt.Errorf("idempotencyKey already used with a different request") } + done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") name := strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) + if done { + if name == "" { + name = desiredName + } + return name, true, nil + } if name == "" { name = desiredName } - done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") + if name != "" { + reservedSpritz, getErr := s.findReservedSpritz(ctx, namespace, name) + if getErr != nil { + return "", false, getErr + } + if reservedSpritz != nil && strings.TrimSpace(reservedSpritz.Annotations[idempotencyHashAnnotationKey]) != fingerprint { + name = desiredName + } + } + if name == "" { + name = desiredName + } + if name != strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) { + if existing.Data == nil { + existing.Data = map[string]string{} + } + existing.Data[idempotencyReservationNameKey] = name + existing.Data[idempotencyReservationDoneKey] = "false" + if updateErr := s.client.Update(ctx, existing); updateErr != nil { + return "", false, updateErr + } + } return name, done, nil } return desiredName, false, nil From 42fe8efe886fdce2aca9d88edaddf2b7888352eb Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 16:28:33 +0100 Subject: [PATCH 09/21] fix(provisioning): tighten auth and async prompt activity --- api/acp_gateway.go | 8 +-- api/acp_gateway_test.go | 57 +++++++++++++++++++++ api/activity.go | 27 ++++++++++ api/auth.go | 6 +-- api/auth_middleware_test.go | 93 +++++++++++++++++++++++++++++++++++ api/main.go | 1 + api/main_create_owner_test.go | 33 +++++++++++++ api/provisioning.go | 3 -- 8 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 api/acp_gateway_test.go diff --git a/api/acp_gateway.go b/api/acp_gateway.go index 241afdd..ff0e2b8 100644 --- a/api/acp_gateway.go +++ b/api/acp_gateway.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" "sync" - "time" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -57,12 +56,7 @@ func (s *server) openACPConversationConnection(c echo.Context) error { browserConn, workspaceConn, func(payload []byte) { - if !isACPPromptMessage(payload) { - return - } - if err := s.markSpritzActivity(c.Request().Context(), spritz.Namespace, spritz.Name, time.Now()); err != nil { - c.Logger().Warnf("failed to record acp activity for %s/%s: %v", spritz.Namespace, spritz.Name, err) - } + s.scheduleACPPromptActivity(c.Logger(), spritz.Namespace, spritz.Name, payload) }, nil, ) diff --git a/api/acp_gateway_test.go b/api/acp_gateway_test.go new file mode 100644 index 0000000..e3e7999 --- /dev/null +++ b/api/acp_gateway_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type noopWarnLogger struct{} + +func (noopWarnLogger) Warnf(string, ...interface{}) {} + +func TestScheduleACPPromptActivityDoesNotBlockPromptPath(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + var calls atomic.Int32 + + s := &server{ + activityRecorder: func(ctx context.Context, namespace, name string, when time.Time) error { + calls.Add(1) + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }, + } + + start := time.Now() + s.scheduleACPPromptActivity(noopWarnLogger{}, "spritz-test", "young-crest", []byte(`{"jsonrpc":"2.0","method":"session/prompt"}`)) + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Fatalf("expected prompt activity scheduling to return immediately, took %s", elapsed) + } + + select { + case <-started: + case <-time.After(250 * time.Millisecond): + t.Fatalf("expected background activity recorder to start") + } + + close(release) + + deadline := time.Now().Add(250 * time.Millisecond) + for time.Now().Before(deadline) { + if calls.Load() == 1 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("expected one background activity write, got %d", calls.Load()) +} diff --git a/api/activity.go b/api/activity.go index 1d30489..745799c 100644 --- a/api/activity.go +++ b/api/activity.go @@ -12,6 +12,33 @@ import ( spritzv1 "spritz.sh/operator/api/v1" ) +const acpPromptActivityTimeout = 2 * time.Second + +type warnLogger interface { + Warnf(string, ...interface{}) +} + +func (s *server) recordSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error { + if s.activityRecorder != nil { + return s.activityRecorder(ctx, namespace, name, when) + } + return s.markSpritzActivity(ctx, namespace, name, when) +} + +func (s *server) scheduleACPPromptActivity(logger warnLogger, namespace, name string, payload []byte) { + if !isACPPromptMessage(payload) { + return + } + when := time.Now() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), acpPromptActivityTimeout) + defer cancel() + if err := s.recordSpritzActivity(ctx, namespace, name, when); err != nil && logger != nil { + logger.Warnf("failed to record acp activity for %s/%s: %v", namespace, name, err) + } + }() +} + func (s *server) markSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error { if strings.TrimSpace(name) == "" { return nil diff --git a/api/auth.go b/api/auth.go index 85c4a2d..2671eb4 100644 --- a/api/auth.go +++ b/api/auth.go @@ -164,15 +164,13 @@ func normalizePrincipalType(raw string, fallback principalType) principalType { return principalTypeHuman case principalTypeService: return principalTypeService - case principalTypeAdmin: - return principalTypeAdmin default: return fallback } } func finalizePrincipal(id, email string, teams []string, subject, issuer string, principalTypeValue principalType, scopes []string, admin bool) principal { - isAdmin := admin || principalTypeValue == principalTypeAdmin + isAdmin := admin if subject == "" { subject = id } @@ -200,7 +198,7 @@ func (p principal) isService() bool { } func (p principal) isAdminPrincipal() bool { - return p.IsAdmin || p.Type == principalTypeAdmin + return p.IsAdmin } func (p principal) hasScope(scope string) bool { diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index de67906..78b96db 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -127,6 +127,48 @@ func TestAuthMiddlewareSetsPrincipalTypeAndScopes(t *testing.T) { } } +func TestAuthMiddlewareDoesNotGrantAdminFromHeaderTypeClaim(t *testing.T) { + t.Setenv("SPRITZ_AUTH_MODE", "header") + t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") + t.Setenv("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type") + + s := &server{auth: newAuthConfig()} + e := echo.New() + + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "type": p.Type, + "admin": p.IsAdmin, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("X-Spritz-User-Id", "user-123") + req.Header.Set("X-Spritz-Principal-Type", "admin") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeHuman) { + t.Fatalf("expected header admin claim to fall back to human, got %#v", payload["type"]) + } + if admin, _ := payload["admin"].(bool); admin { + t.Fatalf("expected header admin claim to remain non-admin") + } +} + func TestBearerAuthParsesSpaceDelimitedScopes(t *testing.T) { introspection := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ @@ -268,3 +310,54 @@ func TestBearerAuthDefaultsToHumanTypeWithoutTypeClaimInBearerMode(t *testing.T) t.Fatalf("expected default bearer principal type to stay human in bearer mode, got %#v", payload["type"]) } } + +func TestBearerAuthDoesNotGrantAdminFromTypeClaim(t *testing.T) { + introspection := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "zenobot", + "type": "admin", + "scope": "spritz.instances.create spritz.instances.assign_owner", + }) + })) + defer introspection.Close() + + t.Setenv("SPRITZ_AUTH_MODE", "auto") + t.Setenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL", introspection.URL) + t.Setenv("SPRITZ_AUTH_BEARER_ID_PATHS", "sub") + t.Setenv("SPRITZ_AUTH_BEARER_TYPE_PATHS", "type") + t.Setenv("SPRITZ_AUTH_BEARER_SCOPES_PATHS", "scope") + + s := &server{auth: newAuthConfig()} + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "type": p.Type, + "admin": p.IsAdmin, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeService) { + t.Fatalf("expected bearer admin claim to fall back to service, got %#v", payload["type"]) + } + if admin, _ := payload["admin"].(bool); admin { + t.Fatalf("expected bearer admin claim to remain non-admin") + } +} diff --git a/api/main.go b/api/main.go index f6778fb..b08a8cd 100644 --- a/api/main.go +++ b/api/main.go @@ -50,6 +50,7 @@ type server struct { sharedMountsLive *sharedMountsLatestNotifier userConfigPolicy userConfigPolicy nameGeneratorFactory func(context.Context, string, string) (func() string, error) + activityRecorder func(context.Context, string, string, time.Time) error } func main() { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 268d7cb..fabeb23 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -347,6 +347,39 @@ func TestCreateSpritzReplaysIdempotentProvisionerRequest(t *testing.T) { } } +func TestCreateSpritzReplaysIdempotentProvisionerRequestWhenRequestIDChanges(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-request-id","requestId":"discord-delivery-1"}`) + second := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-request-id","requestId":"discord-delivery-2"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) + } +} + func TestCreateSpritzRejectsIdempotentProvisionerPayloadMismatch(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) diff --git a/api/provisioning.go b/api/provisioning.go index f84036f..1cbbda5 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -216,9 +216,6 @@ func validateProvisionerRequestSurface(body *createRequest) error { func provisionerSource(body *createRequest) string { source := strings.TrimSpace(body.Source) - if source == "" { - source = strings.TrimSpace(body.RequestID) - } if source == "" { source = defaultProvisionerSource } From b43c754c7396fe32ee91664e4665e996564ccdda Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 16:43:37 +0100 Subject: [PATCH 10/21] fix(provisioning): tighten create contract semantics --- api/main.go | 6 +++- api/main_create_owner_test.go | 60 ++++++++++++++++++++++++++++++++++- api/provisioning.go | 11 +++++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/api/main.go b/api/main.go index b08a8cd..0765f47 100644 --- a/api/main.go +++ b/api/main.go @@ -419,7 +419,11 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { fingerprintName = "" } - fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName) + fingerprintNamePrefix := "" + if !nameProvided { + fingerprintNamePrefix = namePrefix + } + fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName, fingerprintNamePrefix) if err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index fabeb23..49effa0 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -298,6 +298,31 @@ func TestCreateSpritzRejectsProvisionerWithoutOwnerID(t *testing.T) { } } +func TestCreateSpritzRejectsProvisionerConflictingOwnerFields(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-owner-conflict","spec":{"owner":{"id":"user-999"}}}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "ownerId conflicts with spec.owner.id") { + t.Fatalf("expected conflicting owner validation error, got %s", rec.Body.String()) + } +} + func TestCreateSpritzReplaysIdempotentProvisionerRequest(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -413,6 +438,39 @@ func TestCreateSpritzRejectsIdempotentProvisionerPayloadMismatch(t *testing.T) { } } +func TestCreateSpritzRejectsIdempotentProvisionerRequestWhenNamePrefixChanges(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-prefix","namePrefix":"claude-code"}`) + second := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-prefix","namePrefix":"openclaw"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusConflict { + t.Fatalf("expected conflict status 409, got %d: %s", rec2.Code, rec2.Body.String()) + } +} + func TestCreateSpritzReplaysIdempotentProvisionerRequestBeforeQuotaCheck(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -659,7 +717,7 @@ func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { t.Fatalf("resolveCreateLifetimes failed: %v", err) } - fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", s.namespace, provisionerSource(&body), body.Spec, nil) + fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) if err != nil { t.Fatalf("createFingerprint failed: %v", err) } diff --git a/api/provisioning.go b/api/provisioning.go index 1cbbda5..ed3d377 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -152,6 +152,9 @@ func applyTopLevelCreateFields(body *createRequest) { func normalizeCreateOwner(body *createRequest, principal principal, authEnabled bool) (spritzv1.SpritzOwner, error) { owner := body.Spec.Owner + if explicitOwner := strings.TrimSpace(body.OwnerID); explicitOwner != "" && strings.TrimSpace(owner.ID) != "" && explicitOwner != strings.TrimSpace(owner.ID) { + return owner, fmt.Errorf("ownerId conflicts with spec.owner.id") + } if principal.isService() && strings.TrimSpace(body.OwnerID) == "" && strings.TrimSpace(owner.ID) == "" { return owner, fmt.Errorf("ownerId is required") } @@ -245,7 +248,7 @@ func (p provisionerPolicy) validatePreset(presetID string) error { return fmt.Errorf("preset is not allowed: %s", presetID) } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint string) (string, error) { +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint, namePrefixForFingerprint string) (string, error) { if !principalCanUseProvisionerFlow(principal) { return "", errForbidden } @@ -278,7 +281,7 @@ func (s *server) validateProvisionerCreate(ctx context.Context, principal princi if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { return "", err } - return createFingerprint(body.Spec.Owner.ID, body.PresetID, nameForFingerprint, namespace, provisionerSource(body), body.Spec, userConfig) + return createFingerprint(body.Spec.Owner.ID, body.PresetID, nameForFingerprint, namePrefixForFingerprint, namespace, provisionerSource(body), body.Spec, userConfig) } func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, principal principal, ownerID string) error { @@ -504,7 +507,7 @@ func hashLabelValue(prefix, value string) string { return fmt.Sprintf("%s-%x", prefix, sum[:12]) } -func createFingerprint(ownerID, presetID, name, namespace, source string, spec spritzv1.SpritzSpec, userConfig json.RawMessage) (string, error) { +func createFingerprint(ownerID, presetID, name, namePrefix, namespace, source string, spec spritzv1.SpritzSpec, userConfig json.RawMessage) (string, error) { specCopy := spec specCopy.Annotations = nil specCopy.Labels = nil @@ -512,6 +515,7 @@ func createFingerprint(ownerID, presetID, name, namespace, source string, spec s OwnerID string `json:"ownerId"` PresetID string `json:"presetId,omitempty"` Name string `json:"name,omitempty"` + NamePrefix string `json:"namePrefix,omitempty"` Namespace string `json:"namespace,omitempty"` Source string `json:"source,omitempty"` Spec spritzv1.SpritzSpec `json:"spec"` @@ -520,6 +524,7 @@ func createFingerprint(ownerID, presetID, name, namespace, source string, spec s OwnerID: ownerID, PresetID: presetID, Name: name, + NamePrefix: strings.TrimSpace(namePrefix), Namespace: namespace, Source: source, Spec: specCopy, From 5b31f26b49972ec559c2ff02b78028e9a9494f34 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 16:58:42 +0100 Subject: [PATCH 11/21] fix(provisioning): redact preset secrets and replay pending creates --- api/main.go | 45 +++++++------ api/main_create_owner_test.go | 118 ++++++++++++++++++++++++++++++++++ api/provisioning.go | 34 ++++++++++ 3 files changed, 176 insertions(+), 21 deletions(-) diff --git a/api/main.go b/api/main.go index 0765f47..6ad19eb 100644 --- a/api/main.go +++ b/api/main.go @@ -273,7 +273,7 @@ func (s *server) listPresets(c echo.Context) error { if principal.isService() && !principal.hasScope(scopePresetsRead) && !principal.isAdminPrincipal() { return writeError(c, http.StatusForbidden, "forbidden") } - return writeJSON(c, http.StatusOK, map[string]any{"items": s.presets.all()}) + return writeJSON(c, http.StatusOK, map[string]any{"items": s.presets.public()}) } func (s *server) suggestSpritzName(c echo.Context) error { @@ -431,29 +431,32 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusBadRequest, err.Error()) } reservedName, completed, err := s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, fingerprint, body.Name) - if err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) + if err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) } - body.Name = reservedName - if completed { - existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) - if err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) - } - if existing != nil { - if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { - return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") - } - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + return writeError(c, http.StatusInternalServerError, err.Error()) + } + body.Name = reservedName + existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if existing != nil { + if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { + if completed { + return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } - return writeError(c, http.StatusConflict, "idempotencyKey already used") - } - if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + } else { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } + } + if completed { + return writeError(c, http.StatusConflict, "idempotencyKey already used") + } + if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } body.Annotations = mergeStringMap(body.Annotations, map[string]string{ actorIDAnnotationKey: principal.ID, actorTypeAnnotationKey: string(principal.Type), diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 49effa0..d4b3fe2 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -650,6 +650,29 @@ func TestCreateSpritzAllowsProvisionerPresetWithInjectedEnv(t *testing.T) { } } +func TestListPresetsOmitsPresetEnvValues(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.presets.byID[0].Env = []corev1.EnvVar{{Name: "OPENCLAW_CONFIG_JSON", Value: `{"secret":"value"}`}} + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/presets", s.listPresets) + + req := httptest.NewRequest(http.MethodGet, "/api/presets", nil) + req.Header.Set("X-Spritz-User-Id", "user-123") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "OPENCLAW_CONFIG_JSON") || strings.Contains(rec.Body.String(), `"secret":"value"`) { + t.Fatalf("expected preset response to omit env values, got %s", rec.Body.String()) + } +} + func TestCreateSpritzUsesProvisionerDefaultPresetWhenPresetOmitted(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -777,3 +800,98 @@ func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant t.Fatalf("expected create to move past the poisoned reservation name, got %#v", name) } } + +func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.maxActivePerOwner = 1 + s.nameGeneratorFactory = func(context.Context, string, string) (func() string, error) { + return func() string { + return "openclaw-fixed" + }, nil + } + + body := createRequest{ + OwnerID: "user-123", + IdempotencyKey: "discord-pending", + PresetID: "openclaw", + } + applyTopLevelCreateFields(&body) + owner, err := normalizeCreateOwner(&body, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + body.Spec.Owner = owner + if _, err := s.applyCreatePreset(&body); err != nil { + t.Fatalf("applyCreatePreset failed: %v", err) + } + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { + t.Fatalf("resolveCreateLifetimes failed: %v", err) + } + fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) + if err != nil { + t.Fatalf("createFingerprint failed: %v", err) + } + + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", body.IdempotencyKey), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: fingerprint, + idempotencyReservationNameKey: "openclaw-fixed", + idempotencyReservationDoneKey: "false", + }, + }); err != nil { + t.Fatalf("failed to seed reservation: %v", err) + } + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-fixed", + Namespace: s.namespace, + Annotations: map[string]string{ + idempotencyHashAnnotationKey: fingerprint, + }, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-123"}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + }); err != nil { + t.Fatalf("failed to seed spritz: %v", err) + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + reqBody := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-pending"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200 replay, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response json: %v", err) + } + data := payload["data"].(map[string]any) + if replayed, _ := data["replayed"].(bool); !replayed { + t.Fatalf("expected replayed response, got %#v", data["replayed"]) + } + spritz := data["spritz"].(map[string]any) + metadata := spritz["metadata"].(map[string]any) + if metadata["name"] != "openclaw-fixed" { + t.Fatalf("expected replay to return existing spritz, got %#v", metadata["name"]) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index ed3d377..c823084 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -57,6 +57,18 @@ type runtimePreset struct { Env []corev1.EnvVar `json:"env,omitempty"` } +type publicPreset struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Image string `json:"image,omitempty"` + RepoURL string `json:"repoUrl,omitempty"` + Branch string `json:"branch,omitempty"` + TTL string `json:"ttl,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + NamePrefix string `json:"namePrefix,omitempty"` +} + type presetCatalog struct { byID []runtimePreset } @@ -375,6 +387,28 @@ func (c presetCatalog) all() []runtimePreset { return items } +func (c presetCatalog) public() []publicPreset { + items := c.all() + if len(items) == 0 { + return nil + } + publicItems := make([]publicPreset, 0, len(items)) + for _, item := range items { + publicItems = append(publicItems, publicPreset{ + ID: item.ID, + Name: item.Name, + Description: item.Description, + Image: item.Image, + RepoURL: item.RepoURL, + Branch: item.Branch, + TTL: item.TTL, + IdleTTL: item.IdleTTL, + NamePrefix: item.NamePrefix, + }) + } + return publicItems +} + func (c presetCatalog) get(id string) (*runtimePreset, bool) { id = sanitizeSpritzNameToken(id) if id == "" { From f36bb894f205c239b4a2817e7a14eab2c3914cd1 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 17:22:40 +0100 Subject: [PATCH 12/21] fix(provisioning): harden header auth and ssh activity --- api/auth.go | 22 ++++++-- api/auth_middleware_test.go | 47 +++++++++++++++++ api/main.go | 3 +- api/main_create_owner_test.go | 44 ++++++++++++++++ api/main_owner_visibility_test.go | 9 ++-- api/ssh_config.go | 6 +++ api/ssh_gateway.go | 61 +++++++++++++++++++++++ api/ssh_gateway_test.go | 48 ++++++++++++++++++ helm/spritz/templates/api-deployment.yaml | 4 ++ helm/spritz/values.yaml | 2 + 10 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 api/ssh_gateway_test.go diff --git a/api/auth.go b/api/auth.go index 2671eb4..dd8df45 100644 --- a/api/auth.go +++ b/api/auth.go @@ -47,6 +47,7 @@ type authConfig struct { headerTeams string headerType string headerScopes string + headerTrustTypeAndScopes bool headerDefaultType principalType adminIDs map[string]struct{} adminTeams map[string]struct{} @@ -110,6 +111,7 @@ func newAuthConfig() authConfig { headerTeams: envOrDefault("SPRITZ_AUTH_HEADER_TEAMS", "X-Spritz-User-Teams"), headerType: envOrDefault("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type"), headerScopes: envOrDefault("SPRITZ_AUTH_HEADER_SCOPES", "X-Spritz-Principal-Scopes"), + headerTrustTypeAndScopes: parseBoolEnv("SPRITZ_AUTH_HEADER_TRUST_TYPE_AND_SCOPES", false), headerDefaultType: normalizePrincipalType(envOrDefault("SPRITZ_AUTH_HEADER_DEFAULT_TYPE", string(principalTypeHuman)), principalTypeHuman), adminIDs: splitSet(os.Getenv("SPRITZ_AUTH_ADMIN_IDS")), adminTeams: splitSet(os.Getenv("SPRITZ_AUTH_ADMIN_TEAMS")), @@ -227,14 +229,20 @@ func (a *authConfig) principal(r *http.Request) (principal, error) { } email := strings.TrimSpace(r.Header.Get(a.headerEmail)) teams := splitList(r.Header.Get(a.headerTeams)) + principalTypeValue := a.headerDefaultType + scopes := []string(nil) + if a.headerTrustTypeAndScopes { + principalTypeValue = normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType) + scopes = splitScopes(r.Header.Get(a.headerScopes)) + } return finalizePrincipal( id, email, teams, id, "", - normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), - splitScopes(r.Header.Get(a.headerScopes)), + principalTypeValue, + scopes, a.isAdmin(id, teams), ), nil case authModeAuto: @@ -242,14 +250,20 @@ func (a *authConfig) principal(r *http.Request) (principal, error) { if id != "" { email := strings.TrimSpace(r.Header.Get(a.headerEmail)) teams := splitList(r.Header.Get(a.headerTeams)) + principalTypeValue := a.headerDefaultType + scopes := []string(nil) + if a.headerTrustTypeAndScopes { + principalTypeValue = normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType) + scopes = splitScopes(r.Header.Get(a.headerScopes)) + } return finalizePrincipal( id, email, teams, id, "", - normalizePrincipalType(r.Header.Get(a.headerType), a.headerDefaultType), - splitScopes(r.Header.Get(a.headerScopes)), + principalTypeValue, + scopes, a.isAdmin(id, teams), ), nil } diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 78b96db..2f4a850 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -86,6 +86,7 @@ func TestAuthMiddlewareSetsPrincipalTypeAndScopes(t *testing.T) { t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") t.Setenv("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type") t.Setenv("SPRITZ_AUTH_HEADER_SCOPES", "X-Spritz-Principal-Scopes") + t.Setenv("SPRITZ_AUTH_HEADER_TRUST_TYPE_AND_SCOPES", "true") s := &server{auth: newAuthConfig()} e := echo.New() @@ -127,6 +128,52 @@ func TestAuthMiddlewareSetsPrincipalTypeAndScopes(t *testing.T) { } } +func TestAuthMiddlewareIgnoresHeaderTypeAndScopesByDefault(t *testing.T) { + t.Setenv("SPRITZ_AUTH_MODE", "header") + t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") + t.Setenv("SPRITZ_AUTH_HEADER_TYPE", "X-Spritz-Principal-Type") + t.Setenv("SPRITZ_AUTH_HEADER_SCOPES", "X-Spritz-Principal-Scopes") + + s := &server{auth: newAuthConfig()} + e := echo.New() + + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/spritzes", func(c echo.Context) error { + p, ok := principalFromContext(c) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "missing principal"}) + } + return c.JSON(http.StatusOK, map[string]any{ + "id": p.ID, + "type": p.Type, + "scopes": p.Scopes, + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/spritzes", nil) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + payload := map[string]any{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if payload["type"] != string(principalTypeHuman) { + t.Fatalf("expected header auth to default to human, got %#v", payload["type"]) + } + scopes, _ := payload["scopes"].([]any) + if len(scopes) != 0 { + t.Fatalf("expected no header scopes by default, got %#v", payload["scopes"]) + } +} + func TestAuthMiddlewareDoesNotGrantAdminFromHeaderTypeClaim(t *testing.T) { t.Setenv("SPRITZ_AUTH_MODE", "header") t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") diff --git a/api/main.go b/api/main.go index 6ad19eb..da03b81 100644 --- a/api/main.go +++ b/api/main.go @@ -324,7 +324,6 @@ func (s *server) createSpritz(c echo.Context) error { } } - requestedNamespace := strings.TrimSpace(body.Namespace) != "" requestedImage := strings.TrimSpace(body.Spec.Image) != "" requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 s.applyProvisionerDefaultPreset(&body, principal) @@ -389,6 +388,8 @@ func (s *server) createSpritz(c echo.Context) error { if err != nil { return writeError(c, http.StatusForbidden, err.Error()) } + requestedNamespaceValue := strings.TrimSpace(body.Namespace) + requestedNamespace := requestedNamespaceValue != "" && requestedNamespaceValue != namespace owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) if err != nil { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index d4b3fe2..67a075c 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -69,6 +69,7 @@ func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, o } func configureProvisionerTestServer(s *server) { + s.auth.headerTrustTypeAndScopes = true s.presets = presetCatalog{ byID: []runtimePreset{{ ID: "openclaw", @@ -298,6 +299,27 @@ func TestCreateSpritzRejectsProvisionerWithoutOwnerID(t *testing.T) { } } +func TestCreateSpritzRejectsHeaderScopeSpoofingForOwnerAssignment(t *testing.T) { + s := newCreateSpritzTestServer(t) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"name":"spoofed-owner","ownerId":"user-999","idempotencyKey":"discord-spoof","spec":{"image":"example.com/spritz-openclaw:latest"}}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "user-123") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestCreateSpritzRejectsProvisionerConflictingOwnerFields(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -714,6 +736,28 @@ func TestCreateSpritzUsesProvisionerDefaultPresetWhenPresetOmitted(t *testing.T) } } +func TestCreateSpritzAllowsProvisionerCurrentNamespaceWithoutOverride(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-current-ns","namespace":"spritz-test"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) diff --git a/api/main_owner_visibility_test.go b/api/main_owner_visibility_test.go index f0baa87..1d008d8 100644 --- a/api/main_owner_visibility_test.go +++ b/api/main_owner_visibility_test.go @@ -26,10 +26,11 @@ func newListSpritzTestServer(t *testing.T, objects ...client.Object) *server { scheme: scheme, namespace: "spritz-test", auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", - headerType: "X-Spritz-Principal-Type", - headerDefaultType: principalTypeHuman, + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerType: "X-Spritz-Principal-Type", + headerTrustTypeAndScopes: true, + headerDefaultType: principalTypeHuman, }, internalAuth: internalAuthConfig{enabled: false}, } diff --git a/api/ssh_config.go b/api/ssh_config.go index ab7cdf9..78b2c8d 100644 --- a/api/ssh_config.go +++ b/api/ssh_config.go @@ -21,6 +21,7 @@ type sshGatewayConfig struct { user string principalPrefix string certTTL time.Duration + activityRefresh time.Duration containerName string command []string caSigner ssh.Signer @@ -76,6 +77,10 @@ func newSSHGatewayConfig() (sshGatewayConfig, error) { if certTTL <= 0 { certTTL = 15 * time.Minute } + activityRefresh := parseDurationEnv("SPRITZ_SSH_ACTIVITY_REFRESH", time.Minute) + if activityRefresh <= 0 { + activityRefresh = time.Minute + } containerName := envOrDefault("SPRITZ_SSH_CONTAINER", "spritz") command := splitCommand(envOrDefault("SPRITZ_SSH_COMMAND", "bash -l")) @@ -93,6 +98,7 @@ func newSSHGatewayConfig() (sshGatewayConfig, error) { user: user, principalPrefix: principalPrefix, certTTL: certTTL, + activityRefresh: activityRefresh, containerName: containerName, command: command, caSigner: caSigner, diff --git a/api/ssh_gateway.go b/api/ssh_gateway.go index 661f620..024ff53 100644 --- a/api/ssh_gateway.go +++ b/api/ssh_gateway.go @@ -13,6 +13,8 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/remotecommand" + + spritzv1 "spritz.sh/operator/api/v1" ) const sshPrincipalDelimiter = ":" @@ -97,6 +99,14 @@ func (s *server) handleSSHSession(sess sshserver.Session) { log.Printf("spritz ssh: session start name=%s namespace=%s user_id=%s", name, namespace, keyID) defer log.Printf("spritz ssh: session end name=%s namespace=%s user_id=%s", name, namespace, keyID) + spritz := &spritzv1.Spritz{} + if err := s.client.Get(sess.Context(), clientKey(namespace, name), spritz); err != nil { + log.Printf("spritz ssh: spritz not found name=%s namespace=%s user_id=%s err=%v", name, namespace, keyID, err) + _, _ = io.WriteString(sess, "spritz not ready\n") + _ = sess.Exit(1) + return + } + pod, err := s.findRunningPod(sess.Context(), namespace, name, s.sshGateway.containerName) if err != nil { log.Printf("spritz ssh: pod not ready name=%s namespace=%s err=%v", name, namespace, err) @@ -104,6 +114,7 @@ func (s *server) handleSSHSession(sess sshserver.Session) { _ = sess.Exit(1) return } + s.startSSHActivityLoop(sess.Context(), spritz) pty, winCh, hasPty := sess.Pty() sizeQueue := newTerminalSizeQueue() @@ -124,6 +135,56 @@ func (s *server) handleSSHSession(sess sshserver.Session) { _ = sess.Exit(0) } +func sshActivityRefreshInterval(spec spritzv1.SpritzSpec, fallback time.Duration) time.Duration { + interval := fallback + if interval <= 0 { + interval = time.Minute + } + if raw := strings.TrimSpace(spec.IdleTTL); raw != "" { + if idleTTL, err := time.ParseDuration(raw); err == nil && idleTTL > 0 { + candidate := idleTTL / 2 + if candidate <= 0 { + candidate = idleTTL + } + if candidate > 0 && candidate < interval { + interval = candidate + } + } + } + if interval <= 0 { + return time.Minute + } + return interval +} + +func (s *server) startSSHActivityLoop(ctx context.Context, spritz *spritzv1.Spritz) { + if s == nil || spritz == nil { + return + } + record := func(when time.Time) { + refreshCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.recordSpritzActivity(refreshCtx, spritz.Namespace, spritz.Name, when); err != nil { + log.Printf("spritz ssh: failed to refresh activity name=%s namespace=%s err=%v", spritz.Name, spritz.Namespace, err) + } + } + record(time.Now()) + + interval := sshActivityRefreshInterval(spritz.Spec, s.sshGateway.activityRefresh) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case tick := <-ticker.C: + record(tick) + } + } + }() +} + func (s *server) streamSSH(ctx context.Context, pod *corev1.Pod, sess sshserver.Session, hasPty bool, sizeQueue *terminalSizeQueue) error { if len(s.sshGateway.command) == 0 { return fmt.Errorf("ssh command missing") diff --git a/api/ssh_gateway_test.go b/api/ssh_gateway_test.go new file mode 100644 index 0000000..e192be1 --- /dev/null +++ b/api/ssh_gateway_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "sync/atomic" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + spritzv1 "spritz.sh/operator/api/v1" +) + +func TestSSHActivityRefreshIntervalUsesHalfIdleTTLWhenShorter(t *testing.T) { + spec := spritzv1.SpritzSpec{IdleTTL: "80ms"} + + interval := sshActivityRefreshInterval(spec, time.Second) + if interval != 40*time.Millisecond { + t.Fatalf("expected 40ms interval, got %s", interval) + } +} + +func TestStartSSHActivityLoopRefreshesWhileSessionIsOpen(t *testing.T) { + var calls atomic.Int32 + s := &server{ + sshGateway: sshGatewayConfig{activityRefresh: 40 * time.Millisecond}, + activityRecorder: func(ctx context.Context, namespace, name string, when time.Time) error { + calls.Add(1) + return nil + }, + } + spritz := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ssh-workspace", + Namespace: "spritz-test", + }, + Spec: spritzv1.SpritzSpec{IdleTTL: "80ms"}, + } + + ctx, cancel := context.WithCancel(context.Background()) + s.startSSHActivityLoop(ctx, spritz) + time.Sleep(95 * time.Millisecond) + cancel() + + if calls.Load() < 2 { + t.Fatalf("expected repeated activity refreshes, got %d", calls.Load()) + } +} diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 5d9574c..187599a 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -58,6 +58,8 @@ spec: value: {{ .Values.api.auth.headerType | quote }} - name: SPRITZ_AUTH_HEADER_SCOPES value: {{ .Values.api.auth.headerScopes | quote }} + - name: SPRITZ_AUTH_HEADER_TRUST_TYPE_AND_SCOPES + value: {{ .Values.api.auth.headerTrustTypeAndScopes | quote }} - name: SPRITZ_AUTH_HEADER_DEFAULT_TYPE value: {{ .Values.api.auth.headerDefaultType | quote }} {{- if .Values.api.auth.adminIds }} @@ -277,6 +279,8 @@ spec: value: {{ .Values.api.sshGateway.principalPrefix | quote }} - name: SPRITZ_SSH_CERT_TTL value: {{ .Values.api.sshGateway.certTtl | quote }} + - name: SPRITZ_SSH_ACTIVITY_REFRESH + value: {{ .Values.api.sshGateway.activityRefresh | quote }} - name: SPRITZ_SSH_MINT_LIMIT value: {{ .Values.api.sshGateway.mintLimit | quote }} - name: SPRITZ_SSH_MINT_WINDOW diff --git a/helm/spritz/values.yaml b/helm/spritz/values.yaml index 1e5f9bf..6b1eeae 100644 --- a/helm/spritz/values.yaml +++ b/helm/spritz/values.yaml @@ -131,6 +131,7 @@ api: headerTeams: X-Spritz-User-Teams headerType: X-Spritz-Principal-Type headerScopes: X-Spritz-Principal-Scopes + headerTrustTypeAndScopes: false headerDefaultType: human adminIds: [] adminTeams: [] @@ -207,6 +208,7 @@ api: user: spritz principalPrefix: spritz certTtl: 15m + activityRefresh: 1m mintLimit: 5 mintWindow: 1m mintBurst: 5 From 9c4900aceb7c809a502671c9a0ec13b22bb56be8 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 17:36:35 +0100 Subject: [PATCH 13/21] fix(provisioning): tighten idempotent replay ownership --- api/main.go | 26 ++++++++++++------------ api/main_create_owner_test.go | 37 +++++++++++++++++++++++++++++++++++ api/provisioning.go | 17 ++++++++++++++++ 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/api/main.go b/api/main.go index da03b81..0b01a66 100644 --- a/api/main.go +++ b/api/main.go @@ -444,7 +444,7 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusInternalServerError, err.Error()) } if existing != nil { - if strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) != fingerprint { + if !matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, fingerprint) { if completed { return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } @@ -550,18 +550,18 @@ func (s *server) createSpritz(c echo.Context) error { if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) } - if err := s.client.Create(c.Request().Context(), spritz); err != nil { - if principal.isService() && apierrors.IsAlreadyExists(err) { - existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) - if getErr != nil { - return writeError(c, http.StatusInternalServerError, getErr.Error()) - } - if existing != nil && strings.TrimSpace(existing.Annotations[idempotencyHashAnnotationKey]) == strings.TrimSpace(annotations[idempotencyHashAnnotationKey]) { - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) - } - if !nameProvided { - continue - } + if err := s.client.Create(c.Request().Context(), spritz); err != nil { + if principal.isService() && apierrors.IsAlreadyExists(err) { + existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) + if getErr != nil { + return writeError(c, http.StatusInternalServerError, getErr.Error()) + } + if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, annotations[idempotencyHashAnnotationKey]) { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + if !nameProvided { + continue + } return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } if !nameProvided && apierrors.IsAlreadyExists(err) { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 67a075c..ebd3cbb 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -896,6 +896,8 @@ func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T Namespace: s.namespace, Annotations: map[string]string{ idempotencyHashAnnotationKey: fingerprint, + idempotencyKeyAnnotationKey: body.IdempotencyKey, + actorIDAnnotationKey: "zenobot", }, }, Spec: spritzv1.SpritzSpec{ @@ -939,3 +941,38 @@ func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T t.Fatalf("expected replay to return existing spritz, got %#v", metadata["name"]) } } + +func TestCreateSpritzDoesNotReplayDifferentActorOrKeyForSameNamedWorkspace(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"name":"openclaw-fixed","presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-a"}`) + second := []byte(`{"name":"openclaw-fixed","presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-b"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot-a") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create to succeed, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot-b") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusConflict { + t.Fatalf("expected status 409, got %d: %s", rec2.Code, rec2.Body.String()) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index c823084..ce681da 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -680,6 +680,23 @@ func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) return spritz, nil } +func matchesIdempotentReplayTarget(spritz *spritzv1.Spritz, principal principal, key, fingerprint string) bool { + if spritz == nil { + return false + } + annotations := spritz.GetAnnotations() + if strings.TrimSpace(annotations[idempotencyHashAnnotationKey]) != strings.TrimSpace(fingerprint) { + return false + } + if strings.TrimSpace(annotations[idempotencyKeyAnnotationKey]) != strings.TrimSpace(key) { + return false + } + if strings.TrimSpace(annotations[actorIDAnnotationKey]) != strings.TrimSpace(principal.ID) { + return false + } + return true +} + func summarizeCreateResponse(spritz *spritzv1.Spritz, principal principal, presetID, source, idempotencyKey string, replayed bool) createSpritzResponse { createdAt := spritz.CreationTimestamp.DeepCopy() idleExpiresAt, maxExpiresAt, expiresAt := lifecycleExpiryTimes(spritz, time.Now()) From dd07a010f7e847c31871b50900761fb6e83b17ed Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 17:49:33 +0100 Subject: [PATCH 14/21] fix(provisioning): scope reservations to control namespace --- api/main.go | 28 +++++++- api/main_create_owner_test.go | 79 ++++++++++++++++++++++- api/provisioning.go | 20 ++++-- helm/spritz/templates/api-deployment.yaml | 4 ++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/api/main.go b/api/main.go index 0b01a66..b26df12 100644 --- a/api/main.go +++ b/api/main.go @@ -34,6 +34,7 @@ type server struct { restConfig *rest.Config scheme *runtime.Scheme namespace string + controlNamespace string auth authConfig internalAuth internalAuthConfig ingressDefaults ingressDefaults @@ -68,6 +69,16 @@ func main() { os.Exit(1) } ns := os.Getenv("SPRITZ_NAMESPACE") + controlNamespace := strings.TrimSpace(os.Getenv("SPRITZ_CONTROL_NAMESPACE")) + if controlNamespace == "" { + controlNamespace = strings.TrimSpace(os.Getenv("POD_NAMESPACE")) + } + if controlNamespace == "" { + controlNamespace = strings.TrimSpace(ns) + } + if controlNamespace == "" { + controlNamespace = "default" + } { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -129,6 +140,7 @@ func main() { restConfig: cfg, scheme: scheme, namespace: ns, + controlNamespace: controlNamespace, auth: auth, internalAuth: internalAuth, ingressDefaults: ingressDefaults, @@ -265,6 +277,17 @@ func (s *server) resolveSpritzNamespace(requested string) (string, error) { return namespace, nil } +func (s *server) namespaceOverrideRequested(requested, resolved string) bool { + requested = strings.TrimSpace(requested) + if requested == "" { + return false + } + if strings.TrimSpace(s.namespace) == "" { + return true + } + return requested != strings.TrimSpace(resolved) +} + func (s *server) listPresets(c echo.Context) error { principal, ok := principalFromContext(c) if s.auth.enabled() && (!ok || principal.ID == "") { @@ -388,8 +411,7 @@ func (s *server) createSpritz(c echo.Context) error { if err != nil { return writeError(c, http.StatusForbidden, err.Error()) } - requestedNamespaceValue := strings.TrimSpace(body.Namespace) - requestedNamespace := requestedNamespaceValue != "" && requestedNamespaceValue != namespace + requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) if err != nil { @@ -570,7 +592,7 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusInternalServerError, err.Error()) } if principal.isService() { - if err := s.completeIdempotencyReservation(c.Request().Context(), namespace, principal.ID, body.IdempotencyKey, spritz); err != nil { + if err := s.completeIdempotencyReservation(c.Request().Context(), principal.ID, body.IdempotencyKey, spritz); err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) } } diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index ebd3cbb..194d630 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -38,9 +38,10 @@ func newCreateSpritzTestServer(t *testing.T) *server { t.Helper() scheme := newTestSpritzScheme(t) return &server{ - client: fake.NewClientBuilder().WithScheme(scheme).Build(), - scheme: scheme, - namespace: "spritz-test", + client: fake.NewClientBuilder().WithScheme(scheme).Build(), + scheme: scheme, + namespace: "spritz-test", + controlNamespace: "spritz-test", auth: authConfig{ mode: authModeHeader, headerID: "X-Spritz-User-Id", @@ -758,6 +759,78 @@ func TestCreateSpritzAllowsProvisionerCurrentNamespaceWithoutOverride(t *testing } } +func TestCreateSpritzRejectsExplicitNamespaceForProvisionerWhenOverrideDisabled(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowedNamespaces = map[string]struct{}{"team-a": {}} + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-ns-override","namespace":"team-a"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "namespace override is not allowed") { + t.Fatalf("expected namespace override error, got %s", rec.Body.String()) + } +} + +func TestCreateSpritzRejectsProvisionerIdempotencyReuseAcrossNamespaces(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = true + s.provisioners.allowedNamespaces = map[string]struct{}{"team-a": {}, "team-b": {}} + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-cross-ns","namespace":"team-a"}`) + second := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-cross-ns","namespace":"team-b"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create to succeed, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusConflict { + t.Fatalf("expected status 409, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "idempotencyKey already used with a different request") { + t.Fatalf("expected idempotency conflict, got %s", rec2.Body.String()) + } +} + func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) diff --git a/api/provisioning.go b/api/provisioning.go index ce681da..058797e 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -577,15 +577,26 @@ func idempotencyReservationName(actorID, key string) string { return fmt.Sprintf("%s%x", idempotencyReservationPrefix, sum[:16]) } +func (s *server) idempotencyReservationNamespace() string { + if namespace := strings.TrimSpace(s.controlNamespace); namespace != "" { + return namespace + } + if namespace := strings.TrimSpace(s.namespace); namespace != "" { + return namespace + } + return "default" +} + func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace string, principal principal, key, fingerprint, desiredName string) (string, bool, error) { if strings.TrimSpace(key) == "" { return desiredName, false, nil } reservationName := idempotencyReservationName(principal.ID, key) + reservationNamespace := s.idempotencyReservationNamespace() record := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: reservationName, - Namespace: namespace, + Namespace: reservationNamespace, Labels: map[string]string{ actorLabelKey: actorLabelValue(principal.ID), idempotencyLabelKey: idempotencyLabelValue(key), @@ -602,7 +613,7 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri return "", false, err } existing := &corev1.ConfigMap{} - if getErr := s.client.Get(ctx, clientKey(namespace, reservationName), existing); getErr != nil { + if getErr := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), existing); getErr != nil { return "", false, getErr } if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != fingerprint { @@ -646,13 +657,14 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri return desiredName, false, nil } -func (s *server) completeIdempotencyReservation(ctx context.Context, namespace, actorID, key string, spritz *spritzv1.Spritz) error { +func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, key string, spritz *spritzv1.Spritz) error { if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" || spritz == nil { return nil } reservationName := idempotencyReservationName(actorID, key) + reservationNamespace := s.idempotencyReservationNamespace() current := &corev1.ConfigMap{} - if err := s.client.Get(ctx, clientKey(namespace, reservationName), current); err != nil { + if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { if apierrors.IsNotFound(err) { return nil } diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 187599a..60c8470 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -40,6 +40,10 @@ spec: {{- toYaml .Values.api.resources | nindent 12 }} {{- end }} env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace - name: SPRITZ_NAMESPACE value: {{ .Values.spritz.namespace | quote }} {{- if .Values.api.defaultAnnotations }} From 5deac54c7b237a6e11fddc0888d8c4daab6fb96b Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 18:05:39 +0100 Subject: [PATCH 15/21] fix(provisioning): harden replay and preset discovery --- api/main.go | 29 +++--- api/main_create_owner_test.go | 119 +++++++++++++++++++++++++ api/provisioning.go | 45 ++++++++++ operator/api/v1/lifecycle.go | 2 + operator/controllers/lifecycle_test.go | 19 ++++ 5 files changed, 204 insertions(+), 10 deletions(-) diff --git a/api/main.go b/api/main.go index b26df12..3ef98f6 100644 --- a/api/main.go +++ b/api/main.go @@ -296,7 +296,11 @@ func (s *server) listPresets(c echo.Context) error { if principal.isService() && !principal.hasScope(scopePresetsRead) && !principal.isAdminPrincipal() { return writeError(c, http.StatusForbidden, "forbidden") } - return writeJSON(c, http.StatusOK, map[string]any{"items": s.presets.public()}) + items := s.presets.public() + if principal.isService() && !principal.isAdminPrincipal() { + items = s.presets.publicAllowed(s.provisioners.allowedPresetIDs) + } + return writeJSON(c, http.StatusOK, map[string]any{"items": items}) } func (s *server) suggestSpritzName(c echo.Context) error { @@ -563,15 +567,20 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { attempts = 8 } - for attempt := 0; attempt < attempts; attempt++ { - name := body.Name - if !nameProvided && attempt > 0 { - name = nameGenerator() - } - spritz, err := createSpritzResource(name) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } + for attempt := 0; attempt < attempts; attempt++ { + name := body.Name + if !nameProvided && attempt > 0 { + name = nameGenerator() + } + if principal.isService() { + if err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, name); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } + spritz, err := createSpritzResource(name) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } if err := s.client.Create(c.Request().Context(), spritz); err != nil { if principal.isService() && apierrors.IsAlreadyExists(err) { existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 194d630..e66ad00 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -58,6 +59,7 @@ func newCreateSpritzTestServer(t *testing.T) *server { type createInterceptClient struct { client.Client onCreate func(context.Context, client.Object) error + onUpdate func(context.Context, client.Object) error } func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { @@ -69,6 +71,15 @@ func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, o return c.Client.Create(ctx, obj, opts...) } +func (c *createInterceptClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if c.onUpdate != nil { + if err := c.onUpdate(ctx, obj); err != nil { + return err + } + } + return c.Client.Update(ctx, obj, opts...) +} + func configureProvisionerTestServer(s *server) { s.auth.headerTrustTypeAndScopes = true s.presets = presetCatalog{ @@ -696,6 +707,40 @@ func TestListPresetsOmitsPresetEnvValues(t *testing.T) { } } +func TestListPresetsFiltersProvisionerAllowlistForServicePrincipal(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.presets.byID = append(s.presets.byID, runtimePreset{ + ID: "claude-code", + Name: "Claude Code", + Image: "example.com/spritz-claude-code:latest", + NamePrefix: "claude-code", + }) + s.provisioners.allowedPresetIDs = map[string]struct{}{"openclaw": {}} + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.GET("/api/presets", s.listPresets) + + req := httptest.NewRequest(http.MethodGet, "/api/presets", nil) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.presets.read") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "claude-code") { + t.Fatalf("expected service preset list to exclude disallowed presets, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "openclaw") { + t.Fatalf("expected service preset list to include allowed preset, got %s", rec.Body.String()) + } +} + func TestCreateSpritzUsesProvisionerDefaultPresetWhenPresetOmitted(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -1049,3 +1094,77 @@ func TestCreateSpritzDoesNotReplayDifferentActorOrKeyForSameNamedWorkspace(t *te t.Fatalf("expected status 409, got %d: %s", rec2.Code, rec2.Body.String()) } } + +func TestCreateSpritzReplaysGeneratedNameAfterCompletionFailure(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.nameGeneratorFactory = func(context.Context, string, string) (func() string, error) { + names := []string{"openclaw-replayable"} + index := 0 + return func() string { + if index >= len(names) { + return names[len(names)-1] + } + value := names[index] + index++ + return value + }, nil + } + + baseClient := s.client + failComplete := true + s.client = &createInterceptClient{ + Client: baseClient, + onUpdate: func(_ context.Context, obj client.Object) error { + configMap, ok := obj.(*corev1.ConfigMap) + if !ok { + return nil + } + if strings.HasPrefix(configMap.Name, idempotencyReservationPrefix) && + failComplete && + strings.EqualFold(strings.TrimSpace(configMap.Data[idempotencyReservationDoneKey]), "true") { + failComplete = false + return errors.New("forced completion failure") + } + return nil + }, + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + reqBody := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-generated-replay"}`) + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(reqBody)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + + if rec1.Code != http.StatusInternalServerError { + t.Fatalf("expected first create to fail after workspace creation, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(reqBody)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("expected retry to replay created workspace, got %d: %s", rec2.Code, rec2.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec2.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response json: %v", err) + } + data := payload["data"].(map[string]any) + if replayed, _ := data["replayed"].(bool); !replayed { + t.Fatalf("expected replayed response, got %#v", data["replayed"]) + } +} diff --git a/api/provisioning.go b/api/provisioning.go index 058797e..5e0bfa2 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -389,6 +389,13 @@ func (c presetCatalog) all() []runtimePreset { func (c presetCatalog) public() []publicPreset { items := c.all() + if len(items) == 0 { + return nil + } + return publicPresetList(items) +} + +func publicPresetList(items []runtimePreset) []publicPreset { if len(items) == 0 { return nil } @@ -409,6 +416,20 @@ func (c presetCatalog) public() []publicPreset { return publicItems } +func (c presetCatalog) publicAllowed(allowed map[string]struct{}) []publicPreset { + items := c.all() + if len(items) == 0 || len(allowed) == 0 { + return publicPresetList(items) + } + filtered := make([]runtimePreset, 0, len(items)) + for _, item := range items { + if _, ok := allowed[item.ID]; ok { + filtered = append(filtered, item) + } + } + return publicPresetList(filtered) +} + func (c presetCatalog) get(id string) (*runtimePreset, bool) { id = sanitizeSpritzNameToken(id) if id == "" { @@ -678,6 +699,30 @@ func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, ke return s.client.Update(ctx, current) } +func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, name string) error { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" || strings.TrimSpace(name) == "" { + return nil + } + reservationName := idempotencyReservationName(actorID, key) + reservationNamespace := s.idempotencyReservationNamespace() + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + if current.Data == nil { + current.Data = map[string]string{} + } + if strings.TrimSpace(current.Data[idempotencyReservationNameKey]) == name && !strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") { + return nil + } + current.Data[idempotencyReservationNameKey] = name + current.Data[idempotencyReservationDoneKey] = "false" + return s.client.Update(ctx, current) +} + func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) (*spritzv1.Spritz, error) { if strings.TrimSpace(name) == "" { return nil, nil diff --git a/operator/api/v1/lifecycle.go b/operator/api/v1/lifecycle.go index 223a63b..ea749aa 100644 --- a/operator/api/v1/lifecycle.go +++ b/operator/api/v1/lifecycle.go @@ -45,6 +45,8 @@ func LifecycleExpiryTimes(spritz *Spritz) (*metav1.Time, *metav1.Time, *metav1.T } switch { + case idleExpiresAt == nil && maxExpiresAt == nil: + return nil, nil, nil, "", nil case idleExpiresAt == nil: return nil, maxExpiresAt, maxExpiresAt, LifecycleReasonTTL, nil case maxExpiresAt == nil: diff --git a/operator/controllers/lifecycle_test.go b/operator/controllers/lifecycle_test.go index 96273d0..c4d39d3 100644 --- a/operator/controllers/lifecycle_test.go +++ b/operator/controllers/lifecycle_test.go @@ -57,3 +57,22 @@ func TestComputeSpritzLifecycleWindowRejectsInvalidIdleTTL(t *testing.T) { t.Fatal("expected invalid idle ttl error") } } + +func TestComputeSpritzLifecycleWindowReturnsNoReasonWithoutLifetimes(t *testing.T) { + spritz := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + CreationTimestamp: metav1.NewTime(time.Date(2026, 3, 11, 9, 0, 0, 0, time.UTC)), + }, + } + + idleExpiresAt, maxExpiresAt, effectiveExpiresAt, reason, err := spritzv1.LifecycleExpiryTimes(spritz) + if err != nil { + t.Fatalf("LifecycleExpiryTimes returned error: %v", err) + } + if idleExpiresAt != nil || maxExpiresAt != nil || effectiveExpiresAt != nil { + t.Fatalf("expected no expiry timestamps, got idle=%#v max=%#v effective=%#v", idleExpiresAt, maxExpiresAt, effectiveExpiresAt) + } + if reason != "" { + t.Fatalf("expected empty lifecycle reason, got %q", reason) + } +} From 1e9d347921a6bf4e4ae3ba19fe213a6edf06f409 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 18:22:19 +0100 Subject: [PATCH 16/21] fix(provisioning): align control namespace and lifecycle copies --- api/main.go | 4 +-- crd/spritz.sh_spritzes.yaml | 11 ++++++ helm/spritz/crds/spritz.sh_spritzes.yaml | 11 ++++++ helm/spritz/templates/api-deployment.yaml | 2 ++ operator/api/v1/spritz_types.go | 6 ++++ operator/api/v1/spritz_types_test.go | 41 +++++++++++++++++++++++ 6 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 operator/api/v1/spritz_types_test.go diff --git a/api/main.go b/api/main.go index 3ef98f6..7adbaed 100644 --- a/api/main.go +++ b/api/main.go @@ -71,10 +71,10 @@ func main() { ns := os.Getenv("SPRITZ_NAMESPACE") controlNamespace := strings.TrimSpace(os.Getenv("SPRITZ_CONTROL_NAMESPACE")) if controlNamespace == "" { - controlNamespace = strings.TrimSpace(os.Getenv("POD_NAMESPACE")) + controlNamespace = strings.TrimSpace(ns) } if controlNamespace == "" { - controlNamespace = strings.TrimSpace(ns) + controlNamespace = strings.TrimSpace(os.Getenv("POD_NAMESPACE")) } if controlNamespace == "" { controlNamespace = "default" diff --git a/crd/spritz.sh_spritzes.yaml b/crd/spritz.sh_spritzes.yaml index 3a1b392..d041214 100644 --- a/crd/spritz.sh_spritzes.yaml +++ b/crd/spritz.sh_spritzes.yaml @@ -227,6 +227,9 @@ spec: default: true type: boolean type: object + idleTtl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string image: pattern: ^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(:[0-9]+)?(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*(@sha256:[a-f0-9]{64}|:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?$ type: string @@ -658,9 +661,17 @@ spec: expiresAt: format: date-time type: string + idleExpiresAt: + format: date-time + type: string lastActivityAt: format: date-time type: string + lifecycleReason: + type: string + maxExpiresAt: + format: date-time + type: string message: type: string phase: diff --git a/helm/spritz/crds/spritz.sh_spritzes.yaml b/helm/spritz/crds/spritz.sh_spritzes.yaml index 3a1b392..d041214 100644 --- a/helm/spritz/crds/spritz.sh_spritzes.yaml +++ b/helm/spritz/crds/spritz.sh_spritzes.yaml @@ -227,6 +227,9 @@ spec: default: true type: boolean type: object + idleTtl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string image: pattern: ^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(:[0-9]+)?(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*(@sha256:[a-f0-9]{64}|:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?$ type: string @@ -658,9 +661,17 @@ spec: expiresAt: format: date-time type: string + idleExpiresAt: + format: date-time + type: string lastActivityAt: format: date-time type: string + lifecycleReason: + type: string + maxExpiresAt: + format: date-time + type: string message: type: string phase: diff --git a/helm/spritz/templates/api-deployment.yaml b/helm/spritz/templates/api-deployment.yaml index 60c8470..3a826d3 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -46,6 +46,8 @@ spec: fieldPath: metadata.namespace - name: SPRITZ_NAMESPACE value: {{ .Values.spritz.namespace | quote }} + - name: SPRITZ_CONTROL_NAMESPACE + value: {{ .Values.spritz.namespace | quote }} {{- if .Values.api.defaultAnnotations }} - name: SPRITZ_DEFAULT_ANNOTATIONS value: {{ .Values.api.defaultAnnotations | quote }} diff --git a/operator/api/v1/spritz_types.go b/operator/api/v1/spritz_types.go index d55c3dc..3b017d5 100644 --- a/operator/api/v1/spritz_types.go +++ b/operator/api/v1/spritz_types.go @@ -499,6 +499,12 @@ func (in *SpritzStatus) DeepCopyInto(out *SpritzStatus) { if in.LastActivityAt != nil { out.LastActivityAt = in.LastActivityAt.DeepCopy() } + if in.IdleExpiresAt != nil { + out.IdleExpiresAt = in.IdleExpiresAt.DeepCopy() + } + if in.MaxExpiresAt != nil { + out.MaxExpiresAt = in.MaxExpiresAt.DeepCopy() + } if in.ExpiresAt != nil { out.ExpiresAt = in.ExpiresAt.DeepCopy() } diff --git a/operator/api/v1/spritz_types_test.go b/operator/api/v1/spritz_types_test.go new file mode 100644 index 0000000..d11a26a --- /dev/null +++ b/operator/api/v1/spritz_types_test.go @@ -0,0 +1,41 @@ +package v1 + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { + idle := metav1.NewTime(time.Date(2026, 3, 11, 12, 0, 0, 0, time.UTC)) + max := metav1.NewTime(time.Date(2026, 3, 12, 12, 0, 0, 0, time.UTC)) + ready := metav1.NewTime(time.Date(2026, 3, 11, 11, 0, 0, 0, time.UTC)) + + original := &SpritzStatus{ + IdleExpiresAt: &idle, + MaxExpiresAt: &max, + ReadyAt: &ready, + } + + var copied SpritzStatus + original.DeepCopyInto(&copied) + if copied.IdleExpiresAt == original.IdleExpiresAt { + t.Fatal("expected idle expiry timestamp pointer to be deep-copied") + } + if copied.MaxExpiresAt == original.MaxExpiresAt { + t.Fatal("expected max expiry timestamp pointer to be deep-copied") + } + + updatedIdle := metav1.NewTime(copied.IdleExpiresAt.Add(2 * time.Hour)) + updatedMax := metav1.NewTime(copied.MaxExpiresAt.Add(2 * time.Hour)) + copied.IdleExpiresAt = &updatedIdle + copied.MaxExpiresAt = &updatedMax + + if !original.IdleExpiresAt.Equal(&idle) { + t.Fatalf("expected original idle expiry to stay unchanged, got %#v", original.IdleExpiresAt) + } + if !original.MaxExpiresAt.Equal(&max) { + t.Fatalf("expected original max expiry to stay unchanged, got %#v", original.MaxExpiresAt) + } +} From aea1f7c940aece270c4da1cecf43835b1833a359 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 18:47:46 +0100 Subject: [PATCH 17/21] fix(provisioning): preserve pending idempotent names --- api/main.go | 77 ++++++++++------ api/main_create_owner_test.go | 159 +++++++++++++++++++++++++++++++++- api/provisioning.go | 143 +++++++++++++++++++----------- 3 files changed, 301 insertions(+), 78 deletions(-) diff --git a/api/main.go b/api/main.go index 7adbaed..4f72280 100644 --- a/api/main.go +++ b/api/main.go @@ -308,9 +308,6 @@ func (s *server) suggestSpritzName(c echo.Context) error { if s.auth.enabled() && (!ok || principal.ID == "") { return writeError(c, http.StatusUnauthorized, "unauthenticated") } - if principal.isService() && !principal.hasScope(scopeInstancesSuggestName) && !principal.isAdminPrincipal() { - return writeError(c, http.StatusForbidden, "forbidden") - } var body suggestNameRequest if err := c.Bind(&body); err != nil { @@ -325,6 +322,14 @@ func (s *server) suggestSpritzName(c echo.Context) error { if err != nil { return writeError(c, http.StatusForbidden, err.Error()) } + if principal.isService() { + if err := s.validateProvisionerPlacement(principal, namespace, metadata.presetID, strings.TrimSpace(body.Image) != "", strings.TrimSpace(body.Namespace) != "", scopeInstancesSuggestName); err != nil { + if errors.Is(err, errForbidden) { + return writeError(c, http.StatusForbidden, "forbidden") + } + return writeError(c, http.StatusBadRequest, err.Error()) + } + } generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, metadata.namePrefix) if err != nil { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") @@ -441,6 +446,7 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } + provisionerFingerprint := "" if principal.isService() { fingerprintName := body.Name if !nameProvided { @@ -457,6 +463,7 @@ func (s *server) createSpritz(c echo.Context) error { } return writeError(c, http.StatusBadRequest, err.Error()) } + provisionerFingerprint = fingerprint reservedName, completed, err := s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, fingerprint, body.Name) if err != nil { if strings.Contains(err.Error(), "idempotencyKey already used") { @@ -490,7 +497,7 @@ func (s *server) createSpritz(c echo.Context) error { sourceAnnotationKey: provisionerSource(&body), requestIDAnnotationKey: body.RequestID, idempotencyKeyAnnotationKey: body.IdempotencyKey, - idempotencyHashAnnotationKey: fingerprint, + idempotencyHashAnnotationKey: provisionerFingerprint, }) } else if s.auth.enabled() && !principal.isAdminPrincipal() && owner.ID != principal.ID { return writeError(c, http.StatusForbidden, "owner mismatch") @@ -567,32 +574,48 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { attempts = 8 } - for attempt := 0; attempt < attempts; attempt++ { - name := body.Name - if !nameProvided && attempt > 0 { - name = nameGenerator() + currentName := body.Name + for attempt := 0; attempt < attempts; attempt++ { + name := currentName + failedName := "" + if !nameProvided && attempt > 0 { + failedName = currentName + name = nameGenerator() + } + if principal.isService() { + reservedName, completed, err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, provisionerFingerprint, failedName, name) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) } - if principal.isService() { - if err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, name); err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) + name = reservedName + if completed { + existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) + if getErr != nil { + return writeError(c, http.StatusInternalServerError, getErr.Error()) } + if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, provisionerFingerprint) { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + return writeError(c, http.StatusConflict, "idempotencyKey already used") } - spritz, err := createSpritzResource(name) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - if err := s.client.Create(c.Request().Context(), spritz); err != nil { - if principal.isService() && apierrors.IsAlreadyExists(err) { - existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) - if getErr != nil { - return writeError(c, http.StatusInternalServerError, getErr.Error()) - } - if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, annotations[idempotencyHashAnnotationKey]) { - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) - } - if !nameProvided { - continue - } + } + spritz, err := createSpritzResource(name) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + if err := s.client.Create(c.Request().Context(), spritz); err != nil { + if principal.isService() && apierrors.IsAlreadyExists(err) { + existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) + if getErr != nil { + return writeError(c, http.StatusInternalServerError, getErr.Error()) + } + if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, annotations[idempotencyHashAnnotationKey]) { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } + if !nameProvided { + currentName = name + continue + } return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } if !nameProvided && apierrors.IsAlreadyExists(err) { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index e66ad00..d9d51b6 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -14,9 +14,9 @@ import ( "github.com/labstack/echo/v4" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -201,6 +201,80 @@ func TestSuggestSpritzNameUsesPrefixFromRequest(t *testing.T) { } } +func TestSuggestSpritzNameRejectsDisallowedProvisionerTargets(t *testing.T) { + testCases := []struct { + name string + configure func(*server) + body []byte + wantStatus int + wantBody string + }{ + { + name: "preset allowlist", + configure: func(s *server) { + configureProvisionerTestServer(s) + s.presets = presetCatalog{ + byID: []runtimePreset{ + {ID: "openclaw", Name: "OpenClaw", Image: "example.com/spritz-openclaw:latest"}, + {ID: "claude-code", Name: "Claude Code", Image: "example.com/spritz-claude-code:latest"}, + }, + } + }, + body: []byte(`{"presetId":"claude-code"}`), + wantStatus: http.StatusBadRequest, + wantBody: "preset is not allowed", + }, + { + name: "namespace override", + configure: func(s *server) { + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = true + s.provisioners.allowedNamespaces = map[string]struct{}{"team-a": {}} + }, + body: []byte(`{"presetId":"openclaw","namespace":"team-b"}`), + wantStatus: http.StatusBadRequest, + wantBody: "namespace is not allowed", + }, + { + name: "custom image", + configure: func(s *server) { + configureProvisionerTestServer(s) + }, + body: []byte(`{"image":"example.com/spritz-claude-code:latest"}`), + wantStatus: http.StatusBadRequest, + wantBody: "custom image is not allowed", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := newCreateSpritzTestServer(t) + tc.configure(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes/suggest-name", s.suggestSpritzName) + + req := httptest.NewRequest(http.MethodPost, "/api/spritzes/suggest-name", bytes.NewReader(tc.body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", scopeInstancesSuggestName) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Fatalf("expected status %d, got %d: %s", tc.wantStatus, rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), tc.wantBody) { + t.Fatalf("expected response body %q, got %s", tc.wantBody, rec.Body.String()) + } + }) + } +} + func TestCreateSpritzGeneratesPrefixedNameFromImage(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() @@ -1060,6 +1134,89 @@ func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T } } +func TestSetIdempotencyReservationNameKeepsSinglePendingCandidate(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + + body := createRequest{ + OwnerID: "user-123", + IdempotencyKey: "discord-pending-single-name", + PresetID: "openclaw", + } + applyTopLevelCreateFields(&body) + owner, err := normalizeCreateOwner(&body, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + body.Spec.Owner = owner + if _, err := s.applyCreatePreset(&body); err != nil { + t.Fatalf("applyCreatePreset failed: %v", err) + } + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { + t.Fatalf("resolveCreateLifetimes failed: %v", err) + } + fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) + if err != nil { + t.Fatalf("createFingerprint failed: %v", err) + } + + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", body.IdempotencyKey), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: fingerprint, + idempotencyReservationNameKey: "openclaw-blocked", + idempotencyReservationDoneKey: "false", + }, + }); err != nil { + t.Fatalf("failed to seed reservation: %v", err) + } + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-blocked", + Namespace: s.namespace, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-other:latest", + Owner: spritzv1.SpritzOwner{ID: "someone-else"}, + }, + }); err != nil { + t.Fatalf("failed to seed conflicting spritz: %v", err) + } + + firstName, done, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, fingerprint, "openclaw-blocked", "openclaw-alpha") + if err != nil { + t.Fatalf("first reservation update failed: %v", err) + } + if done { + t.Fatal("expected reservation to remain pending") + } + if firstName != "openclaw-alpha" { + t.Fatalf("expected first replacement name %q, got %q", "openclaw-alpha", firstName) + } + + secondName, done, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, fingerprint, "openclaw-blocked", "openclaw-beta") + if err != nil { + t.Fatalf("second reservation update failed: %v", err) + } + if done { + t.Fatal("expected reservation to remain pending") + } + if secondName != "openclaw-alpha" { + t.Fatalf("expected second caller to reuse %q, got %q", "openclaw-alpha", secondName) + } + + reservation := &corev1.ConfigMap{} + if err := s.client.Get(context.Background(), clientKey(s.namespace, idempotencyReservationName("zenobot", body.IdempotencyKey)), reservation); err != nil { + t.Fatalf("failed to reload reservation: %v", err) + } + if got := strings.TrimSpace(reservation.Data[idempotencyReservationNameKey]); got != "openclaw-alpha" { + t.Fatalf("expected reservation to stay on %q, got %q", "openclaw-alpha", got) + } +} + func TestCreateSpritzDoesNotReplayDifferentActorOrKeyForSameNamedWorkspace(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) diff --git a/api/provisioning.go b/api/provisioning.go index 5e0bfa2..faeaa92 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -14,6 +14,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" spritzv1 "spritz.sh/operator/api/v1" @@ -260,29 +261,36 @@ func (p provisionerPolicy) validatePreset(presetID string) error { return fmt.Errorf("preset is not allowed: %s", presetID) } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint, namePrefixForFingerprint string) (string, error) { +func (s *server) validateProvisionerPlacement(principal principal, namespace, presetID string, requestedImage, requestedNamespace bool, scope string) error { if !principalCanUseProvisionerFlow(principal) { - return "", errForbidden - } - if err := authorizeServiceAction(principal, scopeInstancesCreate, true); err != nil { - return "", err + return errForbidden } - if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { - return "", err + if err := authorizeServiceAction(principal, scope, true); err != nil { + return err } if requestedNamespace && !s.provisioners.allowNamespaceOverride { - return "", fmt.Errorf("namespace override is not allowed") + return fmt.Errorf("namespace override is not allowed") } if err := s.provisioners.validateNamespace(namespace); err != nil { - return "", err + return err } - if body.PresetID != "" { - if err := s.provisioners.validatePreset(body.PresetID); err != nil { - return "", err + if presetID != "" { + if err := s.provisioners.validatePreset(presetID); err != nil { + return err } } if requestedImage && !s.provisioners.allowCustomImage { - return "", fmt.Errorf("custom image is not allowed") + return fmt.Errorf("custom image is not allowed") + } + return nil +} + +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint, namePrefixForFingerprint string) (string, error) { + if err := s.validateProvisionerPlacement(principal, namespace, body.PresetID, requestedImage, requestedNamespace, scopeInstancesCreate); err != nil { + return "", err + } + if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { + return "", err } if requestedRepo && !s.provisioners.allowCustomRepo { return "", fmt.Errorf("custom repo is not allowed") @@ -649,31 +657,16 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri return name, true, nil } if name == "" { - name = desiredName - } - if name != "" { - reservedSpritz, getErr := s.findReservedSpritz(ctx, namespace, name) - if getErr != nil { - return "", false, getErr - } - if reservedSpritz != nil && strings.TrimSpace(reservedSpritz.Annotations[idempotencyHashAnnotationKey]) != fingerprint { - name = desiredName - } + return s.setIdempotencyReservationName(ctx, principal.ID, key, fingerprint, "", desiredName) } - if name == "" { - name = desiredName + reservedSpritz, getErr := s.findReservedSpritz(ctx, namespace, name) + if getErr != nil { + return "", false, getErr } - if name != strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) { - if existing.Data == nil { - existing.Data = map[string]string{} - } - existing.Data[idempotencyReservationNameKey] = name - existing.Data[idempotencyReservationDoneKey] = "false" - if updateErr := s.client.Update(ctx, existing); updateErr != nil { - return "", false, updateErr - } + if reservedSpritz != nil && !matchesIdempotentReplayTarget(reservedSpritz, principal, key, fingerprint) { + return s.setIdempotencyReservationName(ctx, principal.ID, key, fingerprint, name, desiredName) } - return name, done, nil + return name, false, nil } return desiredName, false, nil } @@ -699,28 +692,78 @@ func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, ke return s.client.Update(ctx, current) } -func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, name string) error { - if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" || strings.TrimSpace(name) == "" { - return nil +func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, fingerprint, failedName, proposedName string) (string, bool, error) { + failedName = strings.TrimSpace(failedName) + proposedName = strings.TrimSpace(proposedName) + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { + return proposedName, false, nil } reservationName := idempotencyReservationName(actorID, key) reservationNamespace := s.idempotencyReservationNamespace() - current := &corev1.ConfigMap{} - if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { - if apierrors.IsNotFound(err) { + selectedName := proposedName + completed := false + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { + if apierrors.IsNotFound(err) { + selectedName = proposedName + completed = false + return nil + } + return err + } + if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(fingerprint) { + return fmt.Errorf("idempotencyKey already used with a different request") + } + storedName := strings.TrimSpace(current.Data[idempotencyReservationNameKey]) + done := strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") + if done { + if storedName == "" { + storedName = proposedName + } + selectedName = storedName + completed = true return nil } - return err - } - if current.Data == nil { - current.Data = map[string]string{} - } - if strings.TrimSpace(current.Data[idempotencyReservationNameKey]) == name && !strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") { + if storedName == "" { + if proposedName == "" { + selectedName = "" + completed = false + return nil + } + if current.Data == nil { + current.Data = map[string]string{} + } + current.Data[idempotencyReservationNameKey] = proposedName + current.Data[idempotencyReservationDoneKey] = "false" + if err := s.client.Update(ctx, current); err != nil { + return err + } + selectedName = proposedName + completed = false + return nil + } + if failedName == "" || storedName != failedName || proposedName == "" || proposedName == storedName { + selectedName = storedName + completed = false + return nil + } + if current.Data == nil { + current.Data = map[string]string{} + } + current.Data[idempotencyReservationNameKey] = proposedName + current.Data[idempotencyReservationDoneKey] = "false" + if err := s.client.Update(ctx, current); err != nil { + return err + } + selectedName = proposedName + completed = false return nil + }) + if err != nil { + return "", false, err } - current.Data[idempotencyReservationNameKey] = name - current.Data[idempotencyReservationDoneKey] = "false" - return s.client.Update(ctx, current) + return selectedName, completed, nil } func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) (*spritzv1.Spritz, error) { From e6b58fba76a9e1a566181d229acbff3832faa935 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 19:02:46 +0100 Subject: [PATCH 18/21] fix(provisioning): align default preset flows --- api/main.go | 4 +- api/main_create_owner_test.go | 47 ++++++++++++++++++++ api/provisioning.go | 16 +++++++ cli/src/index.ts | 1 - cli/test/provisioner-create.test.ts | 66 +++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/api/main.go b/api/main.go index 4f72280..72b884e 100644 --- a/api/main.go +++ b/api/main.go @@ -313,6 +313,7 @@ func (s *server) suggestSpritzName(c echo.Context) error { if err := c.Bind(&body); err != nil { return writeError(c, http.StatusBadRequest, "invalid json") } + s.applyProvisionerDefaultSuggestNamePreset(&body, principal) metadata, err := s.resolveSuggestNameMetadata(body) if err != nil { return writeError(c, http.StatusBadRequest, err.Error()) @@ -322,8 +323,9 @@ func (s *server) suggestSpritzName(c echo.Context) error { if err != nil { return writeError(c, http.StatusForbidden, err.Error()) } + requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) if principal.isService() { - if err := s.validateProvisionerPlacement(principal, namespace, metadata.presetID, strings.TrimSpace(body.Image) != "", strings.TrimSpace(body.Namespace) != "", scopeInstancesSuggestName); err != nil { + if err := s.validateProvisionerPlacement(principal, namespace, metadata.presetID, strings.TrimSpace(body.Image) != "", requestedNamespace, scopeInstancesSuggestName); err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") } diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index d9d51b6..0a07ade 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -275,6 +275,53 @@ func TestSuggestSpritzNameRejectsDisallowedProvisionerTargets(t *testing.T) { } } +func TestSuggestSpritzNameAllowsSameNamespaceWithoutTreatingItAsOverride(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes/suggest-name", s.suggestSpritzName) + + body := []byte(`{"presetId":"openclaw","namespace":"spritz-test"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes/suggest-name", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", scopeInstancesSuggestName) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestSuggestSpritzNameUsesProvisionerDefaultPreset(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes/suggest-name", s.suggestSpritzName) + + req := httptest.NewRequest(http.MethodPost, "/api/spritzes/suggest-name", bytes.NewReader([]byte(`{}`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", scopeInstancesSuggestName) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "openclaw-") { + t.Fatalf("expected generated openclaw-prefixed suggestion, got %s", rec.Body.String()) + } +} + func TestCreateSpritzGeneratesPrefixedNameFromImage(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() diff --git a/api/provisioning.go b/api/provisioning.go index faeaa92..6a4c8d0 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -148,6 +148,22 @@ func (s *server) applyProvisionerDefaultPreset(body *createRequest, principal pr body.PresetID = s.provisioners.defaultPresetID } +func (s *server) applyProvisionerDefaultSuggestNamePreset(body *suggestNameRequest, principal principal) { + if body == nil || !principal.isService() { + return + } + if strings.TrimSpace(body.PresetID) != "" { + return + } + if strings.TrimSpace(body.Image) != "" { + return + } + if s.provisioners.defaultPresetID == "" { + return + } + body.PresetID = s.provisioners.defaultPresetID +} + func applyTopLevelCreateFields(body *createRequest) { if strings.TrimSpace(body.OwnerID) != "" && strings.TrimSpace(body.Spec.Owner.ID) == "" { body.Spec.Owner.ID = strings.TrimSpace(body.OwnerID) diff --git a/cli/src/index.ts b/cli/src/index.ts index b403d73..962568e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1076,7 +1076,6 @@ async function main() { const name = positionalArgs()[0]; const presetId = argValue('--preset'); const image = argValue('--image'); - if (!presetId && !image) throw new Error('--preset or --image is required'); const repo = argValue('--repo'); const branch = argValue('--branch'); diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index 0eee284..b0c09c6 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -146,3 +146,69 @@ test('create falls back to local owner identity without bearer auth', async (t) const payload = JSON.parse(stdout); assert.equal(payload.ownerId, 'local-user'); }); + +test('create allows server-side default preset resolution', async (t) => { + let requestBody: any = null; + let requestHeaders: http.IncomingHttpHeaders | null = null; + + const server = http.createServer((req, res) => { + requestHeaders = req.headers; + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'success', + data: { + accessUrl: 'https://console.example.com/w/openclaw-tide-wind/', + ownerId: 'user-123', + presetId: 'openclaw', + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--owner-id', 'user-123', '--idempotency-key', 'discord-default-preset'], + { + env: { + ...process.env, + SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`, + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(exitCode, 0, `spz create should succeed: ${stderr}`); + + assert.equal(requestHeaders?.authorization, 'Bearer service-token'); + assert.deepEqual(requestBody, { + ownerId: 'user-123', + idempotencyKey: 'discord-default-preset', + spec: {}, + }); + + const payload = JSON.parse(stdout); + assert.equal(payload.presetId, 'openclaw'); + assert.equal(payload.ownerId, 'user-123'); +}); From 632726b836da2e4c37d18f1cd8f62e77e8382936 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 21:21:44 +0100 Subject: [PATCH 19/21] feat(provisioning): enforce strict idempotency cutover --- api/main.go | 193 ++++++++--- api/main_create_owner_test.go | 623 +++++++++++++++++++++++++++++++++- api/provisioning.go | 287 +++++++++++++--- 3 files changed, 1001 insertions(+), 102 deletions(-) diff --git a/api/main.go b/api/main.go index 72b884e..4e3a88e 100644 --- a/api/main.go +++ b/api/main.go @@ -358,11 +358,23 @@ func (s *server) createSpritz(c echo.Context) error { } } + namespace, err := s.resolveSpritzNamespace(body.Namespace) + if err != nil { + return writeError(c, http.StatusForbidden, err.Error()) + } + requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) + + owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + body.Spec.Owner = owner + provisionerFingerprintBody := body + requestedImage := strings.TrimSpace(body.Spec.Image) != "" requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 s.applyProvisionerDefaultPreset(&body, principal) - preset, err := s.applyCreatePreset(&body) - if err != nil { + if _, err := s.applyCreatePreset(&body); err != nil { return writeError(c, http.StatusBadRequest, err.Error()) } @@ -418,68 +430,146 @@ func (s *server) createSpritz(c echo.Context) error { body.Spec.SharedMounts = normalized } - namespace, err := s.resolveSpritzNamespace(body.Namespace) - if err != nil { - return writeError(c, http.StatusForbidden, err.Error()) - } - requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) - - owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - body.Spec.Owner = owner - nameProvided := body.Name != "" var nameGenerator func() string - namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Spec.Image) - if namePrefix == "" && preset != nil { - namePrefix = resolveSpritzNamePrefix(preset.NamePrefix, preset.Image) - } - if !nameProvided { - generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) + requestedNamePrefix := strings.TrimSpace(provisionerFingerprintBody.NamePrefix) + buildNameGenerator := func(resolved createRequest) error { + namePrefix := requestedNamePrefix + if restoredNamePrefix := strings.TrimSpace(resolved.NamePrefix); restoredNamePrefix != "" { + namePrefix = restoredNamePrefix + } + generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, s.resolvedCreateNamePrefix(resolved, namePrefix)) if err != nil { - return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + return err } nameGenerator = generator - body.Name = nameGenerator() + return nil } - if body.Name == "" { + if !nameProvided { + if !principal.isService() { + if err := buildNameGenerator(body); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + } + body.Name = nameGenerator() + } + } + if !principal.isService() && body.Name == "" { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } provisionerFingerprint := "" + idempotencyState := provisionerIdempotencyState{} + resolvedFromReservation := false + completed := false if principal.isService() { - fingerprintName := body.Name - if !nameProvided { - fingerprintName = "" + canonicalName := strings.TrimSpace(provisionerFingerprintBody.Name) + canonicalNamePrefix := "" + if canonicalName == "" { + canonicalNamePrefix = strings.TrimSpace(provisionerFingerprintBody.NamePrefix) } - fingerprintNamePrefix := "" - if !nameProvided { - fingerprintNamePrefix = namePrefix - } - fingerprint, err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, normalizedUserConfig, requestedImage, requestedRepo, requestedNamespace, fingerprintName, fingerprintNamePrefix) + provisionerFingerprint, err = createRequestFingerprint(provisionerFingerprintBody, namespace, canonicalName, canonicalNamePrefix, normalizedUserConfig) if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if err := authorizeServiceAction(principal, scopeInstancesCreate, true); err != nil { + if errors.Is(err, errForbidden) { + return writeError(c, http.StatusForbidden, "forbidden") + } + return writeError(c, http.StatusForbidden, "forbidden") + } + if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { if errors.Is(err, errForbidden) { return writeError(c, http.StatusForbidden, "forbidden") } return writeError(c, http.StatusBadRequest, err.Error()) } - provisionerFingerprint = fingerprint - reservedName, completed, err := s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, fingerprint, body.Name) + if body.IdempotencyKey == "" { + return writeError(c, http.StatusBadRequest, "idempotencyKey is required") + } + var reservationName string + var storedPayload string + var found bool + reservationName, completed, storedPayload, found, err = s.getIdempotencyReservation(c.Request().Context(), principal.ID, body.IdempotencyKey, provisionerFingerprint) if err != nil { if strings.Contains(err.Error(), "idempotencyKey already used") { return writeError(c, http.StatusConflict, err.Error()) } return writeError(c, http.StatusInternalServerError, err.Error()) } - body.Name = reservedName - existing, err := s.findReservedSpritz(c.Request().Context(), namespace, reservedName) + restoreStoredPayload := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return fmt.Errorf("idempotencyKey already used by an incompatible pending request") + } + payload, err := decodeResolvedProvisionerPayload(raw) + if err != nil { + return err + } + body.PresetID = payload.PresetID + body.NamePrefix = payload.NamePrefix + body.Source = payload.Source + body.RequestID = payload.RequestID + body.Spec = payload.Spec + owner = body.Spec.Owner + return nil + } + if found { + if err := restoreStoredPayload(storedPayload); err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + resolvedFromReservation = true + idempotencyState = provisionerIdempotencyState{ + canonicalFingerprint: provisionerFingerprint, + resolvedPayload: strings.TrimSpace(storedPayload), + } + if strings.TrimSpace(reservationName) != "" { + body.Name = reservationName + } + } else { + if err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, requestedImage, requestedRepo, requestedNamespace); err != nil { + if errors.Is(err, errForbidden) { + return writeError(c, http.StatusForbidden, "forbidden") + } + return writeError(c, http.StatusBadRequest, err.Error()) + } + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + idempotencyState, err = s.provisionerIdempotencyFingerprints(provisionerFingerprintBody, body, namespace, normalizedUserConfig) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + var reservedName string + reservedName, completed, storedPayload, err = s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, body.Name, idempotencyState) + if err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if strings.TrimSpace(storedPayload) != "" { + if err := restoreStoredPayload(storedPayload); err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } + body.Name = reservedName + } + if !nameProvided { + if err := buildNameGenerator(body); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + } + } + existing, err := s.findReservedSpritz(c.Request().Context(), namespace, body.Name) if err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) } if existing != nil { - if !matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, fingerprint) { + if !matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, provisionerFingerprint) { if completed { return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") } @@ -490,8 +580,10 @@ func (s *server) createSpritz(c echo.Context) error { if completed { return writeError(c, http.StatusConflict, "idempotencyKey already used") } - if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + if !resolvedFromReservation { + if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } } body.Annotations = mergeStringMap(body.Annotations, map[string]string{ actorIDAnnotationKey: principal.ID, @@ -505,8 +597,10 @@ func (s *server) createSpritz(c echo.Context) error { return writeError(c, http.StatusForbidden, "owner mismatch") } - if err := resolveCreateLifetimes(&body.Spec, s.provisioners, principal.isService()); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + if !principal.isService() { + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, false); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } } labels := map[string]string{ @@ -580,15 +674,24 @@ func (s *server) createSpritz(c echo.Context) error { for attempt := 0; attempt < attempts; attempt++ { name := currentName failedName := "" - if !nameProvided && attempt > 0 { - failedName = currentName - name = nameGenerator() + if !nameProvided { + if attempt > 0 { + failedName = currentName + } + if strings.TrimSpace(name) == "" || attempt > 0 { + name = nameGenerator() + } } if principal.isService() { - reservedName, completed, err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, provisionerFingerprint, failedName, name) + reservedName, completed, storedPayload, err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, failedName, name, idempotencyState) if err != nil { + if strings.Contains(err.Error(), "idempotencyKey already used") { + return writeError(c, http.StatusConflict, err.Error()) + } return writeError(c, http.StatusInternalServerError, err.Error()) } + if strings.TrimSpace(storedPayload) != "" { + } name = reservedName if completed { existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) @@ -611,7 +714,7 @@ func (s *server) createSpritz(c echo.Context) error { if getErr != nil { return writeError(c, http.StatusInternalServerError, getErr.Error()) } - if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, annotations[idempotencyHashAnnotationKey]) { + if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, provisionerFingerprint) { return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } if !nameProvided { diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 0a07ade..806ae0d 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -322,6 +322,38 @@ func TestSuggestSpritzNameUsesProvisionerDefaultPreset(t *testing.T) { } } +func TestSuggestSpritzNamePreservesPresetNamePrefix(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.presets = presetCatalog{ + byID: []runtimePreset{{ + ID: "openclaw", + Name: "OpenClaw", + Image: "example.com/spritz-openclaw:latest", + NamePrefix: "discord-claw", + }}, + } + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes/suggest-name", s.suggestSpritzName) + + req := httptest.NewRequest(http.MethodPost, "/api/spritzes/suggest-name", bytes.NewReader([]byte(`{"presetId":"openclaw"}`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", scopeInstancesSuggestName) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "discord-claw-") { + t.Fatalf("expected generated discord-claw-prefixed suggestion, got %s", rec.Body.String()) + } +} + func TestCreateSpritzGeneratesPrefixedNameFromImage(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() @@ -626,6 +658,39 @@ func TestCreateSpritzRejectsIdempotentProvisionerRequestWhenNamePrefixChanges(t } } +func TestCreateSpritzReplaysEquivalentProvisionerNamePrefixFormatting(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + first := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-format","namePrefix":"Claude Code"}`) + second := []byte(`{"presetId":"OPENCLAW","ownerId":"user-123","idempotencyKey":"discord-format","namePrefix":"claude-code"}`) + + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(first)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(second)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("expected replay status 200, got %d: %s", rec2.Code, rec2.Body.String()) + } +} + func TestCreateSpritzReplaysIdempotentProvisionerRequestBeforeQuotaCheck(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -903,6 +968,331 @@ func TestCreateSpritzUsesProvisionerDefaultPresetWhenPresetOmitted(t *testing.T) } } +func TestCreateSpritzReplaysIdempotentProvisionerRequestAfterDefaultPresetChanges(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + s.presets = presetCatalog{ + byID: []runtimePreset{ + { + ID: "openclaw", + Name: "OpenClaw", + Image: "example.com/spritz-openclaw:latest", + NamePrefix: "openclaw", + }, + { + ID: "claude-code", + Name: "Claude Code", + Image: "example.com/spritz-claude-code:latest", + NamePrefix: "claude-code", + }, + }, + } + s.provisioners.allowedPresetIDs = map[string]struct{}{"openclaw": {}, "claude-code": {}} + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"ownerId":"user-123","idempotencyKey":"discord-default-shift"}`) + req1 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req1.Header.Set("X-Spritz-User-Id", "zenobot") + req1.Header.Set("X-Spritz-Principal-Type", "service") + req1.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec1 := httptest.NewRecorder() + e.ServeHTTP(rec1, req1) + + if rec1.Code != http.StatusCreated { + t.Fatalf("expected first create status 201, got %d: %s", rec1.Code, rec1.Body.String()) + } + + s.provisioners.defaultPresetID = "claude-code" + + req2 := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req2.Header.Set("X-Spritz-User-Id", "zenobot") + req2.Header.Set("X-Spritz-Principal-Type", "service") + req2.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec2 := httptest.NewRecorder() + e.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("expected replay status 200 after default preset change, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "\"presetId\":\"openclaw\"") { + t.Fatalf("expected replay to return the original openclaw spritz, got %s", rec2.Body.String()) + } +} + +func TestCreateSpritzRejectsLegacyCompletedProvisionerRequestAfterDefaultPresetChanges(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + s.presets = presetCatalog{ + byID: []runtimePreset{ + {ID: "openclaw", Name: "OpenClaw", Image: "example.com/spritz-openclaw:latest", NamePrefix: "openclaw"}, + {ID: "claude-code", Name: "Claude Code", Image: "example.com/spritz-claude-code:latest", NamePrefix: "claude-code"}, + }, + } + s.provisioners.allowedPresetIDs = map[string]struct{}{"openclaw": {}, "claude-code": {}} + + requestBody := createRequest{OwnerID: "user-123", IdempotencyKey: "discord-legacy-completed"} + applyTopLevelCreateFields(&requestBody) + owner, err := normalizeCreateOwner(&requestBody, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + requestBody.Spec.Owner = owner + s.applyProvisionerDefaultPreset(&requestBody, principal{ID: "zenobot", Type: principalTypeService}) + if _, err := s.applyCreatePreset(&requestBody); err != nil { + t.Fatalf("applyCreatePreset failed: %v", err) + } + if err := resolveCreateLifetimes(&requestBody.Spec, s.provisioners, true); err != nil { + t.Fatalf("resolveCreateLifetimes failed: %v", err) + } + legacyFingerprint, err := s.resolvedCreateFingerprint(requestBody, s.namespace, "", nil) + if err != nil { + t.Fatalf("resolvedCreateFingerprint failed: %v", err) + } + + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", "discord-legacy-completed"), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: legacyFingerprint, + idempotencyReservationNameKey: "openclaw-legacy", + idempotencyReservationDoneKey: "true", + }, + }); err != nil { + t.Fatalf("failed to seed legacy reservation: %v", err) + } + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-legacy", + Namespace: s.namespace, + Annotations: map[string]string{ + idempotencyHashAnnotationKey: legacyFingerprint, + idempotencyKeyAnnotationKey: "discord-legacy-completed", + actorIDAnnotationKey: "zenobot", + presetIDAnnotationKey: "openclaw", + sourceAnnotationKey: "external", + }, + }, + Spec: requestBody.Spec, + }); err != nil { + t.Fatalf("failed to seed legacy spritz: %v", err) + } + + s.provisioners.defaultPresetID = "claude-code" + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"ownerId":"user-123","idempotencyKey":"discord-legacy-completed"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("expected strict-cutover conflict for legacy completed reservation, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "idempotencyKey already used with a different request") { + t.Fatalf("expected legacy completed reservation to fail closed, got %s", rec.Body.String()) + } +} + +func TestCreateSpritzUsesPendingReservationPayloadAfterDefaultPresetChanges(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + s.nameGeneratorFactory = func(_ context.Context, _ string, prefix string) (func() string, error) { + names := []string{prefix + "-retry-one", prefix + "-retry-two"} + index := 0 + return func() string { + name := names[index] + if index < len(names)-1 { + index++ + } + return name + }, nil + } + s.presets = presetCatalog{ + byID: []runtimePreset{ + { + ID: "openclaw", + Name: "OpenClaw", + Image: "example.com/spritz-openclaw:latest", + NamePrefix: "openclaw", + }, + { + ID: "claude-code", + Name: "Claude Code", + Image: "example.com/spritz-claude-code:latest", + NamePrefix: "claude-code", + }, + }, + } + s.provisioners.allowedPresetIDs = map[string]struct{}{"openclaw": {}, "claude-code": {}} + + requestBody := createRequest{ + OwnerID: "user-123", + IdempotencyKey: "discord-pending-default-shift", + } + applyTopLevelCreateFields(&requestBody) + owner, err := normalizeCreateOwner(&requestBody, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + requestBody.Spec.Owner = owner + requestFingerprintBody := requestBody + s.applyProvisionerDefaultPreset(&requestBody, principal{ID: "zenobot", Type: principalTypeService}) + if _, err := s.applyCreatePreset(&requestBody); err != nil { + t.Fatalf("applyCreatePreset failed: %v", err) + } + if err := resolveCreateLifetimes(&requestBody.Spec, s.provisioners, true); err != nil { + t.Fatalf("resolveCreateLifetimes failed: %v", err) + } + fingerprint, err := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) + if err != nil { + t.Fatalf("createRequestFingerprint failed: %v", err) + } + resolvedPayload, err := createResolvedProvisionerPayload(requestBody, s.resolvedCreateNamePrefix(requestBody, requestFingerprintBody.NamePrefix)) + if err != nil { + t.Fatalf("createResolvedProvisionerPayload failed: %v", err) + } + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", requestBody.IdempotencyKey), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: fingerprint, + idempotencyReservationNameKey: "openclaw-pending-default", + idempotencyReservationDoneKey: "false", + idempotencyReservationBodyKey: resolvedPayload, + }, + }); err != nil { + t.Fatalf("failed to seed reservation: %v", err) + } + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-pending-default", + Namespace: s.namespace, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-other:latest", + Owner: spritzv1.SpritzOwner{ID: "someone-else"}, + }, + }); err != nil { + t.Fatalf("failed to seed conflicting pending name: %v", err) + } + + s.provisioners.defaultPresetID = "claude-code" + s.presets.byID[0].NamePrefix = "newprefix" + s.provisioners.maxTTL = 24 * time.Hour + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"ownerId":"user-123","idempotencyKey":"discord-pending-default-shift"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "\"presetId\":\"openclaw\"") { + t.Fatalf("expected pending replay to keep the original openclaw preset, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "\"image\":\"example.com/spritz-openclaw:latest\"") { + t.Fatalf("expected pending replay to create the original openclaw image, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "\"name\":\"openclaw-retry-one\"") { + t.Fatalf("expected pending replay to keep the original openclaw name prefix, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "\"ttl\":\"168h0m0s\"") { + t.Fatalf("expected pending replay to keep the original ttl despite stricter current policy, got %s", rec.Body.String()) + } +} + +func TestCreateSpritzRejectsPendingReservationWithoutPayload(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.provisioners.defaultPresetID = "openclaw" + s.presets = presetCatalog{ + byID: []runtimePreset{ + {ID: "openclaw", Name: "OpenClaw", Image: "example.com/spritz-openclaw:latest", NamePrefix: "openclaw"}, + {ID: "claude-code", Name: "Claude Code", Image: "example.com/spritz-claude-code:latest", NamePrefix: "claude-code"}, + }, + } + s.provisioners.allowedPresetIDs = map[string]struct{}{"openclaw": {}, "claude-code": {}} + + requestBody := createRequest{ + OwnerID: "user-123", + IdempotencyKey: "discord-pending-without-payload", + } + applyTopLevelCreateFields(&requestBody) + owner, err := normalizeCreateOwner(&requestBody, principal{ID: "zenobot", Type: principalTypeService}, s.auth.enabled()) + if err != nil { + t.Fatalf("normalizeCreateOwner failed: %v", err) + } + requestBody.Spec.Owner = owner + requestFingerprintBody := requestBody + currentFingerprint, err := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) + if err != nil { + t.Fatalf("createRequestFingerprint failed: %v", err) + } + if err := s.client.Create(context.Background(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName("zenobot", requestBody.IdempotencyKey), + Namespace: s.namespace, + }, + Data: map[string]string{ + idempotencyReservationHashKey: currentFingerprint, + idempotencyReservationNameKey: "openclaw-cutover", + idempotencyReservationDoneKey: "false", + }, + }); err != nil { + t.Fatalf("failed to seed reservation: %v", err) + } + + s.provisioners.defaultPresetID = "claude-code" + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"ownerId":"user-123","idempotencyKey":"discord-pending-without-payload"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("expected strict-cutover conflict for legacy pending reservation, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "idempotencyKey already used by an incompatible pending request") { + t.Fatalf("expected legacy pending reservation to fail closed, got %s", rec.Body.String()) + } +} + func TestCreateSpritzAllowsProvisionerCurrentNamespaceWithoutOverride(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -925,6 +1315,42 @@ func TestCreateSpritzAllowsProvisionerCurrentNamespaceWithoutOverride(t *testing } } +func TestCreateSpritzKeepsResponseMetadataIndependentFromHumanAnnotations(t *testing.T) { + s := newCreateSpritzTestServer(t) + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{ + "name":"tidal-ember", + "annotations":{ + "spritz.sh/preset-id":"spoofed-preset", + "spritz.sh/source":"spoofed-source", + "spritz.sh/idempotency-key":"spoofed-key" + }, + "spec":{"image":"example.com/spritz:latest"} + }`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "user-1") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + responseBody := rec.Body.String() + if strings.Contains(responseBody, "\"presetId\":\"spoofed-preset\"") { + t.Fatalf("expected response presetId to ignore caller annotations, got %s", responseBody) + } + if strings.Contains(responseBody, "\"source\":\"spoofed-source\"") { + t.Fatalf("expected response source to ignore caller annotations, got %s", responseBody) + } + if strings.Contains(responseBody, "\"idempotencyKey\":\"spoofed-key\"") { + t.Fatalf("expected response idempotencyKey to ignore caller annotations, got %s", responseBody) + } +} + func TestCreateSpritzRejectsExplicitNamespaceForProvisionerWhenOverrideDisabled(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -954,6 +1380,37 @@ func TestCreateSpritzRejectsExplicitNamespaceForProvisionerWhenOverrideDisabled( } } +func TestCreateSpritzRejectsUnboundedNamespaceOverrideWhenQuotaEnforced(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = true + s.provisioners.allowedNamespaces = nil + s.provisioners.maxActivePerOwner = 1 + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-global-override","namespace":"team-b"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "quota enforcement requires allowed namespaces when namespace override is enabled") { + t.Fatalf("expected unbounded override quota error, got %s", rec.Body.String()) + } +} + func TestCreateSpritzRejectsProvisionerIdempotencyReuseAcrossNamespaces(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -997,6 +1454,138 @@ func TestCreateSpritzRejectsProvisionerIdempotencyReuseAcrossNamespaces(t *testi } } +func TestCreateSpritzEnforcesProvisionerActiveQuotaAcrossAllowedNamespaces(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = true + s.provisioners.allowedNamespaces = map[string]struct{}{"team-a": {}, "team-b": {}} + s.provisioners.maxActivePerOwner = 1 + + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-existing", + Namespace: "team-a", + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-123"}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + }); err != nil { + t.Fatalf("failed to seed existing spritz: %v", err) + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-cross-ns-active","namespace":"team-b"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "owner active workspace limit reached") { + t.Fatalf("expected active quota error, got %s", rec.Body.String()) + } +} + +func TestCreateSpritzIgnoresOtherNamespacesWhenOverrideDisabled(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = false + s.provisioners.maxActivePerOwner = 1 + + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-other-namespace", + Namespace: "team-a", + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-123"}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + }); err != nil { + t.Fatalf("failed to seed unrelated namespace spritz: %v", err) + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-default-ns"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateSpritzEnforcesProvisionerActorRateAcrossAllowedNamespaces(t *testing.T) { + s := newCreateSpritzTestServer(t) + configureProvisionerTestServer(s) + s.namespace = "" + s.controlNamespace = "spritz-system" + s.provisioners.allowNamespaceOverride = true + s.provisioners.allowedNamespaces = map[string]struct{}{"team-a": {}, "team-b": {}} + s.provisioners.maxCreatesPerActor = 1 + + if err := s.client.Create(context.Background(), &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openclaw-actor-existing", + Namespace: "team-a", + CreationTimestamp: metav1.NewTime(time.Now()), + Annotations: map[string]string{ + actorIDAnnotationKey: "zenobot", + }, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/spritz-openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-456"}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + }); err != nil { + t.Fatalf("failed to seed actor-rate spritz: %v", err) + } + + e := echo.New() + secured := e.Group("", s.authMiddleware()) + secured.POST("/api/spritzes", s.createSpritz) + + body := []byte(`{"presetId":"openclaw","ownerId":"user-123","idempotencyKey":"discord-cross-ns-actor","namespace":"team-b"}`) + req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("X-Spritz-User-Id", "zenobot") + req.Header.Set("X-Spritz-Principal-Type", "service") + req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "actor create rate limit reached") { + t.Fatalf("expected actor rate error, got %s", rec.Body.String()) + } +} + func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant(t *testing.T) { s := newCreateSpritzTestServer(t) configureProvisionerTestServer(s) @@ -1017,15 +1606,20 @@ func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant t.Fatalf("normalizeCreateOwner failed: %v", err) } body.Spec.Owner = owner + requestFingerprintBody := body if _, err := s.applyCreatePreset(&body); err != nil { t.Fatalf("applyCreatePreset failed: %v", err) } if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { t.Fatalf("resolveCreateLifetimes failed: %v", err) } - fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) + fingerprint, err := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) + if err != nil { + t.Fatalf("createRequestFingerprint failed: %v", err) + } + resolvedPayload, err := createResolvedProvisionerPayload(body, s.resolvedCreateNamePrefix(body, requestFingerprintBody.NamePrefix)) if err != nil { - t.Fatalf("createFingerprint failed: %v", err) + t.Fatalf("createResolvedProvisionerPayload failed: %v", err) } conflictingName := "openclaw-blocked-name" @@ -1038,6 +1632,7 @@ func TestCreateSpritzRetriesPendingIdempotencyReservationWithConflictingOccupant idempotencyReservationHashKey: fingerprint, idempotencyReservationNameKey: conflictingName, idempotencyReservationDoneKey: "false", + idempotencyReservationBodyKey: resolvedPayload, }, }); err != nil { t.Fatalf("failed to seed reservation: %v", err) @@ -1105,15 +1700,20 @@ func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T t.Fatalf("normalizeCreateOwner failed: %v", err) } body.Spec.Owner = owner + requestFingerprintBody := body if _, err := s.applyCreatePreset(&body); err != nil { t.Fatalf("applyCreatePreset failed: %v", err) } if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { t.Fatalf("resolveCreateLifetimes failed: %v", err) } - fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) + fingerprint, err := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) if err != nil { - t.Fatalf("createFingerprint failed: %v", err) + t.Fatalf("createRequestFingerprint failed: %v", err) + } + resolvedPayload, err := createResolvedProvisionerPayload(body, s.resolvedCreateNamePrefix(body, requestFingerprintBody.NamePrefix)) + if err != nil { + t.Fatalf("createResolvedProvisionerPayload failed: %v", err) } if err := s.client.Create(context.Background(), &corev1.ConfigMap{ @@ -1125,6 +1725,7 @@ func TestCreateSpritzReplaysPendingIdempotentCreateBeforeQuotaCheck(t *testing.T idempotencyReservationHashKey: fingerprint, idempotencyReservationNameKey: "openclaw-fixed", idempotencyReservationDoneKey: "false", + idempotencyReservationBodyKey: resolvedPayload, }, }); err != nil { t.Fatalf("failed to seed reservation: %v", err) @@ -1196,15 +1797,20 @@ func TestSetIdempotencyReservationNameKeepsSinglePendingCandidate(t *testing.T) t.Fatalf("normalizeCreateOwner failed: %v", err) } body.Spec.Owner = owner + requestFingerprintBody := body if _, err := s.applyCreatePreset(&body); err != nil { t.Fatalf("applyCreatePreset failed: %v", err) } if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { t.Fatalf("resolveCreateLifetimes failed: %v", err) } - fingerprint, err := createFingerprint(body.Spec.Owner.ID, body.PresetID, "", "openclaw", s.namespace, provisionerSource(&body), body.Spec, nil) + fingerprint, err := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) if err != nil { - t.Fatalf("createFingerprint failed: %v", err) + t.Fatalf("createRequestFingerprint failed: %v", err) + } + state := provisionerIdempotencyState{ + canonicalFingerprint: fingerprint, + resolvedPayload: "payload", } if err := s.client.Create(context.Background(), &corev1.ConfigMap{ @@ -1216,6 +1822,7 @@ func TestSetIdempotencyReservationNameKeepsSinglePendingCandidate(t *testing.T) idempotencyReservationHashKey: fingerprint, idempotencyReservationNameKey: "openclaw-blocked", idempotencyReservationDoneKey: "false", + idempotencyReservationBodyKey: "payload", }, }); err != nil { t.Fatalf("failed to seed reservation: %v", err) @@ -1233,7 +1840,7 @@ func TestSetIdempotencyReservationNameKeepsSinglePendingCandidate(t *testing.T) t.Fatalf("failed to seed conflicting spritz: %v", err) } - firstName, done, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, fingerprint, "openclaw-blocked", "openclaw-alpha") + firstName, done, _, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, "openclaw-blocked", "openclaw-alpha", state) if err != nil { t.Fatalf("first reservation update failed: %v", err) } @@ -1244,7 +1851,7 @@ func TestSetIdempotencyReservationNameKeepsSinglePendingCandidate(t *testing.T) t.Fatalf("expected first replacement name %q, got %q", "openclaw-alpha", firstName) } - secondName, done, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, fingerprint, "openclaw-blocked", "openclaw-beta") + secondName, done, _, err := s.setIdempotencyReservationName(context.Background(), "zenobot", body.IdempotencyKey, "openclaw-blocked", "openclaw-beta", state) if err != nil { t.Fatalf("second reservation update failed: %v", err) } diff --git a/api/provisioning.go b/api/provisioning.go index 6a4c8d0..3f3030e 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -40,6 +40,7 @@ const ( idempotencyReservationHashKey = "fingerprint" idempotencyReservationNameKey = "spritzName" idempotencyReservationDoneKey = "completed" + idempotencyReservationBodyKey = "payload" defaultProvisionerSource = "external" defaultProvisionerIdleTTL = 24 * time.Hour defaultProvisionerMaxTTL = 7 * 24 * time.Hour @@ -116,6 +117,19 @@ type suggestNameMetadata struct { image string } +type idempotentCreatePayload struct { + PresetID string `json:"presetId,omitempty"` + NamePrefix string `json:"namePrefix,omitempty"` + Source string `json:"source,omitempty"` + RequestID string `json:"requestId,omitempty"` + Spec spritzv1.SpritzSpec `json:"spec"` +} + +type provisionerIdempotencyState struct { + canonicalFingerprint string + resolvedPayload string +} + func (s *server) applyCreatePreset(body *createRequest) (*runtimePreset, error) { body.PresetID = sanitizeSpritzNameToken(body.PresetID) if body.PresetID == "" { @@ -301,49 +315,65 @@ func (s *server) validateProvisionerPlacement(principal principal, namespace, pr return nil } -func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, userConfig json.RawMessage, requestedImage, requestedRepo, requestedNamespace bool, nameForFingerprint, namePrefixForFingerprint string) (string, error) { +func (s *server) validateProvisionerCreate(ctx context.Context, principal principal, namespace string, body *createRequest, requestedImage, requestedRepo, requestedNamespace bool) error { if err := s.validateProvisionerPlacement(principal, namespace, body.PresetID, requestedImage, requestedNamespace, scopeInstancesCreate); err != nil { - return "", err + return err } if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { - return "", err + return err } if requestedRepo && !s.provisioners.allowCustomRepo { - return "", fmt.Errorf("custom repo is not allowed") + return fmt.Errorf("custom repo is not allowed") } if body.IdempotencyKey == "" { - return "", fmt.Errorf("idempotencyKey is required") + return fmt.Errorf("idempotencyKey is required") } - if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { - return "", err - } - return createFingerprint(body.Spec.Owner.ID, body.PresetID, nameForFingerprint, namePrefixForFingerprint, namespace, provisionerSource(body), body.Spec, userConfig) + return nil } func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, principal principal, ownerID string) error { - list := &spritzv1.SpritzList{} - if err := s.client.List(ctx, list, client.InNamespace(namespace)); err != nil { - return err + if s.provisioners.maxActivePerOwner <= 0 && s.provisioners.maxCreatesPerActor <= 0 && s.provisioners.maxCreatesPerOwner <= 0 { + return nil + } + if s.provisioners.allowNamespaceOverride && len(s.provisioners.allowedNamespaces) == 0 && + (s.provisioners.maxActivePerOwner > 0 || s.provisioners.maxCreatesPerActor > 0 || s.provisioners.maxCreatesPerOwner > 0) { + return fmt.Errorf("quota enforcement requires allowed namespaces when namespace override is enabled") + } + namespaces := []string{namespace} + if fixedNamespace := strings.TrimSpace(s.namespace); fixedNamespace != "" { + namespaces = []string{fixedNamespace} + } else if s.provisioners.allowNamespaceOverride && len(s.provisioners.allowedNamespaces) > 0 { + namespaces = namespaces[:0] + for allowedNamespace := range s.provisioners.allowedNamespaces { + namespaces = append(namespaces, allowedNamespace) + } + sort.Strings(namespaces) } activeForOwner := 0 actorCreates := 0 ownerCreates := 0 cutoff := time.Now().Add(-s.provisioners.rateWindow) - for _, item := range list.Items { - if item.DeletionTimestamp != nil { - continue - } - if item.Spec.Owner.ID == ownerID && item.Status.Phase != "Expired" { - activeForOwner++ - } - if s.provisioners.rateWindow > 0 && item.CreationTimestamp.Time.Before(cutoff) { - continue - } - if item.Annotations[actorIDAnnotationKey] == principal.ID { - actorCreates++ + for _, listNamespace := range namespaces { + list := &spritzv1.SpritzList{} + if err := s.client.List(ctx, list, client.InNamespace(listNamespace)); err != nil { + return err } - if item.Spec.Owner.ID == ownerID { - ownerCreates++ + for _, item := range list.Items { + if item.DeletionTimestamp != nil { + continue + } + if item.Spec.Owner.ID == ownerID && item.Status.Phase != "Expired" { + activeForOwner++ + } + if s.provisioners.rateWindow > 0 && item.CreationTimestamp.Time.Before(cutoff) { + continue + } + if item.Annotations[actorIDAnnotationKey] == principal.ID { + actorCreates++ + } + if item.Spec.Owner.ID == ownerID { + ownerCreates++ + } } } if s.provisioners.maxActivePerOwner > 0 && activeForOwner >= s.provisioners.maxActivePerOwner { @@ -358,6 +388,45 @@ func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, return nil } +func createRequestFingerprint(body createRequest, namespace, name, namePrefix string, userConfig json.RawMessage) (string, error) { + return createFingerprint( + body.Spec.Owner.ID, + sanitizeSpritzNameToken(body.PresetID), + strings.TrimSpace(name), + sanitizeSpritzNameToken(namePrefix), + namespace, + provisionerSource(&body), + body.Spec, + userConfig, + ) +} + +func createResolvedProvisionerPayload(body createRequest, resolvedNamePrefix string) (string, error) { + payload := idempotentCreatePayload{ + PresetID: sanitizeSpritzNameToken(body.PresetID), + NamePrefix: sanitizeSpritzNameToken(resolvedNamePrefix), + Source: provisionerSource(&body), + RequestID: strings.TrimSpace(body.RequestID), + Spec: body.Spec, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func decodeResolvedProvisionerPayload(raw string) (idempotentCreatePayload, error) { + payload := idempotentCreatePayload{} + if strings.TrimSpace(raw) == "" { + return payload, nil + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return idempotentCreatePayload{}, err + } + return payload, nil +} + func newPresetCatalog() (presetCatalog, error) { raw := strings.TrimSpace(envOrDefault("SPRITZ_PRESETS", "")) if raw == "" { @@ -468,6 +537,67 @@ func (c presetCatalog) get(id string) (*runtimePreset, bool) { return nil, false } +func (s *server) allowedProvisionerPresets() []runtimePreset { + items := s.presets.all() + if len(items) == 0 || len(s.provisioners.allowedPresetIDs) == 0 { + return items + } + filtered := make([]runtimePreset, 0, len(items)) + for _, item := range items { + if _, ok := s.provisioners.allowedPresetIDs[item.ID]; ok { + filtered = append(filtered, item) + } + } + return filtered +} + +func (s *server) resolvedCreateNamePrefix(body createRequest, explicitNamePrefix string) string { + if prefix := sanitizeSpritzNameToken(explicitNamePrefix); prefix != "" { + return prefix + } + if preset, ok := s.presets.get(body.PresetID); ok { + return resolvePresetNamePrefix("", *preset) + } + return resolveSpritzNamePrefix("", body.Spec.Image) +} + +func (s *server) resolvedCreateFingerprint(body createRequest, namespace, explicitNamePrefix string, userConfig json.RawMessage) (string, error) { + namePrefix := "" + if strings.TrimSpace(body.Name) == "" { + namePrefix = s.resolvedCreateNamePrefix(body, explicitNamePrefix) + } + return createFingerprint( + body.Spec.Owner.ID, + sanitizeSpritzNameToken(body.PresetID), + strings.TrimSpace(body.Name), + sanitizeSpritzNameToken(namePrefix), + namespace, + provisionerSource(&body), + body.Spec, + userConfig, + ) +} + +func (s *server) provisionerIdempotencyFingerprints(requestBody, resolvedBody createRequest, namespace string, userConfig json.RawMessage) (provisionerIdempotencyState, error) { + canonicalName := strings.TrimSpace(requestBody.Name) + canonicalNamePrefix := "" + if canonicalName == "" { + canonicalNamePrefix = strings.TrimSpace(requestBody.NamePrefix) + } + canonicalFingerprint, err := createRequestFingerprint(requestBody, namespace, canonicalName, canonicalNamePrefix, userConfig) + if err != nil { + return provisionerIdempotencyState{}, err + } + resolvedPayload, err := createResolvedProvisionerPayload(resolvedBody, s.resolvedCreateNamePrefix(resolvedBody, requestBody.NamePrefix)) + if err != nil { + return provisionerIdempotencyState{}, err + } + return provisionerIdempotencyState{ + canonicalFingerprint: canonicalFingerprint, + resolvedPayload: resolvedPayload, + }, nil +} + func newProvisionerPolicy() provisionerPolicy { defaultIdle := parseDurationEnv("SPRITZ_PROVISIONER_DEFAULT_IDLE_TTL", defaultProvisionerIdleTTL) maxIdle := parseDurationEnv("SPRITZ_PROVISIONER_MAX_IDLE_TTL", defaultIdle) @@ -632,9 +762,30 @@ func (s *server) idempotencyReservationNamespace() string { return "default" } -func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace string, principal principal, key, fingerprint, desiredName string) (string, bool, error) { +func (s *server) getIdempotencyReservation(ctx context.Context, actorID, key, fingerprint string) (string, bool, string, bool, error) { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { + return "", false, "", false, nil + } + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(s.idempotencyReservationNamespace(), idempotencyReservationName(actorID, key)), current); err != nil { + if apierrors.IsNotFound(err) { + return "", false, "", false, nil + } + return "", false, "", false, err + } + if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(fingerprint) { + return "", false, "", false, fmt.Errorf("idempotencyKey already used with a different request") + } + return strings.TrimSpace(current.Data[idempotencyReservationNameKey]), + strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true"), + strings.TrimSpace(current.Data[idempotencyReservationBodyKey]), + true, + nil +} + +func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace string, principal principal, key, desiredName string, state provisionerIdempotencyState) (string, bool, string, error) { if strings.TrimSpace(key) == "" { - return desiredName, false, nil + return desiredName, false, strings.TrimSpace(state.resolvedPayload), nil } reservationName := idempotencyReservationName(principal.ID, key) reservationNamespace := s.idempotencyReservationNamespace() @@ -648,43 +799,48 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri }, }, Data: map[string]string{ - idempotencyReservationHashKey: fingerprint, + idempotencyReservationHashKey: state.canonicalFingerprint, idempotencyReservationNameKey: desiredName, idempotencyReservationDoneKey: "false", + idempotencyReservationBodyKey: strings.TrimSpace(state.resolvedPayload), }, } if err := s.client.Create(ctx, record); err != nil { if !apierrors.IsAlreadyExists(err) { - return "", false, err + return "", false, "", err } existing := &corev1.ConfigMap{} if getErr := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), existing); getErr != nil { - return "", false, getErr + return "", false, "", getErr } - if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != fingerprint { - return "", false, fmt.Errorf("idempotencyKey already used with a different request") + if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { + return "", false, "", fmt.Errorf("idempotencyKey already used with a different request") } done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") name := strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) + storedPayload := strings.TrimSpace(existing.Data[idempotencyReservationBodyKey]) + if storedPayload == "" { + return "", false, "", fmt.Errorf("idempotencyKey already used by an incompatible pending request") + } if done { if name == "" { name = desiredName } - return name, true, nil + return name, true, storedPayload, nil } if name == "" { - return s.setIdempotencyReservationName(ctx, principal.ID, key, fingerprint, "", desiredName) + return s.setIdempotencyReservationName(ctx, principal.ID, key, "", desiredName, state) } reservedSpritz, getErr := s.findReservedSpritz(ctx, namespace, name) if getErr != nil { - return "", false, getErr + return "", false, "", getErr } - if reservedSpritz != nil && !matchesIdempotentReplayTarget(reservedSpritz, principal, key, fingerprint) { - return s.setIdempotencyReservationName(ctx, principal.ID, key, fingerprint, name, desiredName) + if reservedSpritz != nil && !matchesIdempotentReplayTarget(reservedSpritz, principal, key, state.canonicalFingerprint) { + return s.setIdempotencyReservationName(ctx, principal.ID, key, name, desiredName, state) } - return name, false, nil + return name, false, storedPayload, nil } - return desiredName, false, nil + return desiredName, false, strings.TrimSpace(state.resolvedPayload), nil } func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, key string, spritz *spritzv1.Spritz) error { @@ -708,43 +864,51 @@ func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, ke return s.client.Update(ctx, current) } -func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, fingerprint, failedName, proposedName string) (string, bool, error) { +func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, failedName, proposedName string, state provisionerIdempotencyState) (string, bool, string, error) { failedName = strings.TrimSpace(failedName) proposedName = strings.TrimSpace(proposedName) if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { - return proposedName, false, nil + return proposedName, false, strings.TrimSpace(state.resolvedPayload), nil } reservationName := idempotencyReservationName(actorID, key) reservationNamespace := s.idempotencyReservationNamespace() selectedName := proposedName completed := false + selectedPayload := strings.TrimSpace(state.resolvedPayload) err := retry.RetryOnConflict(retry.DefaultRetry, func() error { current := &corev1.ConfigMap{} if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { if apierrors.IsNotFound(err) { selectedName = proposedName completed = false + selectedPayload = strings.TrimSpace(state.resolvedPayload) return nil } return err } - if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(fingerprint) { + if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { return fmt.Errorf("idempotencyKey already used with a different request") } storedName := strings.TrimSpace(current.Data[idempotencyReservationNameKey]) done := strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") + storedPayload := strings.TrimSpace(current.Data[idempotencyReservationBodyKey]) + if storedPayload == "" { + return fmt.Errorf("idempotencyKey already used by an incompatible pending request") + } if done { if storedName == "" { storedName = proposedName } selectedName = storedName completed = true + selectedPayload = storedPayload return nil } if storedName == "" { if proposedName == "" { selectedName = "" completed = false + selectedPayload = storedPayload return nil } if current.Data == nil { @@ -757,11 +921,13 @@ func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key } selectedName = proposedName completed = false + selectedPayload = storedPayload return nil } if failedName == "" || storedName != failedName || proposedName == "" || proposedName == storedName { selectedName = storedName completed = false + selectedPayload = storedPayload return nil } if current.Data == nil { @@ -774,12 +940,16 @@ func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key } selectedName = proposedName completed = false + selectedPayload = storedPayload return nil }) if err != nil { - return "", false, err + return "", false, "", err } - return selectedName, completed, nil + if strings.TrimSpace(selectedPayload) == "" { + selectedPayload = strings.TrimSpace(state.resolvedPayload) + } + return selectedName, completed, selectedPayload, nil } func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) (*spritzv1.Spritz, error) { @@ -814,6 +984,18 @@ func matchesIdempotentReplayTarget(spritz *spritzv1.Spritz, principal principal, } func summarizeCreateResponse(spritz *spritzv1.Spritz, principal principal, presetID, source, idempotencyKey string, replayed bool) createSpritzResponse { + annotations := spritz.GetAnnotations() + if principal.isService() { + if storedPresetID := strings.TrimSpace(annotations[presetIDAnnotationKey]); storedPresetID != "" { + presetID = storedPresetID + } + if storedSource := strings.TrimSpace(annotations[sourceAnnotationKey]); storedSource != "" { + source = storedSource + } + if storedIdempotencyKey := strings.TrimSpace(annotations[idempotencyKeyAnnotationKey]); storedIdempotencyKey != "" { + idempotencyKey = storedIdempotencyKey + } + } createdAt := spritz.CreationTimestamp.DeepCopy() idleExpiresAt, maxExpiresAt, expiresAt := lifecycleExpiryTimes(spritz, time.Now()) return createSpritzResponse{ @@ -844,6 +1026,16 @@ func lifecycleExpiryTimes(spritz *spritzv1.Spritz, _ time.Time) (*metav1.Time, * return idleExpiresAt, maxExpiresAt, effectiveExpiresAt } +func resolvePresetNamePrefix(explicit string, preset runtimePreset) string { + if prefix := sanitizeSpritzNameToken(explicit); prefix != "" { + return prefix + } + if prefix := sanitizeSpritzNameToken(preset.NamePrefix); prefix != "" { + return prefix + } + return deriveSpritzNamePrefixFromImage(preset.Image) +} + func (s *server) resolveSuggestNameMetadata(body suggestNameRequest) (suggestNameMetadata, error) { metadata := suggestNameMetadata{ presetID: sanitizeSpritzNameToken(body.PresetID), @@ -854,10 +1046,7 @@ func (s *server) resolveSuggestNameMetadata(body suggestNameRequest) (suggestNam return suggestNameMetadata{}, fmt.Errorf("preset not found: %s", metadata.presetID) } metadata.image = preset.Image - metadata.namePrefix = resolveSpritzNamePrefix(body.NamePrefix, preset.NamePrefix) - if metadata.namePrefix == "" { - metadata.namePrefix = resolveSpritzNamePrefix("", preset.Image) - } + metadata.namePrefix = resolvePresetNamePrefix(body.NamePrefix, *preset) return metadata, nil } metadata.image = strings.TrimSpace(body.Image) From d516b93c25b7bd3d030a55956a1d737e2b2b74b2 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 21:47:32 +0100 Subject: [PATCH 20/21] refactor(provisioning): extract create transaction flow --- api/main.go | 167 ++++----------------- api/provisioning.go | 17 ++- api/provisioning_transaction.go | 249 ++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 145 deletions(-) create mode 100644 api/provisioning_transaction.go diff --git a/api/main.go b/api/main.go index 4e3a88e..a8d06f6 100644 --- a/api/main.go +++ b/api/main.go @@ -458,141 +458,40 @@ func (s *server) createSpritz(c echo.Context) error { } provisionerFingerprint := "" - idempotencyState := provisionerIdempotencyState{} - resolvedFromReservation := false - completed := false + var provisionerTx *provisionerCreateTransaction if principal.isService() { - canonicalName := strings.TrimSpace(provisionerFingerprintBody.Name) - canonicalNamePrefix := "" - if canonicalName == "" { - canonicalNamePrefix = strings.TrimSpace(provisionerFingerprintBody.NamePrefix) - } - provisionerFingerprint, err = createRequestFingerprint(provisionerFingerprintBody, namespace, canonicalName, canonicalNamePrefix, normalizedUserConfig) - if err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) - } - if err := authorizeServiceAction(principal, scopeInstancesCreate, true); err != nil { - if errors.Is(err, errForbidden) { - return writeError(c, http.StatusForbidden, "forbidden") - } - return writeError(c, http.StatusForbidden, "forbidden") - } - if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { - if errors.Is(err, errForbidden) { - return writeError(c, http.StatusForbidden, "forbidden") - } - return writeError(c, http.StatusBadRequest, err.Error()) - } - if body.IdempotencyKey == "" { - return writeError(c, http.StatusBadRequest, "idempotencyKey is required") - } - var reservationName string - var storedPayload string - var found bool - reservationName, completed, storedPayload, found, err = s.getIdempotencyReservation(c.Request().Context(), principal.ID, body.IdempotencyKey, provisionerFingerprint) - if err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - restoreStoredPayload := func(raw string) error { - if strings.TrimSpace(raw) == "" { - return fmt.Errorf("idempotencyKey already used by an incompatible pending request") - } - payload, err := decodeResolvedProvisionerPayload(raw) - if err != nil { - return err - } - body.PresetID = payload.PresetID - body.NamePrefix = payload.NamePrefix - body.Source = payload.Source - body.RequestID = payload.RequestID - body.Spec = payload.Spec - owner = body.Spec.Owner - return nil - } - if found { - if err := restoreStoredPayload(storedPayload); err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - resolvedFromReservation = true - idempotencyState = provisionerIdempotencyState{ - canonicalFingerprint: provisionerFingerprint, - resolvedPayload: strings.TrimSpace(storedPayload), - } - if strings.TrimSpace(reservationName) != "" { - body.Name = reservationName - } - } else { - if err := s.validateProvisionerCreate(c.Request().Context(), principal, namespace, &body, requestedImage, requestedRepo, requestedNamespace); err != nil { - if errors.Is(err, errForbidden) { - return writeError(c, http.StatusForbidden, "forbidden") - } - return writeError(c, http.StatusBadRequest, err.Error()) - } - if err := resolveCreateLifetimes(&body.Spec, s.provisioners, true); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - idempotencyState, err = s.provisionerIdempotencyFingerprints(provisionerFingerprintBody, body, namespace, normalizedUserConfig) - if err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) - } - var reservedName string - reservedName, completed, storedPayload, err = s.reserveIdempotentCreateName(c.Request().Context(), namespace, principal, body.IdempotencyKey, body.Name, idempotencyState) - if err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - if strings.TrimSpace(storedPayload) != "" { - if err := restoreStoredPayload(storedPayload); err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - } - body.Name = reservedName - } + provisionerTx = newProvisionerCreateTransaction( + s, + c.Request().Context(), + principal, + namespace, + &body, + provisionerFingerprintBody, + normalizedUserConfig, + requestedImage, + requestedRepo, + requestedNamespace, + ) + if err := provisionerTx.prepare(); err != nil { + return writeProvisionerCreateError(c, err) + } + owner = body.Spec.Owner + provisionerFingerprint = provisionerTx.provisionerFingerprint if !nameProvided { if err := buildNameGenerator(body); err != nil { return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } } - existing, err := s.findReservedSpritz(c.Request().Context(), namespace, body.Name) + existing, err := provisionerTx.replayExisting() if err != nil { - return writeError(c, http.StatusInternalServerError, err.Error()) + return writeProvisionerCreateError(c, err) } if existing != nil { - if !matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, provisionerFingerprint) { - if completed { - return writeError(c, http.StatusConflict, "idempotencyKey already used with a different request") - } - } else { - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) - } + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } - if completed { - return writeError(c, http.StatusConflict, "idempotencyKey already used") + if err := provisionerTx.finalizeCreate(); err != nil { + return writeProvisionerCreateError(c, err) } - if !resolvedFromReservation { - if err := s.enforceProvisionerQuotas(c.Request().Context(), namespace, principal, body.Spec.Owner.ID); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - } - body.Annotations = mergeStringMap(body.Annotations, map[string]string{ - actorIDAnnotationKey: principal.ID, - actorTypeAnnotationKey: string(principal.Type), - sourceAnnotationKey: provisionerSource(&body), - requestIDAnnotationKey: body.RequestID, - idempotencyKeyAnnotationKey: body.IdempotencyKey, - idempotencyHashAnnotationKey: provisionerFingerprint, - }) } else if s.auth.enabled() && !principal.isAdminPrincipal() && owner.ID != principal.ID { return writeError(c, http.StatusForbidden, "owner mismatch") } @@ -683,25 +582,13 @@ func (s *server) createSpritz(c echo.Context) error { } } if principal.isService() { - reservedName, completed, storedPayload, err := s.setIdempotencyReservationName(c.Request().Context(), principal.ID, body.IdempotencyKey, failedName, name, idempotencyState) + reservedName, replayed, err := provisionerTx.reserveAttemptName(failedName, name) if err != nil { - if strings.Contains(err.Error(), "idempotencyKey already used") { - return writeError(c, http.StatusConflict, err.Error()) - } - return writeError(c, http.StatusInternalServerError, err.Error()) - } - if strings.TrimSpace(storedPayload) != "" { + return writeProvisionerCreateError(c, err) } name = reservedName - if completed { - existing, getErr := s.findReservedSpritz(c.Request().Context(), namespace, name) - if getErr != nil { - return writeError(c, http.StatusInternalServerError, getErr.Error()) - } - if matchesIdempotentReplayTarget(existing, principal, body.IdempotencyKey, provisionerFingerprint) { - return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) - } - return writeError(c, http.StatusConflict, "idempotencyKey already used") + if replayed != nil { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(replayed, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } } spritz, err := createSpritzResource(name) diff --git a/api/provisioning.go b/api/provisioning.go index 3f3030e..13534b0 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "os" "reflect" @@ -46,6 +47,12 @@ const ( defaultProvisionerMaxTTL = 7 * 24 * time.Hour ) +var ( + errIdempotencyUsed = errors.New("idempotencyKey already used") + errIdempotencyUsedDifferent = errors.New("idempotencyKey already used with a different request") + errIdempotencyIncompatiblePending = errors.New("idempotencyKey already used by an incompatible pending request") +) + type runtimePreset struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -774,7 +781,7 @@ func (s *server) getIdempotencyReservation(ctx context.Context, actorID, key, fi return "", false, "", false, err } if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(fingerprint) { - return "", false, "", false, fmt.Errorf("idempotencyKey already used with a different request") + return "", false, "", false, errIdempotencyUsedDifferent } return strings.TrimSpace(current.Data[idempotencyReservationNameKey]), strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true"), @@ -814,13 +821,13 @@ func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace stri return "", false, "", getErr } if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { - return "", false, "", fmt.Errorf("idempotencyKey already used with a different request") + return "", false, "", errIdempotencyUsedDifferent } done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") name := strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) storedPayload := strings.TrimSpace(existing.Data[idempotencyReservationBodyKey]) if storedPayload == "" { - return "", false, "", fmt.Errorf("idempotencyKey already used by an incompatible pending request") + return "", false, "", errIdempotencyIncompatiblePending } if done { if name == "" { @@ -887,13 +894,13 @@ func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key return err } if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { - return fmt.Errorf("idempotencyKey already used with a different request") + return errIdempotencyUsedDifferent } storedName := strings.TrimSpace(current.Data[idempotencyReservationNameKey]) done := strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") storedPayload := strings.TrimSpace(current.Data[idempotencyReservationBodyKey]) if storedPayload == "" { - return fmt.Errorf("idempotencyKey already used by an incompatible pending request") + return errIdempotencyIncompatiblePending } if done { if storedName == "" { diff --git a/api/provisioning_transaction.go b/api/provisioning_transaction.go new file mode 100644 index 0000000..e2006e7 --- /dev/null +++ b/api/provisioning_transaction.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + spritzv1 "spritz.sh/operator/api/v1" +) + +type provisionerCreateError struct { + status int + message string + err error +} + +func (e *provisionerCreateError) Error() string { + return e.message +} + +func (e *provisionerCreateError) Unwrap() error { + return e.err +} + +func newProvisionerCreateError(status int, err error) error { + return &provisionerCreateError{ + status: status, + message: err.Error(), + err: err, + } +} + +func newProvisionerForbiddenError() error { + return &provisionerCreateError{ + status: http.StatusForbidden, + message: "forbidden", + err: errForbidden, + } +} + +func writeProvisionerCreateError(c echo.Context, err error) error { + var provisionerErr *provisionerCreateError + if errors.As(err, &provisionerErr) { + return writeError(c, provisionerErr.status, provisionerErr.message) + } + return writeError(c, http.StatusInternalServerError, err.Error()) +} + +// provisionerCreateTransaction owns the service-principal create flow from +// canonical request normalization through idempotent replay/resume decisions. +type provisionerCreateTransaction struct { + server *server + ctx context.Context + principal principal + namespace string + requestedImage bool + requestedRepo bool + requestedNamespace bool + normalizedUserConfig json.RawMessage + fingerprintRequest createRequest + body *createRequest + provisionerFingerprint string + idempotencyState provisionerIdempotencyState + resolvedFromReservation bool + completed bool +} + +func newProvisionerCreateTransaction( + server *server, + ctx context.Context, + principal principal, + namespace string, + body *createRequest, + fingerprintRequest createRequest, + normalizedUserConfig json.RawMessage, + requestedImage, requestedRepo, requestedNamespace bool, +) *provisionerCreateTransaction { + return &provisionerCreateTransaction{ + server: server, + ctx: ctx, + principal: principal, + namespace: namespace, + body: body, + fingerprintRequest: fingerprintRequest, + normalizedUserConfig: normalizedUserConfig, + requestedImage: requestedImage, + requestedRepo: requestedRepo, + requestedNamespace: requestedNamespace, + } +} + +// prepare resolves the canonical provisioning request and applies idempotent +// replay/resume state before any create attempt happens. +func (tx *provisionerCreateTransaction) prepare() error { + if err := authorizeServiceAction(tx.principal, scopeInstancesCreate, true); err != nil { + return newProvisionerForbiddenError() + } + if err := authorizeServiceAction(tx.principal, scopeInstancesAssignOwner, true); err != nil { + return newProvisionerForbiddenError() + } + if strings.TrimSpace(tx.body.IdempotencyKey) == "" { + return newProvisionerCreateError(http.StatusBadRequest, errors.New("idempotencyKey is required")) + } + canonicalName := strings.TrimSpace(tx.fingerprintRequest.Name) + canonicalNamePrefix := "" + if canonicalName == "" { + canonicalNamePrefix = strings.TrimSpace(tx.fingerprintRequest.NamePrefix) + } + fingerprint, err := createRequestFingerprint(tx.fingerprintRequest, tx.namespace, canonicalName, canonicalNamePrefix, tx.normalizedUserConfig) + if err != nil { + return err + } + tx.provisionerFingerprint = fingerprint + + reservationName, completed, storedPayload, found, err := tx.server.getIdempotencyReservation(tx.ctx, tx.principal.ID, tx.body.IdempotencyKey, tx.provisionerFingerprint) + if err != nil { + if isProvisionerConflict(err) { + return newProvisionerCreateError(http.StatusConflict, err) + } + return newProvisionerCreateError(http.StatusInternalServerError, err) + } + tx.completed = completed + if found { + if err := tx.restoreStoredPayload(storedPayload); err != nil { + return err + } + tx.resolvedFromReservation = true + tx.idempotencyState = provisionerIdempotencyState{ + canonicalFingerprint: tx.provisionerFingerprint, + resolvedPayload: strings.TrimSpace(storedPayload), + } + if strings.TrimSpace(reservationName) != "" { + tx.body.Name = reservationName + } + return nil + } + + if err := tx.server.validateProvisionerCreate(tx.ctx, tx.principal, tx.namespace, tx.body, tx.requestedImage, tx.requestedRepo, tx.requestedNamespace); err != nil { + if errors.Is(err, errForbidden) { + return newProvisionerForbiddenError() + } + return newProvisionerCreateError(http.StatusBadRequest, err) + } + if err := resolveCreateLifetimes(&tx.body.Spec, tx.server.provisioners, true); err != nil { + return newProvisionerCreateError(http.StatusBadRequest, err) + } + tx.idempotencyState, err = tx.server.provisionerIdempotencyFingerprints(tx.fingerprintRequest, *tx.body, tx.namespace, tx.normalizedUserConfig) + if err != nil { + return newProvisionerCreateError(http.StatusInternalServerError, err) + } + reservedName, completed, storedPayload, err := tx.server.reserveIdempotentCreateName(tx.ctx, tx.namespace, tx.principal, tx.body.IdempotencyKey, tx.body.Name, tx.idempotencyState) + if err != nil { + if isProvisionerConflict(err) { + return newProvisionerCreateError(http.StatusConflict, err) + } + return newProvisionerCreateError(http.StatusInternalServerError, err) + } + tx.completed = completed + if strings.TrimSpace(storedPayload) != "" { + if err := tx.restoreStoredPayload(storedPayload); err != nil { + return err + } + } + tx.body.Name = reservedName + return nil +} + +func (tx *provisionerCreateTransaction) restoreStoredPayload(raw string) error { + if strings.TrimSpace(raw) == "" { + return newProvisionerCreateError(http.StatusConflict, errIdempotencyIncompatiblePending) + } + payload, err := decodeResolvedProvisionerPayload(raw) + if err != nil { + return newProvisionerCreateError(http.StatusInternalServerError, err) + } + tx.body.PresetID = payload.PresetID + tx.body.NamePrefix = payload.NamePrefix + tx.body.Source = payload.Source + tx.body.RequestID = payload.RequestID + tx.body.Spec = payload.Spec + return nil +} + +func (tx *provisionerCreateTransaction) replayExisting() (*spritzv1.Spritz, error) { + existing, err := tx.server.findReservedSpritz(tx.ctx, tx.namespace, tx.body.Name) + if err != nil { + return nil, newProvisionerCreateError(http.StatusInternalServerError, err) + } + if existing == nil { + return nil, nil + } + if !matchesIdempotentReplayTarget(existing, tx.principal, tx.body.IdempotencyKey, tx.provisionerFingerprint) { + if tx.completed { + return nil, newProvisionerCreateError(http.StatusConflict, errIdempotencyUsedDifferent) + } + return nil, nil + } + return existing, nil +} + +func (tx *provisionerCreateTransaction) finalizeCreate() error { + if tx.completed { + return newProvisionerCreateError(http.StatusConflict, errIdempotencyUsed) + } + if !tx.resolvedFromReservation { + if err := tx.server.enforceProvisionerQuotas(tx.ctx, tx.namespace, tx.principal, tx.body.Spec.Owner.ID); err != nil { + return newProvisionerCreateError(http.StatusBadRequest, err) + } + } + tx.body.Annotations = mergeStringMap(tx.body.Annotations, map[string]string{ + actorIDAnnotationKey: tx.principal.ID, + actorTypeAnnotationKey: string(tx.principal.Type), + sourceAnnotationKey: provisionerSource(tx.body), + requestIDAnnotationKey: tx.body.RequestID, + idempotencyKeyAnnotationKey: tx.body.IdempotencyKey, + idempotencyHashAnnotationKey: tx.provisionerFingerprint, + }) + return nil +} + +func (tx *provisionerCreateTransaction) reserveAttemptName(failedName, proposedName string) (string, *spritzv1.Spritz, error) { + reservedName, completed, _, err := tx.server.setIdempotencyReservationName(tx.ctx, tx.principal.ID, tx.body.IdempotencyKey, failedName, proposedName, tx.idempotencyState) + if err != nil { + if isProvisionerConflict(err) { + return "", nil, newProvisionerCreateError(http.StatusConflict, err) + } + return "", nil, newProvisionerCreateError(http.StatusInternalServerError, err) + } + if !completed { + return reservedName, nil, nil + } + existing, err := tx.server.findReservedSpritz(tx.ctx, tx.namespace, reservedName) + if err != nil { + return "", nil, newProvisionerCreateError(http.StatusInternalServerError, err) + } + if matchesIdempotentReplayTarget(existing, tx.principal, tx.body.IdempotencyKey, tx.provisionerFingerprint) { + return reservedName, existing, nil + } + return "", nil, newProvisionerCreateError(http.StatusConflict, errIdempotencyUsed) +} + +func isProvisionerConflict(err error) bool { + return errors.Is(err, errIdempotencyUsed) || + errors.Is(err, errIdempotencyUsedDifferent) || + errors.Is(err, errIdempotencyIncompatiblePending) +} From a435d1895f8f26a94e99660e951cd48500274784 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 11 Mar 2026 22:21:06 +0100 Subject: [PATCH 21/21] refactor(api): extract create normalization and reservation store --- api/create_request_normalization.go | 167 +++++++++++++++++++++ api/idempotency_reservation_store.go | 117 +++++++++++++++ api/main.go | 102 ++----------- api/main_create_owner_test.go | 79 ---------- api/provisioning.go | 158 +++++++------------ api/provisioning_reservation_store_test.go | 69 +++++++++ api/provisioning_test_helpers_test.go | 91 +++++++++++ 7 files changed, 517 insertions(+), 266 deletions(-) create mode 100644 api/create_request_normalization.go create mode 100644 api/idempotency_reservation_store.go create mode 100644 api/provisioning_reservation_store_test.go create mode 100644 api/provisioning_test_helpers_test.go diff --git a/api/create_request_normalization.go b/api/create_request_normalization.go new file mode 100644 index 0000000..3f3bd37 --- /dev/null +++ b/api/create_request_normalization.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + + spritzv1 "spritz.sh/operator/api/v1" +) + +type createRequestError struct { + status int + message string + err error +} + +func (e *createRequestError) Error() string { + return e.message +} + +func (e *createRequestError) Unwrap() error { + return e.err +} + +func newCreateRequestError(status int, err error) error { + return &createRequestError{ + status: status, + message: err.Error(), + err: err, + } +} + +func writeCreateRequestError(c echo.Context, err error) error { + var requestErr *createRequestError + if errors.As(err, &requestErr) { + return writeError(c, requestErr.status, requestErr.message) + } + return writeError(c, http.StatusInternalServerError, err.Error()) +} + +type normalizedCreateRequest struct { + body createRequest + fingerprintRequest createRequest + namespace string + owner spritzv1.SpritzOwner + userConfigKeys map[string]json.RawMessage + userConfigPayload userConfigPayload + normalizedUserConfig json.RawMessage + requestedImage bool + requestedRepo bool + requestedNamespace bool + nameProvided bool + requestedNamePrefix string +} + +func (s *server) normalizeCreateRequest(_ context.Context, principal principal, body createRequest) (*normalizedCreateRequest, error) { + body.Name = strings.TrimSpace(body.Name) + body.NamePrefix = strings.TrimSpace(body.NamePrefix) + applyTopLevelCreateFields(&body) + if principal.isService() { + if err := validateProvisionerRequestSurface(&body); err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + } + + namespace, err := s.resolveSpritzNamespace(body.Namespace) + if err != nil { + return nil, newCreateRequestError(http.StatusForbidden, err) + } + requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) + + owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) + if err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + body.Spec.Owner = owner + fingerprintRequest := body + + requestedImage := strings.TrimSpace(body.Spec.Image) != "" + requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 + + s.applyProvisionerDefaultPreset(&body, principal) + if _, err := s.applyCreatePreset(&body); err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + + userConfigKeys, userConfigPayload, err := parseUserConfig(body.UserConfig) + if err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + var normalizedUserConfig json.RawMessage + if principal.isService() && len(userConfigKeys) > 0 { + return nil, newCreateRequestError(http.StatusBadRequest, errors.New("userConfig is not allowed for service principals")) + } + if len(userConfigKeys) > 0 { + normalized, err := normalizeUserConfig(s.userConfigPolicy, userConfigKeys, userConfigPayload) + if err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + userConfigPayload = normalized + encodedUserConfig, err := json.Marshal(userConfigPayload) + if err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, errors.New("invalid userConfig")) + } + normalizedUserConfig = encodedUserConfig + applyUserConfig(&body.Spec, userConfigKeys, userConfigPayload) + if _, ok := userConfigKeys["image"]; ok { + requestedImage = strings.TrimSpace(body.Spec.Image) != "" + } + if _, ok := userConfigKeys["repo"]; ok { + requestedRepo = body.Spec.Repo != nil || len(body.Spec.Repos) > 0 + } + } + + if err := validateCreateSpec(&body.Spec); err != nil { + return nil, newCreateRequestError(http.StatusBadRequest, err) + } + + return &normalizedCreateRequest{ + body: body, + fingerprintRequest: fingerprintRequest, + namespace: namespace, + owner: owner, + userConfigKeys: userConfigKeys, + userConfigPayload: userConfigPayload, + normalizedUserConfig: normalizedUserConfig, + requestedImage: requestedImage, + requestedRepo: requestedRepo, + requestedNamespace: requestedNamespace, + nameProvided: body.Name != "", + requestedNamePrefix: strings.TrimSpace(fingerprintRequest.NamePrefix), + }, nil +} + +func validateCreateSpec(spec *spritzv1.SpritzSpec) error { + if spec == nil { + return errors.New("spec is required") + } + if spec.Image == "" { + return errors.New("spec.image is required") + } + if spec.Repo != nil && len(spec.Repos) > 0 { + return errors.New("spec.repo cannot be set when spec.repos is provided") + } + if spec.Repo != nil { + if err := validateRepoDir(spec.Repo.Dir); err != nil { + return err + } + } + for _, repo := range spec.Repos { + if err := validateRepoDir(repo.Dir); err != nil { + return err + } + } + if len(spec.SharedMounts) > 0 { + normalized, err := normalizeSharedMounts(spec.SharedMounts) + if err != nil { + return err + } + spec.SharedMounts = normalized + } + return nil +} diff --git a/api/idempotency_reservation_store.go b/api/idempotency_reservation_store.go new file mode 100644 index 0000000..47a80c8 --- /dev/null +++ b/api/idempotency_reservation_store.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type idempotencyReservationRecord struct { + fingerprint string + name string + payload string + completed bool +} + +type idempotencyReservationStore struct { + client client.Client + namespace string +} + +func newIdempotencyReservationStore(client client.Client, namespace string) *idempotencyReservationStore { + return &idempotencyReservationStore{ + client: client, + namespace: namespace, + } +} + +func (s *idempotencyReservationStore) get(ctx context.Context, actorID, key string) (idempotencyReservationRecord, bool, error) { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { + return idempotencyReservationRecord{}, false, nil + } + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(s.namespace, idempotencyReservationName(actorID, key)), current); err != nil { + if apierrors.IsNotFound(err) { + return idempotencyReservationRecord{}, false, nil + } + return idempotencyReservationRecord{}, false, err + } + return reservationRecordFromConfigMap(current), true, nil +} + +func (s *idempotencyReservationStore) create(ctx context.Context, actorID, key string, record idempotencyReservationRecord) error { + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { + return nil + } + return s.client.Create(ctx, reservationConfigMap(s.namespace, actorID, key, record)) +} + +func (s *idempotencyReservationStore) update(ctx context.Context, actorID, key string, mutate func(*idempotencyReservationRecord) error) (idempotencyReservationRecord, error) { + record := idempotencyReservationRecord{} + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &corev1.ConfigMap{} + if err := s.client.Get(ctx, clientKey(s.namespace, idempotencyReservationName(actorID, key)), current); err != nil { + return err + } + updated := reservationRecordFromConfigMap(current) + if err := mutate(&updated); err != nil { + return err + } + writeReservationRecordToConfigMap(current, updated) + if err := s.client.Update(ctx, current); err != nil { + return err + } + record = updated + return nil + }) + if err != nil { + return idempotencyReservationRecord{}, err + } + return record, nil +} + +func reservationConfigMap(namespace, actorID, key string, record idempotencyReservationRecord) *corev1.ConfigMap { + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: idempotencyReservationName(actorID, key), + Namespace: namespace, + Labels: map[string]string{ + actorLabelKey: actorLabelValue(actorID), + idempotencyLabelKey: idempotencyLabelValue(key), + }, + }, + } + writeReservationRecordToConfigMap(configMap, record) + return configMap +} + +func reservationRecordFromConfigMap(configMap *corev1.ConfigMap) idempotencyReservationRecord { + if configMap == nil { + return idempotencyReservationRecord{} + } + return idempotencyReservationRecord{ + fingerprint: strings.TrimSpace(configMap.Data[idempotencyReservationHashKey]), + name: strings.TrimSpace(configMap.Data[idempotencyReservationNameKey]), + payload: strings.TrimSpace(configMap.Data[idempotencyReservationBodyKey]), + completed: strings.EqualFold(strings.TrimSpace(configMap.Data[idempotencyReservationDoneKey]), "true"), + } +} + +func writeReservationRecordToConfigMap(configMap *corev1.ConfigMap, record idempotencyReservationRecord) { + if configMap.Data == nil { + configMap.Data = map[string]string{} + } + configMap.Data[idempotencyReservationHashKey] = strings.TrimSpace(record.fingerprint) + configMap.Data[idempotencyReservationNameKey] = strings.TrimSpace(record.name) + configMap.Data[idempotencyReservationBodyKey] = strings.TrimSpace(record.payload) + if record.completed { + configMap.Data[idempotencyReservationDoneKey] = "true" + } else { + configMap.Data[idempotencyReservationDoneKey] = "false" + } +} diff --git a/api/main.go b/api/main.go index a8d06f6..5715f4f 100644 --- a/api/main.go +++ b/api/main.go @@ -349,90 +349,18 @@ func (s *server) createSpritz(c echo.Context) error { if err := c.Bind(&body); err != nil { return writeError(c, http.StatusBadRequest, "invalid json") } - body.Name = strings.TrimSpace(body.Name) - body.NamePrefix = strings.TrimSpace(body.NamePrefix) - applyTopLevelCreateFields(&body) - if principal.isService() { - if err := validateProvisionerRequestSurface(&body); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - } - - namespace, err := s.resolveSpritzNamespace(body.Namespace) - if err != nil { - return writeError(c, http.StatusForbidden, err.Error()) - } - requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) - - owner, err := normalizeCreateOwner(&body, principal, s.auth.enabled()) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - body.Spec.Owner = owner - provisionerFingerprintBody := body - - requestedImage := strings.TrimSpace(body.Spec.Image) != "" - requestedRepo := body.Spec.Repo != nil || len(body.Spec.Repos) > 0 - s.applyProvisionerDefaultPreset(&body, principal) - if _, err := s.applyCreatePreset(&body); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - - userConfigKeys, userConfigPayload, err := parseUserConfig(body.UserConfig) + normalized, err := s.normalizeCreateRequest(c.Request().Context(), principal, body) if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - if principal.isService() && len(userConfigKeys) > 0 { - return writeError(c, http.StatusBadRequest, "userConfig is not allowed for service principals") - } - var normalizedUserConfig json.RawMessage - if len(userConfigKeys) > 0 { - normalized, err := normalizeUserConfig(s.userConfigPolicy, userConfigKeys, userConfigPayload) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - userConfigPayload = normalized - encodedUserConfig, err := json.Marshal(userConfigPayload) - if err != nil { - return writeError(c, http.StatusBadRequest, "invalid userConfig") - } - normalizedUserConfig = encodedUserConfig - applyUserConfig(&body.Spec, userConfigKeys, userConfigPayload) - if _, ok := userConfigKeys["image"]; ok { - requestedImage = strings.TrimSpace(body.Spec.Image) != "" - } - if _, ok := userConfigKeys["repo"]; ok { - requestedRepo = body.Spec.Repo != nil || len(body.Spec.Repos) > 0 - } - } - - if body.Spec.Image == "" { - return writeError(c, http.StatusBadRequest, "spec.image is required") - } - if body.Spec.Repo != nil && len(body.Spec.Repos) > 0 { - return writeError(c, http.StatusBadRequest, "spec.repo cannot be set when spec.repos is provided") - } - if body.Spec.Repo != nil { - if err := validateRepoDir(body.Spec.Repo.Dir); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - } - for _, repo := range body.Spec.Repos { - if err := validateRepoDir(repo.Dir); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - } - if len(body.Spec.SharedMounts) > 0 { - normalized, err := normalizeSharedMounts(body.Spec.SharedMounts) - if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) - } - body.Spec.SharedMounts = normalized - } - - nameProvided := body.Name != "" + return writeCreateRequestError(c, err) + } + body = normalized.body + namespace := normalized.namespace + owner := normalized.owner + userConfigKeys := normalized.userConfigKeys + userConfigPayload := normalized.userConfigPayload + nameProvided := normalized.nameProvided var nameGenerator func() string - requestedNamePrefix := strings.TrimSpace(provisionerFingerprintBody.NamePrefix) + requestedNamePrefix := normalized.requestedNamePrefix buildNameGenerator := func(resolved createRequest) error { namePrefix := requestedNamePrefix if restoredNamePrefix := strings.TrimSpace(resolved.NamePrefix); restoredNamePrefix != "" { @@ -466,11 +394,11 @@ func (s *server) createSpritz(c echo.Context) error { principal, namespace, &body, - provisionerFingerprintBody, - normalizedUserConfig, - requestedImage, - requestedRepo, - requestedNamespace, + normalized.fingerprintRequest, + normalized.normalizedUserConfig, + normalized.requestedImage, + normalized.requestedRepo, + normalized.requestedNamespace, ) if err := provisionerTx.prepare(); err != nil { return writeProvisionerCreateError(c, err) diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 806ae0d..5fd61f9 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -15,91 +15,12 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" 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" spritzv1 "spritz.sh/operator/api/v1" ) -func newTestSpritzScheme(t *testing.T) *runtime.Scheme { - t.Helper() - scheme := runtime.NewScheme() - if err := spritzv1.AddToScheme(scheme); err != nil { - t.Fatalf("failed to register spritz scheme: %v", err) - } - if err := corev1.AddToScheme(scheme); err != nil { - t.Fatalf("failed to register core scheme: %v", err) - } - return scheme -} - -func newCreateSpritzTestServer(t *testing.T) *server { - t.Helper() - scheme := newTestSpritzScheme(t) - return &server{ - client: fake.NewClientBuilder().WithScheme(scheme).Build(), - scheme: scheme, - namespace: "spritz-test", - controlNamespace: "spritz-test", - auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", - headerEmail: "X-Spritz-User-Email", - headerType: "X-Spritz-Principal-Type", - headerScopes: "X-Spritz-Principal-Scopes", - headerDefaultType: principalTypeHuman, - }, - internalAuth: internalAuthConfig{enabled: false}, - userConfigPolicy: userConfigPolicy{}, - } -} - -type createInterceptClient struct { - client.Client - onCreate func(context.Context, client.Object) error - onUpdate func(context.Context, client.Object) error -} - -func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { - if c.onCreate != nil { - if err := c.onCreate(ctx, obj); err != nil { - return err - } - } - return c.Client.Create(ctx, obj, opts...) -} - -func (c *createInterceptClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { - if c.onUpdate != nil { - if err := c.onUpdate(ctx, obj); err != nil { - return err - } - } - return c.Client.Update(ctx, obj, opts...) -} - -func configureProvisionerTestServer(s *server) { - s.auth.headerTrustTypeAndScopes = true - s.presets = presetCatalog{ - byID: []runtimePreset{{ - ID: "openclaw", - Name: "OpenClaw", - Image: "example.com/spritz-openclaw:latest", - NamePrefix: "openclaw", - }}, - } - s.provisioners = provisionerPolicy{ - allowedPresetIDs: map[string]struct{}{"openclaw": {}}, - defaultIdleTTL: 24 * time.Hour, - maxIdleTTL: 24 * time.Hour, - defaultTTL: 168 * time.Hour, - maxTTL: 168 * time.Hour, - rateWindow: time.Hour, - } -} - func TestCreateSpritzOwnerUsesIDAndOmitsEmail(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() diff --git a/api/provisioning.go b/api/provisioning.go index 13534b0..bdeec60 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -15,7 +15,6 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" spritzv1 "spritz.sh/operator/api/v1" @@ -769,63 +768,52 @@ func (s *server) idempotencyReservationNamespace() string { return "default" } +func (s *server) idempotencyReservations() *idempotencyReservationStore { + return newIdempotencyReservationStore(s.client, s.idempotencyReservationNamespace()) +} + func (s *server) getIdempotencyReservation(ctx context.Context, actorID, key, fingerprint string) (string, bool, string, bool, error) { - if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { - return "", false, "", false, nil - } - current := &corev1.ConfigMap{} - if err := s.client.Get(ctx, clientKey(s.idempotencyReservationNamespace(), idempotencyReservationName(actorID, key)), current); err != nil { - if apierrors.IsNotFound(err) { - return "", false, "", false, nil - } + record, found, err := s.idempotencyReservations().get(ctx, actorID, key) + if err != nil { return "", false, "", false, err } - if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(fingerprint) { + if !found { + return "", false, "", false, nil + } + if strings.TrimSpace(record.fingerprint) != strings.TrimSpace(fingerprint) { return "", false, "", false, errIdempotencyUsedDifferent } - return strings.TrimSpace(current.Data[idempotencyReservationNameKey]), - strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true"), - strings.TrimSpace(current.Data[idempotencyReservationBodyKey]), - true, - nil + return record.name, record.completed, record.payload, true, nil } func (s *server) reserveIdempotentCreateName(ctx context.Context, namespace string, principal principal, key, desiredName string, state provisionerIdempotencyState) (string, bool, string, error) { if strings.TrimSpace(key) == "" { return desiredName, false, strings.TrimSpace(state.resolvedPayload), nil } - reservationName := idempotencyReservationName(principal.ID, key) - reservationNamespace := s.idempotencyReservationNamespace() - record := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: reservationName, - Namespace: reservationNamespace, - Labels: map[string]string{ - actorLabelKey: actorLabelValue(principal.ID), - idempotencyLabelKey: idempotencyLabelValue(key), - }, - }, - Data: map[string]string{ - idempotencyReservationHashKey: state.canonicalFingerprint, - idempotencyReservationNameKey: desiredName, - idempotencyReservationDoneKey: "false", - idempotencyReservationBodyKey: strings.TrimSpace(state.resolvedPayload), - }, - } - if err := s.client.Create(ctx, record); err != nil { + store := s.idempotencyReservations() + record := idempotencyReservationRecord{ + fingerprint: state.canonicalFingerprint, + name: desiredName, + payload: strings.TrimSpace(state.resolvedPayload), + completed: false, + } + if err := store.create(ctx, principal.ID, key, record); err != nil { if !apierrors.IsAlreadyExists(err) { return "", false, "", err } - existing := &corev1.ConfigMap{} - if getErr := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), existing); getErr != nil { + existing, found, getErr := store.get(ctx, principal.ID, key) + if getErr != nil { return "", false, "", getErr } - if strings.TrimSpace(existing.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { + if !found { + return "", false, "", apierrors.NewNotFound(corev1.Resource("configmaps"), idempotencyReservationName(principal.ID, key)) + } + if strings.TrimSpace(existing.fingerprint) != strings.TrimSpace(state.canonicalFingerprint) { return "", false, "", errIdempotencyUsedDifferent } - done := strings.EqualFold(strings.TrimSpace(existing.Data[idempotencyReservationDoneKey]), "true") - name := strings.TrimSpace(existing.Data[idempotencyReservationNameKey]) - storedPayload := strings.TrimSpace(existing.Data[idempotencyReservationBodyKey]) + done := existing.completed + name := existing.name + storedPayload := existing.payload if storedPayload == "" { return "", false, "", errIdempotencyIncompatiblePending } @@ -854,21 +842,15 @@ func (s *server) completeIdempotencyReservation(ctx context.Context, actorID, ke if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" || spritz == nil { return nil } - reservationName := idempotencyReservationName(actorID, key) - reservationNamespace := s.idempotencyReservationNamespace() - current := &corev1.ConfigMap{} - if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return err - } - if current.Data == nil { - current.Data = map[string]string{} + _, err := s.idempotencyReservations().update(ctx, actorID, key, func(record *idempotencyReservationRecord) error { + record.name = spritz.Name + record.completed = true + return nil + }) + if apierrors.IsNotFound(err) { + return nil } - current.Data[idempotencyReservationNameKey] = spritz.Name - current.Data[idempotencyReservationDoneKey] = "true" - return s.client.Update(ctx, current) + return err } func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key, failedName, proposedName string, state provisionerIdempotencyState) (string, bool, string, error) { @@ -877,80 +859,56 @@ func (s *server) setIdempotencyReservationName(ctx context.Context, actorID, key if strings.TrimSpace(actorID) == "" || strings.TrimSpace(key) == "" { return proposedName, false, strings.TrimSpace(state.resolvedPayload), nil } - reservationName := idempotencyReservationName(actorID, key) - reservationNamespace := s.idempotencyReservationNamespace() selectedName := proposedName completed := false selectedPayload := strings.TrimSpace(state.resolvedPayload) - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - current := &corev1.ConfigMap{} - if err := s.client.Get(ctx, clientKey(reservationNamespace, reservationName), current); err != nil { - if apierrors.IsNotFound(err) { - selectedName = proposedName - completed = false - selectedPayload = strings.TrimSpace(state.resolvedPayload) - return nil - } - return err - } - if strings.TrimSpace(current.Data[idempotencyReservationHashKey]) != strings.TrimSpace(state.canonicalFingerprint) { + _, err := s.idempotencyReservations().update(ctx, actorID, key, func(record *idempotencyReservationRecord) error { + if strings.TrimSpace(record.fingerprint) != strings.TrimSpace(state.canonicalFingerprint) { return errIdempotencyUsedDifferent } - storedName := strings.TrimSpace(current.Data[idempotencyReservationNameKey]) - done := strings.EqualFold(strings.TrimSpace(current.Data[idempotencyReservationDoneKey]), "true") - storedPayload := strings.TrimSpace(current.Data[idempotencyReservationBodyKey]) - if storedPayload == "" { + if strings.TrimSpace(record.payload) == "" { return errIdempotencyIncompatiblePending } - if done { - if storedName == "" { - storedName = proposedName + if record.completed { + if strings.TrimSpace(record.name) == "" { + record.name = proposedName } - selectedName = storedName + selectedName = record.name completed = true - selectedPayload = storedPayload + selectedPayload = record.payload return nil } - if storedName == "" { + if strings.TrimSpace(record.name) == "" { if proposedName == "" { selectedName = "" completed = false - selectedPayload = storedPayload + selectedPayload = record.payload return nil } - if current.Data == nil { - current.Data = map[string]string{} - } - current.Data[idempotencyReservationNameKey] = proposedName - current.Data[idempotencyReservationDoneKey] = "false" - if err := s.client.Update(ctx, current); err != nil { - return err - } + record.name = proposedName + record.completed = false selectedName = proposedName completed = false - selectedPayload = storedPayload + selectedPayload = record.payload return nil } - if failedName == "" || storedName != failedName || proposedName == "" || proposedName == storedName { - selectedName = storedName + if failedName == "" || record.name != failedName || proposedName == "" || proposedName == record.name { + selectedName = record.name completed = false - selectedPayload = storedPayload + selectedPayload = record.payload return nil } - if current.Data == nil { - current.Data = map[string]string{} - } - current.Data[idempotencyReservationNameKey] = proposedName - current.Data[idempotencyReservationDoneKey] = "false" - if err := s.client.Update(ctx, current); err != nil { - return err - } + record.name = proposedName + record.completed = false selectedName = proposedName completed = false - selectedPayload = storedPayload + selectedPayload = record.payload return nil }) if err != nil { + if apierrors.IsNotFound(err) { + return proposedName, false, strings.TrimSpace(state.resolvedPayload), nil + } return "", false, "", err } if strings.TrimSpace(selectedPayload) == "" { diff --git a/api/provisioning_reservation_store_test.go b/api/provisioning_reservation_store_test.go new file mode 100644 index 0000000..40e3628 --- /dev/null +++ b/api/provisioning_reservation_store_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestReserveIdempotentCreateNameFailsWhenReservationDisappearsAfterCreateConflict(t *testing.T) { + s := newCreateSpritzTestServer(t) + state := provisionerIdempotencyState{ + canonicalFingerprint: "fingerprint-1", + resolvedPayload: `{"spec":{"image":"example.com/spritz-openclaw:latest"}}`, + } + s.client = &createInterceptClient{ + Client: s.client, + onCreate: func(_ context.Context, obj client.Object) error { + configMap, ok := obj.(*corev1.ConfigMap) + if !ok { + return nil + } + if configMap.Name != idempotencyReservationName("zenobot", "discord-race") { + return nil + } + return apierrors.NewAlreadyExists(schema.GroupResource{Group: "", Resource: "configmaps"}, configMap.Name) + }, + } + + _, _, _, err := s.reserveIdempotentCreateName(context.Background(), "spritz-test", principal{ID: "zenobot", Type: principalTypeService}, "discord-race", "openclaw-tidal-wind", state) + if err == nil { + t.Fatal("expected missing reservation error") + } + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } +} + +func TestSetIdempotencyReservationNameFallsBackWhenReservationMissing(t *testing.T) { + s := newCreateSpritzTestServer(t) + state := provisionerIdempotencyState{ + canonicalFingerprint: "fingerprint-1", + resolvedPayload: `{"spec":{"image":"example.com/spritz-openclaw:latest"}}`, + } + + name, completed, payload, err := s.setIdempotencyReservationName( + context.Background(), + "zenobot", + "discord-race", + "openclaw-old-name", + "openclaw-new-name", + state, + ) + if err != nil { + t.Fatalf("setIdempotencyReservationName returned error: %v", err) + } + if completed { + t.Fatal("expected missing reservation fallback to stay pending") + } + if name != "openclaw-new-name" { + t.Fatalf("expected proposed name fallback, got %q", name) + } + if payload != state.resolvedPayload { + t.Fatalf("expected resolved payload fallback, got %q", payload) + } +} diff --git a/api/provisioning_test_helpers_test.go b/api/provisioning_test_helpers_test.go new file mode 100644 index 0000000..8d21412 --- /dev/null +++ b/api/provisioning_test_helpers_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + spritzv1 "spritz.sh/operator/api/v1" +) + +func newTestSpritzScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := spritzv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to register spritz scheme: %v", err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to register core scheme: %v", err) + } + return scheme +} + +func newCreateSpritzTestServer(t *testing.T) *server { + t.Helper() + scheme := newTestSpritzScheme(t) + return &server{ + client: fake.NewClientBuilder().WithScheme(scheme).Build(), + scheme: scheme, + namespace: "spritz-test", + controlNamespace: "spritz-test", + auth: authConfig{ + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerEmail: "X-Spritz-User-Email", + headerType: "X-Spritz-Principal-Type", + headerScopes: "X-Spritz-Principal-Scopes", + headerDefaultType: principalTypeHuman, + }, + internalAuth: internalAuthConfig{enabled: false}, + userConfigPolicy: userConfigPolicy{}, + } +} + +type createInterceptClient struct { + client.Client + onCreate func(context.Context, client.Object) error + onUpdate func(context.Context, client.Object) error +} + +func (c *createInterceptClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if c.onCreate != nil { + if err := c.onCreate(ctx, obj); err != nil { + return err + } + } + return c.Client.Create(ctx, obj, opts...) +} + +func (c *createInterceptClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if c.onUpdate != nil { + if err := c.onUpdate(ctx, obj); err != nil { + return err + } + } + return c.Client.Update(ctx, obj, opts...) +} + +func configureProvisionerTestServer(s *server) { + s.auth.headerTrustTypeAndScopes = true + s.presets = presetCatalog{ + byID: []runtimePreset{{ + ID: "openclaw", + Name: "OpenClaw", + Image: "example.com/spritz-openclaw:latest", + NamePrefix: "openclaw", + }}, + } + s.provisioners = provisionerPolicy{ + allowedPresetIDs: map[string]struct{}{"openclaw": {}}, + defaultIdleTTL: 24 * time.Hour, + maxIdleTTL: 24 * time.Hour, + defaultTTL: 168 * time.Hour, + maxTTL: 168 * time.Hour, + rateWindow: time.Hour, + } +}