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/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..bfbfbff 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() && !principal.isAdminPrincipal() { 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..ff0e2b8 100644 --- a/api/acp_gateway.go +++ b/api/acp_gateway.go @@ -1,7 +1,9 @@ package main import ( + "encoding/json" "net/http" + "strings" "sync" "github.com/gorilla/websocket" @@ -16,6 +18,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 +52,17 @@ func (s *server) openACPConversationConnection(c echo.Context) error { _ = workspaceConn.Close() }() - return proxyWebSockets(browserConn, workspaceConn) + return proxyWebSockets( + browserConn, + workspaceConn, + func(payload []byte) { + s.scheduleACPPromptActivity(c.Logger(), spritz.Namespace, spritz.Name, payload) + }, + 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 +70,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 +81,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_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/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..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 } @@ -41,8 +45,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{ @@ -427,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/activity.go b/api/activity.go new file mode 100644 index 0000000..745799c --- /dev/null +++ b/api/activity.go @@ -0,0 +1,61 @@ +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" +) + +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 + } + 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..dd8df45 100644 --- a/api/auth.go +++ b/api/auth.go @@ -45,6 +45,10 @@ type authConfig struct { headerID string headerEmail string headerTeams string + headerType string + headerScopes string + headerTrustTypeAndScopes bool + headerDefaultType principalType adminIDs map[string]struct{} adminTeams map[string]struct{} bearerIntrospectionURL string @@ -56,6 +60,9 @@ type authConfig struct { bearerIDPaths []string bearerEmailPaths []string bearerTeamsPaths []string + bearerTypePaths []string + bearerScopesPaths []string + bearerDefaultType principalType bearerAuthorizationHeader string bearerJWKSURL string bearerJWKSIssuer string @@ -76,15 +83,36 @@ 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 { + 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"), + 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")), bearerIntrospectionURL: strings.TrimSpace(os.Getenv("SPRITZ_AUTH_BEARER_INTROSPECTION_URL")), @@ -96,6 +124,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(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")), @@ -129,6 +160,62 @@ 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 + default: + return fallback + } +} + +func finalizePrincipal(id, email string, teams []string, subject, issuer string, principalTypeValue principalType, scopes []string, admin bool) principal { + isAdmin := admin + 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 +} + +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 +229,43 @@ 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 + 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, + "", + principalTypeValue, + scopes, + 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 + 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, + "", + principalTypeValue, + scopes, + a.isAdmin(id, teams), + ), nil } if a.bearerIntrospectionURL == "" && a.bearerJWKSURL == "" { return principal{}, errUnauthenticated @@ -251,13 +358,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), + firstScopeListPath(payload, a.bearerScopesPaths), + a.isAdmin(id, teams), + ), nil } func (a *authConfig) jwks() (*keyfunc.JWKS, error) { @@ -334,12 +444,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), + firstScopeListPath(claims, a.bearerScopesPaths), + a.isAdmin(id, teams), + ), nil } func verifyAudience(claims jwt.MapClaims, audiences []string) bool { @@ -505,6 +619,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 { @@ -513,6 +644,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 == "" { @@ -603,6 +758,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 be4cdd9..2f4a850 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -80,3 +80,331 @@ 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") + t.Setenv("SPRITZ_AUTH_HEADER_TRUST_TYPE_AND_SCOPES", "true") + + 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"]) + } +} + +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") + 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{ + "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"]) + } +} + +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"]) + } +} + +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"]) + } +} + +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/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/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 5c391d6..5715f4f 100644 --- a/api/main.go +++ b/api/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -28,24 +29,29 @@ 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 - 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 + controlNamespace 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) + activityRecorder func(context.Context, string, string, time.Time) error } func main() { @@ -63,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(ns) + } + if controlNamespace == "" { + controlNamespace = strings.TrimSpace(os.Getenv("POD_NAMESPACE")) + } + if controlNamespace == "" { + controlNamespace = "default" + } { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -80,6 +96,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 { @@ -118,6 +140,7 @@ func main() { restConfig: cfg, scheme: scheme, namespace: ns, + controlNamespace: controlNamespace, auth: auth, internalAuth: internalAuth, ingressDefaults: ingressDefaults, @@ -126,6 +149,8 @@ func main() { sshDefaults: sshDefaults, sshMintLimiter: sshMintLimiter, acp: acp, + presets: presets, + provisioners: provisioners, defaultMetadata: defaultAnnotations, sharedMounts: sharedMounts, sharedMountsStore: sharedStore, @@ -189,6 +214,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 +240,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,6 +277,32 @@ 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 == "") { + return writeError(c, http.StatusUnauthorized, "unauthenticated") + } + if principal.isService() && !principal.hasScope(scopePresetsRead) && !principal.isAdminPrincipal() { + return writeError(c, http.StatusForbidden, "forbidden") + } + 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 { principal, ok := principalFromContext(c) if s.auth.enabled() && (!ok || principal.ID == "") { @@ -253,17 +313,26 @@ func (s *server) suggestSpritzName(c echo.Context) error { 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") + s.applyProvisionerDefaultSuggestNamePreset(&body, principal) + 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) + requestedNamespace := s.namespaceOverrideRequested(body.Namespace, namespace) + if principal.isService() { + 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") + } + 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") } @@ -280,80 +349,97 @@ 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) - - 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 len(userConfigKeys) > 0 { - normalized, err := normalizeUserConfig(s.userConfigPolicy, userConfigKeys, userConfigPayload) + 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 := normalized.requestedNamePrefix + 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.StatusBadRequest, err.Error()) + return err } - userConfigPayload = normalized - applyUserConfig(&body.Spec, userConfigKeys, userConfigPayload) + nameGenerator = generator + return nil } - - if body.Spec.Image == "" { - return writeError(c, http.StatusBadRequest, "spec.image is required") + 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 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 !principal.isService() && body.Name == "" { + return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") } - if body.Spec.Repo != nil { - if err := validateRepoDir(body.Spec.Repo.Dir); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + + provisionerFingerprint := "" + var provisionerTx *provisionerCreateTransaction + if principal.isService() { + provisionerTx = newProvisionerCreateTransaction( + s, + c.Request().Context(), + principal, + namespace, + &body, + normalized.fingerprintRequest, + normalized.normalizedUserConfig, + normalized.requestedImage, + normalized.requestedRepo, + normalized.requestedNamespace, + ) + if err := provisionerTx.prepare(); err != nil { + return writeProvisionerCreateError(c, err) } - } - for _, repo := range body.Spec.Repos { - if err := validateRepoDir(repo.Dir); err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + 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") + } } - } - if len(body.Spec.SharedMounts) > 0 { - normalized, err := normalizeSharedMounts(body.Spec.SharedMounts) + existing, err := provisionerTx.replayExisting() if err != nil { - return writeError(c, http.StatusBadRequest, err.Error()) + return writeProvisionerCreateError(c, err) } - body.Spec.SharedMounts = normalized - } - - namespace, err := s.resolveSpritzNamespace(body.Namespace) - if err != nil { - return writeError(c, http.StatusForbidden, err.Error()) - } - - nameProvided := body.Name != "" - var nameGenerator func() string - if !nameProvided { - namePrefix := resolveSpritzNamePrefix(body.NamePrefix, body.Spec.Image) - generator, err := s.newSpritzNameGenerator(c.Request().Context(), namespace, namePrefix) - if err != nil { - return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + if existing != nil { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(existing, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) } - nameGenerator = generator - body.Name = nameGenerator() - } - if body.Name == "" { - return writeError(c, http.StatusInternalServerError, "failed to generate spritz name") + if err := provisionerTx.finalizeCreate(); err != nil { + return writeProvisionerCreateError(c, err) + } + } else if s.auth.enabled() && !principal.isAdminPrincipal() && owner.ID != principal.ID { + return writeError(c, http.StatusForbidden, "owner mismatch") } - 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 !principal.isService() { + if err := resolveCreateLifetimes(&body.Spec, s.provisioners, false); err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) } } - 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 +455,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 @@ -407,22 +497,58 @@ func (s *server) createSpritz(c echo.Context) error { if !nameProvided { attempts = 8 } + currentName := body.Name for attempt := 0; attempt < attempts; attempt++ { - name := body.Name - if !nameProvided && attempt > 0 { - name = nameGenerator() + name := currentName + failedName := "" + if !nameProvided { + if attempt > 0 { + failedName = currentName + } + if strings.TrimSpace(name) == "" || attempt > 0 { + name = nameGenerator() + } + } + if principal.isService() { + reservedName, replayed, err := provisionerTx.reserveAttemptName(failedName, name) + if err != nil { + return writeProvisionerCreateError(c, err) + } + name = reservedName + if replayed != nil { + return writeJSON(c, http.StatusOK, summarizeCreateResponse(replayed, principal, body.PresetID, provisionerSource(&body), body.IdempotencyKey, true)) + } } 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, provisionerFingerprint) { + 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) { continue } return writeError(c, http.StatusInternalServerError, err.Error()) } - return writeJSON(c, http.StatusCreated, spritz) + if principal.isService() { + if err := s.completeIdempotencyReservation(c.Request().Context(), 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)) } return writeError(c, http.StatusInternalServerError, "failed to generate unique spritz name") @@ -433,6 +559,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 +578,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 +600,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 +616,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 +632,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 +648,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 +723,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 +739,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..5fd61f9 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -2,45 +2,25 @@ package main import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/labstack/echo/v4" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" + 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/schema" + "sigs.k8s.io/controller-runtime/pkg/client" 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) - } - 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", - auth: authConfig{ - mode: authModeHeader, - headerID: "X-Spritz-User-Id", - headerEmail: "X-Spritz-User-Email", - }, - internalAuth: internalAuthConfig{enabled: false}, - userConfigPolicy: userConfigPolicy{}, - } -} - func TestCreateSpritzOwnerUsesIDAndOmitsEmail(t *testing.T) { s := newCreateSpritzTestServer(t) e := echo.New() @@ -68,7 +48,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"]) } @@ -138,6 +122,159 @@ 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 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 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() @@ -164,7 +301,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 +313,1590 @@ 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 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 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) + 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) + 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 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"]) + } +} + +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) + 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()) + } +} + +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 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) + 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()) + } +} + +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) + } +} + +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()) + } +} + +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()) + } +} + +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 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) + 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 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) + 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 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) + 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 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) + 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 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) + 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 + 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 := 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("createResolvedProvisionerPayload 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", + 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: 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) + } +} + +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 + 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 := 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("createResolvedProvisionerPayload 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", + 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-fixed", + Namespace: s.namespace, + Annotations: map[string]string{ + idempotencyHashAnnotationKey: fingerprint, + idempotencyKeyAnnotationKey: body.IdempotencyKey, + actorIDAnnotationKey: "zenobot", + }, + }, + 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"]) + } +} + +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 + 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 := createRequestFingerprint(requestFingerprintBody, s.namespace, "", "", nil) + if err != nil { + t.Fatalf("createRequestFingerprint failed: %v", err) + } + state := provisionerIdempotencyState{ + canonicalFingerprint: fingerprint, + resolvedPayload: "payload", + } + + 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", + idempotencyReservationBodyKey: "payload", + }, + }); 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, "openclaw-blocked", "openclaw-alpha", state) + 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, "openclaw-blocked", "openclaw-beta", state) + 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) + 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()) + } +} + +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/main_owner_visibility_test.go b/api/main_owner_visibility_test.go index c0c7543..1d008d8 100644 --- a/api/main_owner_visibility_test.go +++ b/api/main_owner_visibility_test.go @@ -26,8 +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", + mode: authModeHeader, + headerID: "X-Spritz-User-Id", + headerType: "X-Spritz-Principal-Type", + headerTrustTypeAndScopes: true, + headerDefaultType: principalTypeHuman, }, internalAuth: internalAuthConfig{enabled: false}, } @@ -84,3 +87,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..bdeec60 --- /dev/null +++ b/api/provisioning.go @@ -0,0 +1,1023 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "sort" + "strings" + "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" + + 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" + idempotencyReservationPrefix = "spritz-idempotency-" + idempotencyReservationHashKey = "fingerprint" + idempotencyReservationNameKey = "spritzName" + idempotencyReservationDoneKey = "completed" + idempotencyReservationBodyKey = "payload" + defaultProvisionerSource = "external" + defaultProvisionerIdleTTL = 24 * time.Hour + 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"` + 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 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 +} + +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 +} + +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 == "" { + 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 (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 (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) + } + 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 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") + } + if owner.ID == "" { + if authEnabled { + owner.ID = principal.ID + } else { + return owner, fmt.Errorf("spec.owner.id is required") + } + } + return owner, nil +} + +func validateProvisionerRequestSurface(body *createRequest) error { + if body == nil { + return nil + } + 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 == "" { + 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) validateProvisionerPlacement(principal principal, namespace, presetID string, requestedImage, requestedNamespace bool, scope string) error { + if !principalCanUseProvisionerFlow(principal) { + return errForbidden + } + if err := authorizeServiceAction(principal, scope, true); err != nil { + return err + } + if requestedNamespace && !s.provisioners.allowNamespaceOverride { + return fmt.Errorf("namespace override is not allowed") + } + if err := s.provisioners.validateNamespace(namespace); 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 nil +} + +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 + } + if err := authorizeServiceAction(principal, scopeInstancesAssignOwner, true); err != nil { + return err + } + if requestedRepo && !s.provisioners.allowCustomRepo { + return fmt.Errorf("custom repo is not allowed") + } + if body.IdempotencyKey == "" { + return fmt.Errorf("idempotencyKey is required") + } + return nil +} + +func (s *server) enforceProvisionerQuotas(ctx context.Context, namespace string, principal principal, ownerID string) error { + 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 _, listNamespace := range namespaces { + list := &spritzv1.SpritzList{} + if err := s.client.List(ctx, list, client.InNamespace(listNamespace)); err != nil { + return err + } + 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 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 == "" { + 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) 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 + } + 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) 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 == "" { + return nil, false + } + for i := range c.byID { + if c.byID[i].ID == id { + copy := c.byID[i] + return ©, true + } + } + 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) + 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, namePrefix, 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"` + NamePrefix string `json:"namePrefix,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, + NamePrefix: strings.TrimSpace(namePrefix), + 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 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) 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) idempotencyReservations() *idempotencyReservationStore { + return newIdempotencyReservationStore(s.client, s.idempotencyReservationNamespace()) +} + +func (s *server) getIdempotencyReservation(ctx context.Context, actorID, key, fingerprint string) (string, bool, string, bool, error) { + record, found, err := s.idempotencyReservations().get(ctx, actorID, key) + if err != nil { + return "", false, "", false, err + } + if !found { + return "", false, "", false, nil + } + if strings.TrimSpace(record.fingerprint) != strings.TrimSpace(fingerprint) { + return "", false, "", false, errIdempotencyUsedDifferent + } + 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 + } + 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, found, getErr := store.get(ctx, principal.ID, key) + if getErr != nil { + return "", false, "", getErr + } + 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 := existing.completed + name := existing.name + storedPayload := existing.payload + if storedPayload == "" { + return "", false, "", errIdempotencyIncompatiblePending + } + if done { + if name == "" { + name = desiredName + } + return name, true, storedPayload, nil + } + if name == "" { + return s.setIdempotencyReservationName(ctx, principal.ID, key, "", desiredName, state) + } + reservedSpritz, getErr := s.findReservedSpritz(ctx, namespace, name) + if getErr != nil { + return "", false, "", getErr + } + if reservedSpritz != nil && !matchesIdempotentReplayTarget(reservedSpritz, principal, key, state.canonicalFingerprint) { + return s.setIdempotencyReservationName(ctx, principal.ID, key, name, desiredName, state) + } + return name, false, storedPayload, nil + } + return desiredName, false, strings.TrimSpace(state.resolvedPayload), nil +} + +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 + } + _, 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 + } + return err +} + +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, strings.TrimSpace(state.resolvedPayload), nil + } + selectedName := proposedName + completed := false + selectedPayload := strings.TrimSpace(state.resolvedPayload) + _, err := s.idempotencyReservations().update(ctx, actorID, key, func(record *idempotencyReservationRecord) error { + if strings.TrimSpace(record.fingerprint) != strings.TrimSpace(state.canonicalFingerprint) { + return errIdempotencyUsedDifferent + } + if strings.TrimSpace(record.payload) == "" { + return errIdempotencyIncompatiblePending + } + if record.completed { + if strings.TrimSpace(record.name) == "" { + record.name = proposedName + } + selectedName = record.name + completed = true + selectedPayload = record.payload + return nil + } + if strings.TrimSpace(record.name) == "" { + if proposedName == "" { + selectedName = "" + completed = false + selectedPayload = record.payload + return nil + } + record.name = proposedName + record.completed = false + selectedName = proposedName + completed = false + selectedPayload = record.payload + return nil + } + if failedName == "" || record.name != failedName || proposedName == "" || proposedName == record.name { + selectedName = record.name + completed = false + selectedPayload = record.payload + return nil + } + record.name = proposedName + record.completed = false + selectedName = proposedName + completed = false + 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) == "" { + selectedPayload = strings.TrimSpace(state.resolvedPayload) + } + return selectedName, completed, selectedPayload, nil +} + +func (s *server) findReservedSpritz(ctx context.Context, namespace, name string) (*spritzv1.Spritz, error) { + if strings.TrimSpace(name) == "" { + return nil, 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 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 { + 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{ + 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 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), + } + 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 = resolvePresetNamePrefix(body.NamePrefix, *preset) + 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/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, + } +} 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) +} diff --git a/api/random_name.go b/api/random_name.go index a5faf8d..41dd6f2 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", @@ -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/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/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..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), } } @@ -132,7 +134,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,10 +161,13 @@ 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) } - 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 } @@ -199,7 +204,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") } @@ -230,10 +235,17 @@ func (s *server) streamTerminal(ctx context.Context, pod *corev1.Pod, conn *webs 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) + readErr <- readTerminalInput(ctx, conn, stdinWriter, sizeQueue, reportActivity) }() streamErr := executor.StreamWithContext(ctx, remotecommand.StreamOptions{ @@ -265,7 +277,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(): @@ -287,7 +299,32 @@ func readTerminalInput(ctx context.Context, conn *websocket.Conn, stdin *io.Pipe if _, err := stdin.Write(payload); err != nil { return err } + if onInput != nil { + onInput() + } + } + } +} + +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() } } 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/api/terminal_test.go b/api/terminal_test.go new file mode 100644 index 0000000..f57c156 --- /dev/null +++ b/api/terminal_test.go @@ -0,0 +1,107 @@ +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") + } +} + +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/cli/src/index.ts b/cli/src/index.ts index 13a94b1..962568e 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; @@ -545,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; @@ -1023,40 +1056,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'); const repo = argValue('--repo'); const branch = argValue('--branch'); + 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'); + 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..b0c09c6 --- /dev/null +++ b/cli/test/provisioner-create.test.ts @@ -0,0 +1,214 @@ +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'); +}); + +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'); +}); + +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'); +}); 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/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/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..fdeb089 --- /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 type, +- 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 type, not just +as "non-admin bearer callers". + +The long-term principal model should include: + +- `type`: `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` 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 3763662..3a826d3 100644 --- a/helm/spritz/templates/api-deployment.yaml +++ b/helm/spritz/templates/api-deployment.yaml @@ -40,8 +40,14 @@ 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 }} + - name: SPRITZ_CONTROL_NAMESPACE + value: {{ .Values.spritz.namespace | quote }} {{- if .Values.api.defaultAnnotations }} - name: SPRITZ_DEFAULT_ANNOTATIONS value: {{ .Values.api.defaultAnnotations | quote }} @@ -54,6 +60,14 @@ 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_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 }} - name: SPRITZ_AUTH_ADMIN_IDS value: {{ join "," .Values.api.auth.adminIds | quote }} @@ -98,6 +112,18 @@ 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 }} + {{- 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 }} @@ -193,6 +219,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 }} @@ -200,6 +230,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 }} @@ -219,6 +285,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/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/helm/spritz/values.yaml b/helm/spritz/values.yaml index b0d2694..6b1eeae 100644 --- a/helm/spritz/values.yaml +++ b/helm/spritz/values.yaml @@ -129,6 +129,10 @@ 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 + headerTrustTypeAndScopes: false + headerDefaultType: human adminIds: [] adminTeams: [] bearer: @@ -143,6 +147,7 @@ api: emailPaths: - email teamsPaths: [] + typePaths: [] jwks: url: "" issuer: "" @@ -154,9 +159,29 @@ api: refreshTimeout: 5s rateLimit: 10s fallbackToIntrospection: false + scopesPaths: + - scope + - scopes + - scp + defaultType: "" + 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: @@ -172,6 +197,7 @@ api: container: spritz command: "bash -l" origins: [] + activityDebounce: 5s acp: origins: [] sshGateway: @@ -182,6 +208,7 @@ api: user: spritz principalPrefix: spritz certTtl: 15m + activityRefresh: 1m mintLimit: 5 mintWindow: 1m mintBurst: 5 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/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..ea749aa --- /dev/null +++ b/operator/api/v1/lifecycle.go @@ -0,0 +1,59 @@ +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 && maxExpiresAt == nil: + return nil, nil, nil, "", nil + 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..3b017d5 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"` } @@ -494,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) + } +} diff --git a/operator/controllers/lifecycle_test.go b/operator/controllers/lifecycle_test.go new file mode 100644 index 0000000..c4d39d3 --- /dev/null +++ b/operator/controllers/lifecycle_test.go @@ -0,0 +1,78 @@ +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") + } +} + +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) + } +} 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..761f99c 100755 --- a/scripts/verify-helm.sh +++ b/scripts/verify-helm.sh @@ -83,6 +83,13 @@ 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_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" +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-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..20a9150 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 || message?.kind || '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..88725b3 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'); }); @@ -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); +}); 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); }