diff --git a/api/internal_bindings.go b/api/internal_bindings.go new file mode 100644 index 0000000..66df00d --- /dev/null +++ b/api/internal_bindings.go @@ -0,0 +1,435 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" + 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" +) + +type internalBindingRequest struct { + DesiredRevision string `json:"desiredRevision,omitempty"` + Disconnected bool `json:"disconnected,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Principal internalCreatePrincipal `json:"principal"` + Request json.RawMessage `json:"request"` + AdoptActive *internalBindingInstanceRef `json:"adoptActive,omitempty"` + AdoptedRevision string `json:"adoptedRevision,omitempty"` +} + +type internalBindingInstanceRef struct { + Namespace string `json:"namespace,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + Revision string `json:"revision,omitempty"` +} + +type internalBindingMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationTimestamp metav1.Time `json:"creationTimestamp,omitempty"` +} + +type internalBindingTemplateSummary struct { + PresetID string `json:"presetId,omitempty"` + Source string `json:"source,omitempty"` + RequestID string `json:"requestId,omitempty"` + OwnerID string `json:"ownerId,omitempty"` +} + +type internalBindingSpecSummary struct { + BindingKey string `json:"bindingKey"` + DesiredRevision string `json:"desiredRevision,omitempty"` + Disconnected bool `json:"disconnected,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Template internalBindingTemplateSummary `json:"template"` +} + +type internalBindingSummary struct { + Metadata internalBindingMetadata `json:"metadata"` + Spec internalBindingSpecSummary `json:"spec"` + Status spritzv1.SpritzBindingStatus `json:"status"` +} + +func bindingResourceNameForKey(bindingKey string) string { + normalized := strings.TrimSpace(bindingKey) + sum := sha256.Sum256([]byte(normalized)) + prefix := sanitizeSpritzNameToken(normalized) + if prefix == "" { + prefix = "binding" + } + if len(prefix) > 36 { + prefix = prefix[:36] + prefix = strings.TrimRight(prefix, "-") + } + return fmt.Sprintf("%s-%x", prefix, sum[:8]) +} + +func summarizeInternalBinding(binding *spritzv1.SpritzBinding) internalBindingSummary { + return internalBindingSummary{ + Metadata: internalBindingMetadata{ + Name: binding.Name, + Namespace: binding.Namespace, + CreationTimestamp: binding.CreationTimestamp, + }, + Spec: internalBindingSpecSummary{ + BindingKey: strings.TrimSpace(binding.Spec.BindingKey), + DesiredRevision: strings.TrimSpace(binding.Spec.DesiredRevision), + Disconnected: binding.Spec.Disconnected, + Attributes: cloneStringMap(binding.Spec.Attributes), + Template: internalBindingTemplateSummary{ + PresetID: strings.TrimSpace(binding.Spec.Template.PresetID), + Source: strings.TrimSpace(binding.Spec.Template.Source), + RequestID: strings.TrimSpace(binding.Spec.Template.RequestID), + OwnerID: strings.TrimSpace(binding.Spec.Template.Spec.Owner.ID), + }, + }, + Status: binding.Status, + } +} + +func (s *server) getInternalBinding(c echo.Context) error { + namespace, bindingName, err := s.resolveBindingPath(c) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + var binding spritzv1.SpritzBinding + if err := s.client.Get(c.Request().Context(), client.ObjectKey{Namespace: namespace, Name: bindingName}, &binding); err != nil { + if apierrors.IsNotFound(err) { + return writeError(c, http.StatusNotFound, "not found") + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return writeJSON(c, http.StatusOK, summarizeInternalBinding(&binding)) +} + +func (s *server) upsertInternalBinding(c echo.Context) error { + namespace, bindingName, err := s.resolveBindingPath(c) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + var body internalBindingRequest + if err := c.Bind(&body); err != nil { + return writeError(c, http.StatusBadRequest, "invalid json") + } + internalPrincipal, err := body.Principal.normalize() + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + var requestBody createRequest + if err := json.Unmarshal(bytes.TrimSpace(body.Request), &requestBody); err != nil { + return writeError(c, http.StatusBadRequest, "request is invalid") + } + if strings.TrimSpace(requestBody.Name) != "" { + return writeError(c, http.StatusBadRequest, "request.name is not allowed for bindings") + } + + normalized, err := s.normalizeCreateRequest(c.Request().Context(), internalPrincipal, requestBody, false) + if err != nil { + return writeCreateRequestError(c, err) + } + requestBody = normalized.body + + if err := s.ensureServiceAccount(c.Request().Context(), namespace, requestBody.Spec.ServiceAccountName); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to ensure service account") + } + + labels := map[string]string{ + ownerLabelKey: ownerLabelValue(requestBody.Spec.Owner.ID), + actorLabelKey: actorLabelValue(internalPrincipal.ID), + } + if strings.TrimSpace(requestBody.PresetID) != "" { + labels[presetLabelKey] = strings.TrimSpace(requestBody.PresetID) + } + + annotations := cloneStringMap(s.defaultMetadata) + if annotations == nil { + annotations = map[string]string{} + } + if strings.TrimSpace(requestBody.PresetID) != "" { + annotations[presetIDAnnotationKey] = strings.TrimSpace(requestBody.PresetID) + } + + applySSHDefaults(&requestBody.Spec, s.sshDefaults, namespace) + template := spritzv1.SpritzBindingTemplate{ + PresetID: strings.TrimSpace(requestBody.PresetID), + NamePrefix: s.resolvedCreateNamePrefix(requestBody, normalized.requestedNamePrefix), + Source: provisionerSource(&requestBody), + RequestID: strings.TrimSpace(requestBody.RequestID), + Spec: requestBody.Spec, + Labels: labels, + Annotations: annotations, + } + + binding := &spritzv1.SpritzBinding{} + createNew := false + if err := s.client.Get(c.Request().Context(), client.ObjectKey{Namespace: namespace, Name: bindingName}, binding); err != nil { + if !apierrors.IsNotFound(err) { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + createNew = true + binding = &spritzv1.SpritzBinding{ + TypeMeta: metav1.TypeMeta{Kind: "SpritzBinding", APIVersion: spritzv1.GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{ + Name: bindingName, + Namespace: namespace, + }, + } + } + + binding.Spec = spritzv1.SpritzBindingSpec{ + BindingKey: strings.TrimSpace(c.Param("bindingKey")), + DesiredRevision: strings.TrimSpace(body.DesiredRevision), + Disconnected: body.Disconnected, + Attributes: cloneStringMap(body.Attributes), + Template: template, + AdoptActive: convertInternalBindingRef(body.AdoptActive, namespace), + AdoptedRevision: strings.TrimSpace(body.AdoptedRevision), + ObservedRequestID: strings.TrimSpace(requestBody.RequestID), + } + if binding.Annotations == nil { + binding.Annotations = map[string]string{} + } + binding.Annotations[spritzv1.BindingReconcileRequestedAtAnnotationKey] = time.Now().UTC().Format(time.RFC3339Nano) + + if createNew { + if err := s.client.Create(c.Request().Context(), binding); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } else { + if err := s.client.Update(c.Request().Context(), binding); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } + + var stored spritzv1.SpritzBinding + if err := s.client.Get(c.Request().Context(), client.ObjectKey{Namespace: namespace, Name: bindingName}, &stored); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return writeJSON(c, http.StatusOK, summarizeInternalBinding(&stored)) +} + +func (s *server) reconcileInternalBinding(c echo.Context) error { + namespace, bindingName, err := s.resolveBindingPath(c) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + var binding spritzv1.SpritzBinding + if err := s.client.Get(c.Request().Context(), client.ObjectKey{Namespace: namespace, Name: bindingName}, &binding); err != nil { + if apierrors.IsNotFound(err) { + return writeError(c, http.StatusNotFound, "not found") + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if binding.Annotations == nil { + binding.Annotations = map[string]string{} + } + binding.Annotations[spritzv1.BindingReconcileRequestedAtAnnotationKey] = time.Now().UTC().Format(time.RFC3339Nano) + if err := s.client.Update(c.Request().Context(), &binding); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return writeJSON(c, http.StatusOK, summarizeInternalBinding(&binding)) +} + +func (s *server) resolveBindingPath(c echo.Context) (string, string, error) { + namespace, err := s.resolveSpritzNamespace(strings.TrimSpace(c.Param("namespace"))) + if err != nil { + return "", "", err + } + bindingKey := strings.TrimSpace(c.Param("bindingKey")) + if bindingKey == "" { + return "", "", fmt.Errorf("bindingKey required") + } + return namespace, bindingResourceNameForKey(bindingKey), nil +} + +func convertInternalBindingRef(ref *internalBindingInstanceRef, defaultNamespace string) *spritzv1.SpritzBindingInstanceRef { + if ref == nil { + return nil + } + name := strings.TrimSpace(ref.InstanceID) + if name == "" { + return nil + } + namespace := strings.TrimSpace(ref.Namespace) + if namespace == "" { + namespace = defaultNamespace + } + return &spritzv1.SpritzBindingInstanceRef{ + Namespace: namespace, + Name: name, + Revision: strings.TrimSpace(ref.Revision), + } +} + +func findBindingOwner(spritz *spritzv1.Spritz) string { + if spritz == nil { + return "" + } + for _, owner := range spritz.OwnerReferences { + if strings.EqualFold(strings.TrimSpace(owner.Kind), "SpritzBinding") && owner.Name != "" { + return owner.Name + } + } + if spritz.Labels != nil { + return strings.TrimSpace(spritz.Labels[spritzv1.BindingNameLabelKey]) + } + return "" +} + +func (s *server) getBindingByRuntime( + ctx context.Context, + namespace string, + source *spritzv1.Spritz, +) (*spritzv1.SpritzBinding, error) { + bindingName := findBindingOwner(source) + if bindingName == "" { + return nil, nil + } + var binding spritzv1.SpritzBinding + if err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: bindingName}, &binding); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return &binding, nil +} + +func (s *server) replaceInternalBinding( + ctx context.Context, + binding *spritzv1.SpritzBinding, + targetRevision string, +) (*spritzv1.SpritzBinding, bool, error) { + if binding == nil { + return nil, false, nil + } + desiredRevision := strings.TrimSpace(targetRevision) + replayed := strings.TrimSpace(binding.Spec.DesiredRevision) == desiredRevision + needsUpdate := false + if binding.Spec.DesiredRevision != desiredRevision { + binding.Spec.DesiredRevision = desiredRevision + needsUpdate = true + } + if !bindingReadyOnDesiredRevision(binding) { + if binding.Annotations == nil { + binding.Annotations = map[string]string{} + } + binding.Annotations[spritzv1.BindingReconcileRequestedAtAnnotationKey] = time.Now().UTC().Format(time.RFC3339Nano) + needsUpdate = true + } + if needsUpdate { + if err := s.client.Update(ctx, binding); err != nil { + return nil, false, err + } + } + var stored spritzv1.SpritzBinding + if err := s.client.Get(ctx, client.ObjectKey{Namespace: binding.Namespace, Name: binding.Name}, &stored); err != nil { + return nil, false, err + } + return &stored, replayed, nil +} + +func replacementRuntimeFromBinding(binding *spritzv1.SpritzBinding, fallbackName string) *spritzv1.Spritz { + if binding == nil { + return nil + } + ref := binding.Status.CandidateInstanceRef + if ref == nil && bindingReadyOnDesiredRevision(binding) { + ref = binding.Status.ActiveInstanceRef + } + if ref != nil { + revision := strings.TrimSpace(ref.Revision) + if revision == "" { + revision = bindingObservedRevision(binding) + } + if revision == "" { + revision = strings.TrimSpace(binding.Spec.DesiredRevision) + } + return &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ref.Namespace, + Name: ref.Name, + Annotations: map[string]string{ + targetRevisionAnnotationKey: revision, + }, + }, + Status: spritzv1.SpritzStatus{ + Phase: strings.TrimSpace(ref.Phase), + }, + } + } + name := strings.TrimSpace(fallbackName) + if name == "" { + name = predictedBindingCandidateName(binding) + } + if name == "" { + return nil + } + revision := strings.TrimSpace(binding.Spec.DesiredRevision) + if revision == "" { + revision = bindingObservedRevision(binding) + } + return &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: binding.Namespace, + Name: name, + Annotations: map[string]string{ + targetRevisionAnnotationKey: revision, + }, + }, + Status: spritzv1.SpritzStatus{ + Phase: "Provisioning", + }, + } +} + +func bindingReadyOnDesiredRevision(binding *spritzv1.SpritzBinding) bool { + if binding == nil || binding.Status.ActiveInstanceRef == nil { + return false + } + if binding.Status.CandidateInstanceRef != nil { + return false + } + return bindingObservedRevision(binding) == strings.TrimSpace(binding.Spec.DesiredRevision) +} + +func bindingObservedRevision(binding *spritzv1.SpritzBinding) string { + if binding == nil { + return "" + } + if revision := strings.TrimSpace(binding.Status.ObservedRevision); revision != "" { + return revision + } + if binding.Status.ActiveInstanceRef != nil { + return strings.TrimSpace(binding.Status.ActiveInstanceRef.Revision) + } + return "" +} + +func predictedBindingCandidateName(binding *spritzv1.SpritzBinding) string { + if binding == nil { + return "" + } + if binding.Status.CandidateInstanceRef != nil { + return strings.TrimSpace(binding.Status.CandidateInstanceRef.Name) + } + return spritzv1.BindingRuntimeNameForSequence( + strings.TrimSpace(binding.Spec.BindingKey), + binding.Spec.Template.NamePrefix, + binding.Spec.Template.PresetID, + binding.Status.NextRuntimeSequence+1, + ) +} diff --git a/api/internal_bindings_test.go b/api/internal_bindings_test.go new file mode 100644 index 0000000..bd7e873 --- /dev/null +++ b/api/internal_bindings_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + spritzv1 "spritz.sh/operator/api/v1" +) + +func TestInternalUpsertBindingCreatesAndFetchesBinding(t *testing.T) { + s := newInternalSpritzesTestServer(t) + e := echo.New() + s.registerRoutes(e) + + body := `{ + "desiredRevision": "sha256:rev-1", + "disconnected": false, + "attributes": { + "provider": "slack", + "externalTenantId": "T_workspace_1" + }, + "principal": {"id": "channel-gateway"}, + "request": { + "presetId": "zeno", + "ownerId": "user-123", + "requestId": "binding-upsert-1", + "source": "channel-gateway", + "spec": {} + } + }` + req := httptest.NewRequest(http.MethodPut, "/api/internal/v1/bindings/channel-installation-binding-1", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer spritz-internal-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"bindingKey":"channel-installation-binding-1"`) { + t.Fatalf("expected binding key in response, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"desiredRevision":"sha256:rev-1"`) { + t.Fatalf("expected desired revision in response, got %s", rec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/internal/v1/bindings/channel-installation-binding-1", nil) + getReq.Header.Set("Authorization", "Bearer spritz-internal-token") + getRec := httptest.NewRecorder() + e.ServeHTTP(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + if !strings.Contains(getRec.Body.String(), `"bindingKey":"channel-installation-binding-1"`) { + t.Fatalf("expected fetched binding key in response, got %s", getRec.Body.String()) + } + if !strings.Contains(getRec.Body.String(), `"presetId":"zeno"`) { + t.Fatalf("expected fetched preset id in response, got %s", getRec.Body.String()) + } +} + +func TestInternalReplaceSpritzUsesBindingLifecycleWhenRuntimeIsOwnedByBinding(t *testing.T) { + targetRevision := "sha256:rev-2" + binding := &spritzv1.SpritzBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "channel-installation-binding-1", + Namespace: "spritz-production", + }, + Spec: spritzv1.SpritzBindingSpec{ + BindingKey: "channel-installation-binding-1", + DesiredRevision: "sha256:rev-1", + }, + Status: spritzv1.SpritzBindingStatus{ + ObservedRevision: "sha256:rev-1", + ActiveInstanceRef: &spritzv1.SpritzBindingInstanceRef{ + Namespace: "spritz-production", + Name: "zeno-acme", + Revision: "sha256:rev-1", + Phase: "Ready", + }, + CandidateInstanceRef: &spritzv1.SpritzBindingInstanceRef{ + Namespace: "spritz-production", + Name: "zeno-replacement", + Revision: targetRevision, + Phase: "Provisioning", + }, + }, + } + source := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "zeno-acme", + Namespace: "spritz-production", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: spritzv1.GroupVersion.String(), + Kind: "SpritzBinding", + Name: binding.Name, + }}, + }, + } + s := newInternalSpritzesTestServer(t, source) + if err := s.client.Create(context.Background(), binding); err != nil { + t.Fatalf("failed to create binding: %v", err) + } + e := echo.New() + s.registerRoutes(e) + + body := `{ + "targetRevision": "sha256:rev-2", + "idempotencyKey": "replace-1", + "replacement": { + "principal": {"id": "channel-gateway"}, + "request": { + "presetId": "zeno", + "ownerId": "user-123", + "requestId": "replace-1", + "source": "channel-gateway", + "spec": {} + } + } + }` + req := httptest.NewRequest(http.MethodPost, "/api/internal/v1/spritzes/spritz-production/zeno-acme:replace", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer spritz-internal-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String()) + } + var payload struct { + Data internalReplaceSpritzResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode replace response: %v", err) + } + if payload.Data.Replacement.InstanceID != "zeno-replacement" { + t.Fatalf("expected candidate replacement in response, got %#v", payload.Data) + } + if payload.Data.Replacement.TargetRevision != targetRevision { + t.Fatalf("expected target revision %q, got %#v", targetRevision, payload.Data) + } + if payload.Data.Replayed { + t.Fatalf("expected first binding replace to be non-replayed") + } + + var stored spritzv1.SpritzBinding + if err := s.client.Get(context.Background(), client.ObjectKey{Namespace: binding.Namespace, Name: binding.Name}, &stored); err != nil { + t.Fatalf("failed to reload binding: %v", err) + } + if stored.Spec.DesiredRevision != targetRevision { + t.Fatalf("expected binding desired revision to be updated, got %#v", stored.Spec) + } + if strings.TrimSpace(stored.Annotations[spritzv1.BindingReconcileRequestedAtAnnotationKey]) == "" { + t.Fatalf("expected reconcile annotation to be set, got %#v", stored.Annotations) + } +} + +func TestInternalReplaceSpritzSchedulesBindingReplacementBeforeCandidateExists(t *testing.T) { + targetRevision := "sha256:rev-2" + binding := &spritzv1.SpritzBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "channel-installation-binding-1", + Namespace: "spritz-production", + }, + Spec: spritzv1.SpritzBindingSpec{ + BindingKey: "channel-installation-binding-1", + DesiredRevision: "sha256:rev-1", + Template: spritzv1.SpritzBindingTemplate{ + PresetID: "zeno", + NamePrefix: "zeno", + }, + }, + Status: spritzv1.SpritzBindingStatus{ + ObservedRevision: "sha256:rev-1", + ActiveInstanceRef: &spritzv1.SpritzBindingInstanceRef{ + Namespace: "spritz-production", + Name: "zeno-acme", + Revision: "sha256:rev-1", + Phase: "Ready", + }, + }, + } + source := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "zeno-acme", + Namespace: "spritz-production", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: spritzv1.GroupVersion.String(), + Kind: "SpritzBinding", + Name: binding.Name, + }}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + s := newInternalSpritzesTestServer(t, source) + if err := s.client.Create(context.Background(), binding); err != nil { + t.Fatalf("failed to create binding: %v", err) + } + e := echo.New() + s.registerRoutes(e) + + body := `{ + "targetRevision": "sha256:rev-2", + "idempotencyKey": "replace-1", + "replacement": { + "principal": {"id": "channel-gateway"}, + "request": { + "presetId": "zeno", + "ownerId": "user-123", + "requestId": "replace-1", + "source": "channel-gateway", + "spec": {} + } + } + }` + req := httptest.NewRequest(http.MethodPost, "/api/internal/v1/spritzes/spritz-production/zeno-acme:replace", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer spritz-internal-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String()) + } + var firstPayload struct { + Data internalReplaceSpritzResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &firstPayload); err != nil { + t.Fatalf("failed to decode first replace response: %v", err) + } + if firstPayload.Data.Replacement.InstanceID == "" { + t.Fatalf("expected a predicted replacement name, got %#v", firstPayload.Data) + } + if firstPayload.Data.Replacement.TargetRevision != targetRevision { + t.Fatalf("expected target revision %q, got %#v", targetRevision, firstPayload.Data) + } + if firstPayload.Data.Replacement.Ready { + t.Fatalf("expected predicted replacement to be unready") + } + if firstPayload.Data.Replayed { + t.Fatalf("expected first request to be non-replayed") + } + + replayReq := httptest.NewRequest(http.MethodPost, "/api/internal/v1/spritzes/spritz-production/zeno-acme:replace", strings.NewReader(body)) + replayReq.Header.Set("Authorization", "Bearer spritz-internal-token") + replayReq.Header.Set("Content-Type", "application/json") + replayRec := httptest.NewRecorder() + e.ServeHTTP(replayRec, replayReq) + + if replayRec.Code != http.StatusAccepted { + t.Fatalf("expected replay to stay accepted, got %d: %s", replayRec.Code, replayRec.Body.String()) + } + var replayPayload struct { + Data internalReplaceSpritzResponse `json:"data"` + } + if err := json.Unmarshal(replayRec.Body.Bytes(), &replayPayload); err != nil { + t.Fatalf("failed to decode replay replace response: %v", err) + } + if replayPayload.Data.Replacement.InstanceID != firstPayload.Data.Replacement.InstanceID { + t.Fatalf("expected replay to keep the same replacement identity, got first=%#v replay=%#v", firstPayload.Data, replayPayload.Data) + } + if !replayPayload.Data.Replayed { + t.Fatalf("expected replay request to be marked replayed") + } + + actorID := replaceReservationActorIDForTest("spritz-production", "zeno-acme") + record, found, err := s.idempotencyReservations().get(context.Background(), actorID, "replace-1") + if err != nil { + t.Fatalf("failed to load replace reservation: %v", err) + } + if !found { + t.Fatalf("expected replace reservation to be stored") + } + if !record.completed { + t.Fatalf("expected replace reservation to be completed after the first response") + } + if record.name != firstPayload.Data.Replacement.InstanceID { + t.Fatalf("expected reservation name %q, got %#v", firstPayload.Data.Replacement.InstanceID, record) + } +} + +func TestInternalReplaceSpritzBindingLifecycleRejectsIdempotencyConflicts(t *testing.T) { + binding := &spritzv1.SpritzBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "channel-installation-binding-1", + Namespace: "spritz-production", + }, + Spec: spritzv1.SpritzBindingSpec{ + BindingKey: "channel-installation-binding-1", + DesiredRevision: "sha256:rev-1", + Template: spritzv1.SpritzBindingTemplate{ + PresetID: "zeno", + NamePrefix: "zeno", + }, + }, + Status: spritzv1.SpritzBindingStatus{ + ObservedRevision: "sha256:rev-1", + ActiveInstanceRef: &spritzv1.SpritzBindingInstanceRef{ + Namespace: "spritz-production", + Name: "zeno-acme", + Revision: "sha256:rev-1", + Phase: "Ready", + }, + CandidateInstanceRef: &spritzv1.SpritzBindingInstanceRef{ + Namespace: "spritz-production", + Name: "zeno-replacement", + Revision: "sha256:rev-2", + Phase: "Provisioning", + }, + }, + } + source := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "zeno-acme", + Namespace: "spritz-production", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: spritzv1.GroupVersion.String(), + Kind: "SpritzBinding", + Name: binding.Name, + }}, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + s := newInternalSpritzesTestServer(t, source) + if err := s.client.Create(context.Background(), binding); err != nil { + t.Fatalf("failed to create binding: %v", err) + } + e := echo.New() + s.registerRoutes(e) + + firstBody := `{ + "targetRevision": "sha256:rev-2", + "idempotencyKey": "replace-1", + "replacement": { + "principal": {"id": "channel-gateway"}, + "request": { + "presetId": "zeno", + "ownerId": "user-123", + "requestId": "replace-1", + "source": "channel-gateway", + "spec": {} + } + } + }` + firstReq := httptest.NewRequest(http.MethodPost, "/api/internal/v1/spritzes/spritz-production/zeno-acme:replace", strings.NewReader(firstBody)) + firstReq.Header.Set("Authorization", "Bearer spritz-internal-token") + firstReq.Header.Set("Content-Type", "application/json") + firstRec := httptest.NewRecorder() + e.ServeHTTP(firstRec, firstReq) + if firstRec.Code != http.StatusAccepted { + t.Fatalf("expected first replace to succeed, got %d: %s", firstRec.Code, firstRec.Body.String()) + } + + conflictingBody := `{ + "targetRevision": "sha256:rev-3", + "idempotencyKey": "replace-1", + "replacement": { + "principal": {"id": "channel-gateway"}, + "request": { + "presetId": "zeno", + "ownerId": "user-123", + "requestId": "replace-1-conflict", + "source": "channel-gateway", + "spec": {} + } + } + }` + conflictReq := httptest.NewRequest(http.MethodPost, "/api/internal/v1/spritzes/spritz-production/zeno-acme:replace", strings.NewReader(conflictingBody)) + conflictReq.Header.Set("Authorization", "Bearer spritz-internal-token") + conflictReq.Header.Set("Content-Type", "application/json") + conflictRec := httptest.NewRecorder() + e.ServeHTTP(conflictRec, conflictReq) + + if conflictRec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", conflictRec.Code, conflictRec.Body.String()) + } + if !strings.Contains(conflictRec.Body.String(), "different request") { + t.Fatalf("expected idempotency conflict, got %s", conflictRec.Body.String()) + } + + var stored spritzv1.SpritzBinding + if err := s.client.Get(context.Background(), client.ObjectKey{Namespace: binding.Namespace, Name: binding.Name}, &stored); err != nil { + t.Fatalf("failed to reload binding: %v", err) + } + if stored.Spec.DesiredRevision != "sha256:rev-2" { + t.Fatalf("expected binding to keep the original desired revision, got %#v", stored.Spec) + } +} diff --git a/api/internal_spritzes.go b/api/internal_spritzes.go index ae4fcf1..1aab076 100644 --- a/api/internal_spritzes.go +++ b/api/internal_spritzes.go @@ -242,12 +242,58 @@ func (s *server) replaceInternalSpritz(c echo.Context) error { } return writeError(c, http.StatusInternalServerError, err.Error()) } + sourceSummary := &spritzv1.Spritz{ ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, Name: sourceName, }, } + sourceExists := true + if err := s.client.Get(c.Request().Context(), client.ObjectKey{ + Namespace: namespace, + Name: sourceName, + }, sourceSummary); err != nil { + if apierrors.IsNotFound(err) { + sourceExists = false + } else { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + } + if sourceExists { + binding, err := s.getBindingByRuntime(c.Request().Context(), namespace, sourceSummary) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + if binding != nil { + storedBinding, replayed, err := s.replaceInternalBinding( + c.Request().Context(), + binding, + body.TargetRevision, + ) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + replacement := replacementRuntimeFromBinding(storedBinding, strings.TrimSpace(reservation.name)) + if replacement == nil { + return writeError(c, http.StatusServiceUnavailable, "binding replacement is unavailable") + } + if err := s.completeReplaceReservation( + c.Request().Context(), + namespace, + sourceName, + body.IdempotencyKey, + replaceReservationFingerprint, + replacement.Name, + ); err != nil { + if isProvisionerConflict(err) { + return writeError(c, http.StatusConflict, errIdempotencyUsedDifferent.Error()) + } + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return writeReplaceResponse(c, sourceSummary, replacement, replayed) + } + } if strings.TrimSpace(reservation.name) != "" { existingReplacement, err := s.findReservedReplacementReplayTarget( c.Request().Context(), @@ -285,16 +331,9 @@ func (s *server) replaceInternalSpritz(c echo.Context) error { return writeReplaceResponse(c, sourceSummary, existingReplacement, true) } } - if err := s.client.Get(c.Request().Context(), client.ObjectKey{ - Namespace: namespace, - Name: sourceName, - }, sourceSummary); err != nil { - if apierrors.IsNotFound(err) { - return writeError(c, http.StatusNotFound, "not found") - } - return writeError(c, http.StatusInternalServerError, err.Error()) + if !sourceExists { + return writeError(c, http.StatusNotFound, "not found") } - recorder, err := s.invokeCreateSpritzWithPrincipal( c, replacementPrincipal, diff --git a/api/main.go b/api/main.go index db074f4..d30000a 100644 --- a/api/main.go +++ b/api/main.go @@ -253,6 +253,12 @@ func (s *server) registerRoutes(e *echo.Echo) { if s.internalAuth.enabled { internal.GET("/presets/:presetID", s.getInternalPreset) internal.GET("/runtime-bindings/:namespace/:instanceId", s.getRuntimeBinding) + internal.PUT("/bindings/:bindingKey", s.upsertInternalBinding) + internal.GET("/bindings/:bindingKey", s.getInternalBinding) + internal.POST("/bindings/:bindingKey/reconcile", s.reconcileInternalBinding) + internal.PUT("/bindings/:namespace/:bindingKey", s.upsertInternalBinding) + internal.GET("/bindings/:namespace/:bindingKey", s.getInternalBinding) + internal.POST("/bindings/:namespace/:bindingKey/reconcile", s.reconcileInternalBinding) internal.POST("/spritzes", s.createInternalSpritz) internal.GET("/spritzes/:namespace/:name", s.getInternalSpritz) internal.DELETE("/spritzes/:namespace/:name", s.deleteInternalSpritz) diff --git a/crd/generated/spritz.sh_spritzbindings.yaml b/crd/generated/spritz.sh_spritzbindings.yaml new file mode 100644 index 0000000..dd39517 --- /dev/null +++ b/crd/generated/spritz.sh_spritzbindings.yaml @@ -0,0 +1,751 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: spritzbindings.spritz.sh +spec: + group: spritz.sh + names: + kind: SpritzBinding + listKind: SpritzBindingList + plural: spritzbindings + shortNames: + - sprbind + singular: spritzbinding + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.observedRevision + name: Revision + type: string + - jsonPath: .status.activeInstanceRef.name + name: Active + type: string + - jsonPath: .status.candidateInstanceRef.name + name: Candidate + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: SpritzBinding is the durable logical binding that owns disposable + Spritz runtimes. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + adoptActive: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + adoptedRevision: + type: string + attributes: + additionalProperties: + type: string + type: object + bindingKey: + type: string + desiredRevision: + type: string + disconnected: + type: boolean + observedRequestId: + type: string + template: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + namePrefix: + type: string + presetId: + type: string + requestId: + type: string + source: + type: string + spec: + description: SpritzSpec defines the desired state of Spritz. + properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned + external agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object + annotations: + additionalProperties: + type: string + type: object + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + features: + description: SpritzFeatures toggles optional capabilities. + properties: + ssh: + default: false + type: boolean + web: + 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 + ingress: + description: SpritzIngress configures optional HTTP routing. + properties: + annotations: + additionalProperties: + type: string + type: object + className: + description: ClassName is only used when Mode=ingress. + type: string + gatewayName: + description: GatewayName is required when Mode=gateway. + type: string + gatewayNamespace: + description: GatewayNamespace defaults to the spritz namespace + when empty. + type: string + gatewaySectionName: + description: GatewaySectionName can be used to target + a specific Gateway listener. + type: string + host: + type: string + mode: + enum: + - ingress + - gateway + type: string + path: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + owner: + description: SpritzOwner identifies the creator of a spritz. + properties: + id: + minLength: 1 + type: string + team: + type: string + required: + - id + type: object + ports: + items: + description: SpritzPort exposes a container port via a Service. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + name: + minLength: 1 + type: string + protocol: + description: Protocol defines network protocols supported + for things like container ports. + enum: + - TCP + - UDP + - SCTP + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - containerPort + - name + type: object + type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides + for UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object + repo: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key containing + the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key containing + the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + repos: + items: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key + containing the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key + containing the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + type: array + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + runtimePolicy: + description: SpritzRuntimePolicy stores deployment-resolved + infrastructure policy profile references. + properties: + exposureProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + mountProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + networkProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + revision: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + type: object + x-kubernetes-validations: + - message: runtimePolicy requires networkProfile, mountProfile, + exposureProfile, and revision together + rule: '!has(self.networkProfile) && !has(self.mountProfile) + && !has(self.exposureProfile) && !has(self.revision) || + has(self.networkProfile) && has(self.mountProfile) && + has(self.exposureProfile) && has(self.revision)' + serviceAccountName: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + sharedMounts: + description: SharedMounts configures per-spritz shared directories. + items: + properties: + mode: + type: string + mountPath: + type: string + name: + type: string + pollSeconds: + type: integer + publishSeconds: + type: integer + scope: + type: string + syncMode: + type: string + required: + - mountPath + - name + type: object + type: array + ssh: + description: SpritzSSH configures SSH access behavior. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + enabled: + type: boolean + gatewayNamespace: + type: string + gatewayPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + gatewayService: + type: string + mode: + enum: + - service + - gateway + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + user: + type: string + type: object + ttl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string + required: + - image + - owner + type: object + x-kubernetes-validations: + - message: spec.repo and spec.repos are mutually exclusive + rule: '!(has(self.repo) && has(self.repos) && size(self.repos) + > 0)' + required: + - spec + type: object + required: + - bindingKey + - template + type: object + status: + properties: + activeInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + candidateInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + cleanupInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastErrorCode: + type: string + lastErrorMessage: + type: string + lastReconciledAt: + format: date-time + type: string + nextRuntimeSequence: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + observedRevision: + type: string + phase: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crd/spritz.sh_spritzbindings.yaml b/crd/spritz.sh_spritzbindings.yaml new file mode 100644 index 0000000..dd39517 --- /dev/null +++ b/crd/spritz.sh_spritzbindings.yaml @@ -0,0 +1,751 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: spritzbindings.spritz.sh +spec: + group: spritz.sh + names: + kind: SpritzBinding + listKind: SpritzBindingList + plural: spritzbindings + shortNames: + - sprbind + singular: spritzbinding + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.observedRevision + name: Revision + type: string + - jsonPath: .status.activeInstanceRef.name + name: Active + type: string + - jsonPath: .status.candidateInstanceRef.name + name: Candidate + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: SpritzBinding is the durable logical binding that owns disposable + Spritz runtimes. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + adoptActive: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + adoptedRevision: + type: string + attributes: + additionalProperties: + type: string + type: object + bindingKey: + type: string + desiredRevision: + type: string + disconnected: + type: boolean + observedRequestId: + type: string + template: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + namePrefix: + type: string + presetId: + type: string + requestId: + type: string + source: + type: string + spec: + description: SpritzSpec defines the desired state of Spritz. + properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned + external agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object + annotations: + additionalProperties: + type: string + type: object + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + features: + description: SpritzFeatures toggles optional capabilities. + properties: + ssh: + default: false + type: boolean + web: + 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 + ingress: + description: SpritzIngress configures optional HTTP routing. + properties: + annotations: + additionalProperties: + type: string + type: object + className: + description: ClassName is only used when Mode=ingress. + type: string + gatewayName: + description: GatewayName is required when Mode=gateway. + type: string + gatewayNamespace: + description: GatewayNamespace defaults to the spritz namespace + when empty. + type: string + gatewaySectionName: + description: GatewaySectionName can be used to target + a specific Gateway listener. + type: string + host: + type: string + mode: + enum: + - ingress + - gateway + type: string + path: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + owner: + description: SpritzOwner identifies the creator of a spritz. + properties: + id: + minLength: 1 + type: string + team: + type: string + required: + - id + type: object + ports: + items: + description: SpritzPort exposes a container port via a Service. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + name: + minLength: 1 + type: string + protocol: + description: Protocol defines network protocols supported + for things like container ports. + enum: + - TCP + - UDP + - SCTP + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - containerPort + - name + type: object + type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides + for UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object + repo: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key containing + the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key containing + the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + repos: + items: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key + containing the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key + containing the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + type: array + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + runtimePolicy: + description: SpritzRuntimePolicy stores deployment-resolved + infrastructure policy profile references. + properties: + exposureProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + mountProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + networkProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + revision: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + type: object + x-kubernetes-validations: + - message: runtimePolicy requires networkProfile, mountProfile, + exposureProfile, and revision together + rule: '!has(self.networkProfile) && !has(self.mountProfile) + && !has(self.exposureProfile) && !has(self.revision) || + has(self.networkProfile) && has(self.mountProfile) && + has(self.exposureProfile) && has(self.revision)' + serviceAccountName: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + sharedMounts: + description: SharedMounts configures per-spritz shared directories. + items: + properties: + mode: + type: string + mountPath: + type: string + name: + type: string + pollSeconds: + type: integer + publishSeconds: + type: integer + scope: + type: string + syncMode: + type: string + required: + - mountPath + - name + type: object + type: array + ssh: + description: SpritzSSH configures SSH access behavior. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + enabled: + type: boolean + gatewayNamespace: + type: string + gatewayPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + gatewayService: + type: string + mode: + enum: + - service + - gateway + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + user: + type: string + type: object + ttl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string + required: + - image + - owner + type: object + x-kubernetes-validations: + - message: spec.repo and spec.repos are mutually exclusive + rule: '!(has(self.repo) && has(self.repos) && size(self.repos) + > 0)' + required: + - spec + type: object + required: + - bindingKey + - template + type: object + status: + properties: + activeInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + candidateInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + cleanupInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastErrorCode: + type: string + lastErrorMessage: + type: string + lastReconciledAt: + format: date-time + type: string + nextRuntimeSequence: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + observedRevision: + type: string + phase: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/docs/2026-04-03-spritz-binding-lifecycle-architecture.md b/docs/2026-04-03-spritz-binding-lifecycle-architecture.md new file mode 100644 index 0000000..04dc796 --- /dev/null +++ b/docs/2026-04-03-spritz-binding-lifecycle-architecture.md @@ -0,0 +1,440 @@ +--- +date: 2026-04-03 +author: Onur Solmaz +title: Spritz Binding Lifecycle Architecture +tags: [spritz, binding, lifecycle, control-plane, rollout, architecture] +--- + +## Overview + +Goal in plain English: + +- make Spritz the single owner of long-lived instance lifecycle +- keep `Spritz` instances disposable +- keep shared concierge as just another instance behind the same lifecycle model +- make replacement, cutover, and recovery resumable without external repair + +The current split ownership is fragile: + +- one system decides rollout policy +- another system owns replace idempotency +- Kubernetes owns the actual runtime objects +- retries reconstruct state from partial side effects + +This document proposes one production-ready control-plane model for Spritz: + +- one durable logical resource +- one controller that owns lifecycle end to end +- many disposable `Spritz` instances behind that logical resource + +That is the control-plane shape Spritz should converge toward. + +## Problem + +Today the lifecycle of a long-lived logical agent may span multiple stores and +multiple controllers: + +- external installation or business state +- external rollout tables +- Spritz replace reservations +- live `Spritz` resources +- routing and cutover state outside Spritz + +That split creates several bad failure modes: + +- replace can half-succeed and become hard to resume +- retries can collide with stale idempotency state +- a replacement candidate can exist without a durable operation record +- cutover can happen in one system while cleanup is still owned by another +- operator visibility is fragmented across SQL rows, ConfigMaps, and runtime + objects + +The root issue is that the system does not yet model the durable logical thing +separately from the disposable runtime realization. + +## Design Principles + +### 1. One stable logical object + +The durable thing is the logical agent binding, not any concrete runtime name. + +### 2. Disposable runtime objects + +`Spritz` should remain the concrete workload object: + +- one image +- one revision +- one ACP endpoint +- one lifetime + +A long-lived logical agent may move across many `Spritz` instances over time. + +### 3. One lifecycle owner + +Spritz itself should own: + +- candidate creation +- readiness gating +- cutover +- old-instance cleanup +- retry and resume semantics + +External systems should request desired state, not script lifecycle steps. + +### 4. Durable reconcile state + +Resume should come from one durable operation state owned by Spritz, not from: + +- ad hoc ConfigMap reservations +- backend-specific rollout rows +- inference from whichever runtime currently exists + +### 5. Shared concierge is not special + +A shared concierge should remain just another bound instance. It should not +require a concierge-specific lifecycle engine. + +## Core Model + +### `SpritzBinding` + +Spritz should add a new CRD named `SpritzBinding`. + +`SpritzBinding` is the durable logical resource for any long-lived identity +that must survive replacement of its runtime. + +Examples: + +- one shared workspace concierge +- one durable personal agent binding +- one durable tool-hosted assistant + +Examples that do not need `SpritzBinding` by default: + +- one-off ephemeral development environments +- short-lived throwaway instances created directly by users + +### `Spritz` + +`Spritz` remains the concrete runtime object. + +It should not become the durable lifecycle record. It should stay easy to: + +- create +- probe +- replace +- delete + +### Optional `SpritzOperation` + +`SpritzBinding.status` should be the source of truth for current lifecycle +state. + +If Spritz later needs durable history or audit, it may add an optional +append-only `SpritzOperation` CRD. That resource should be used for history and +debugging only, not for correctness. + +## Why a CRD Is the Right Store + +`SpritzBinding` is control-plane state, so a CRD is an appropriate store. + +This is a good fit because the data is: + +- low-volume +- lifecycle-oriented +- naturally expressed as desired state plus observed state +- reconciled by a controller +- useful to inspect with standard Kubernetes tools + +This is not a proposal to use CRDs as a general application database. + +The boundary should stay clear: + +- CRD: current control-plane state for lifecycle ownership +- external database: business records, analytics, long-term history, tenant + metadata, or product-specific UI data + +## `SpritzBinding` Shape + +Suggested shape: + +```yaml +apiVersion: spritz.sh/v1 +kind: SpritzBinding +metadata: + name: workspace-tenant-123 +spec: + template: + presetId: concierge + owner: + id: user-123 + principal: + id: provider-app + type: service + request: + source: provider-install + spec: {} + desiredRevision: sha256:abcd + rolloutStrategy: + type: create-before-destroy +status: + observedRevision: sha256:1234 + phase: updating + activeInstanceRef: + namespace: agents + name: concierge-spring-river + revision: sha256:1234 + candidateInstanceRef: + namespace: agents + name: concierge-bright-hill + revision: sha256:abcd + conditions: + - type: Ready + status: "False" + reason: CandidateNotReady + - type: Progressing + status: "True" + reason: WaitingCandidateReady +``` + +Key rules: + +- `spec` is the desired logical binding +- `status` is the current observed lifecycle state +- exactly one active instance may be live at a time +- at most one candidate instance may be in progress at a time + +## Reconciliation Contract + +The `SpritzBinding` controller should own the full lifecycle. + +### Initial creation + +1. observe a binding with no active instance +2. create a `Spritz` instance from `spec.template` +3. wait until the runtime satisfies the base ready contract +4. publish it as `activeInstanceRef` +5. mark the binding `Ready` + +### Revision rollout + +When `spec.desiredRevision` differs from `status.observedRevision`: + +1. create a candidate `Spritz` instance for the desired revision +2. persist that candidate reference in `status` +3. wait for candidate readiness +4. cut over the binding to the candidate +5. delete the old active runtime +6. set `observedRevision = desiredRevision` +7. clear candidate state +8. mark the binding `Ready` + +### Recovery + +If the active runtime disappears unexpectedly: + +1. keep the binding as the durable owner +2. create a new runtime for the currently desired revision +3. republish the new active reference +4. do not require external repair to recover + +## Explicit Lifecycle Phases + +The controller should expose explicit phases in `status.phase`, for example: + +- `pending` +- `creating` +- `waiting_ready` +- `cutting_over` +- `cleaning_up` +- `ready` +- `degraded` +- `failed` + +The controller should also publish conditions such as: + +- `Ready` +- `Progressing` +- `CandidateReady` +- `CutoverReady` +- `CleanupBlocked` +- `Failed` + +The phase machine must be resumable. A controller restart must not require +reconstructing state from side effects. + +## Readiness Contract + +The controller should gate cutover only on the base runtime readiness contract. + +That contract should mean: + +- the `Spritz` object exists +- the runtime reached the normal usable phase +- ACP, if required by the preset, is ready +- the live endpoint or binding is routable + +Secondary work should not silently extend the create path forever. + +Examples of secondary work: + +- profile synchronization +- shared mount hydration +- convenience bootstrap jobs + +Those should reconcile independently and report their own status. They should +not make replacement unrecoverable. + +## Routing and Live Resolution + +External systems should resolve the logical binding, not the raw runtime name. + +That means: + +- stable routing should target `SpritzBinding` +- `SpritzBinding.status.activeInstanceRef` is the authoritative live runtime +- callers should not treat a previous runtime name as durable truth + +This avoids the current drift where different systems hold different ideas of +which instance is live. + +## Idempotency and Retry Semantics + +The current replace primitive should not remain the primary source of lifecycle +truth. + +Instead: + +- the durable operation state lives on `SpritzBinding.status` +- retries reconcile against current `status` +- candidate names and operation phases are resumed, not rediscovered + +Idempotency should mean: + +- replay returns current operation state +- replay never requires manual reservation cleanup +- replay never poisons future progress when no valid replacement was created + +The existing `:replace` API may remain as a compatibility surface, but it +should become a thin shim that drives binding-owned lifecycle rather than +storing its own independent truth. + +## Replace API Compatibility + +Spritz should keep the current internal replace primitive during migration: + +```text +POST /api/internal/v1/spritzes/{namespace}/{instanceId}:replace +``` + +But its implementation should change: + +- resolve the owning `SpritzBinding` +- update desired revision and operation intent on the binding +- return the binding's current source and candidate state + +That keeps compatibility for callers while moving ownership into the Spritz +control plane. + +## Naming and Candidate Selection + +Candidate runtime naming should be deterministic enough to resume safely. + +Recommended approach: + +- binding controller allocates one candidate name per operation attempt +- that name is persisted in binding status immediately +- the controller never "forgets" the candidate it already chose + +This avoids the current pattern where retries rediscover names via external +idempotency records. + +## Failure Handling + +### Candidate creation fails + +- keep the active instance serving +- mark `Progressing=True` +- set a typed failure reason in status +- retry reconcile without losing the candidate decision boundary + +### Candidate becomes terminal before cutover + +- leave active instance unchanged +- clear only the invalid candidate state +- create a new candidate on the next reconcile + +### Cutover succeeds but old cleanup fails + +- keep the binding pointed at the new active instance +- mark cleanup as pending or blocked +- retry old-instance deletion independently + +### Controller restarts mid-operation + +- resume from `SpritzBinding.status` +- do not depend on external rollout tables +- do not require manual state repair for normal crash recovery + +## Migration Plan + +### Phase 1: introduce binding CRD and controller + +- add `SpritzBinding` +- support create and reconcile for a binding-owned runtime +- keep direct `Spritz` creation unchanged for ephemeral use cases + +### Phase 2: make shared channel concierge use bindings + +- the external installation record stores binding identity, not runtime name +- shared concierge lifecycle resolves through `SpritzBinding` +- replacement and recovery become controller-owned + +### Phase 3: route compatibility APIs through binding state + +- keep `:replace` as a compatibility shim +- stop treating replace reservations as the durable lifecycle record + +### Phase 4: remove split ownership + +- external rollout systems stop owning candidate creation, cutover, and cleanup +- backend or deployment systems request desired revision only + +## Why This Is the Elegant End State + +This model keeps the boundaries clean: + +- `SpritzBinding` is the stable logical thing +- `Spritz` is the disposable runtime thing +- the controller is the lifecycle owner +- external systems ask for desired state and observe status + +It also keeps shared concierge generic: + +- not a special top-level resource +- not a custom lifecycle engine +- just another binding whose runtime happens to use a concierge preset + +That is the production-ready control-plane shape Spritz should aim for. + +## Validation + +The architecture is successful when all of the following are true: + +1. a binding can roll from revision A to revision B without external orchestration +2. a controller restart during rollout resumes cleanly from binding status +3. cutover never happens before the candidate satisfies the base ready contract +4. failure in shared-mount or profile sync does not poison replacement identity +5. a shared concierge is recoverable without manual cleanup of reservation state +6. external systems no longer need to persist active runtime identity as + authoritative truth + +## Related Docs + +- `docs/2026-03-31-instance-replacement-rollout-architecture.md` +- `docs/2026-03-31-shared-channel-concierge-lifecycle-architecture.md` + +This document supersedes the split-ownership direction from the earlier +instance replacement and concierge rollout design. The generic replace +primitive may remain as a compatibility API, but long-lived lifecycle should be +owned by `SpritzBinding`. diff --git a/helm/spritz/crds/spritz.sh_spritzbindings.yaml b/helm/spritz/crds/spritz.sh_spritzbindings.yaml new file mode 100644 index 0000000..dd39517 --- /dev/null +++ b/helm/spritz/crds/spritz.sh_spritzbindings.yaml @@ -0,0 +1,751 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: spritzbindings.spritz.sh +spec: + group: spritz.sh + names: + kind: SpritzBinding + listKind: SpritzBindingList + plural: spritzbindings + shortNames: + - sprbind + singular: spritzbinding + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.observedRevision + name: Revision + type: string + - jsonPath: .status.activeInstanceRef.name + name: Active + type: string + - jsonPath: .status.candidateInstanceRef.name + name: Candidate + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: SpritzBinding is the durable logical binding that owns disposable + Spritz runtimes. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + adoptActive: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + adoptedRevision: + type: string + attributes: + additionalProperties: + type: string + type: object + bindingKey: + type: string + desiredRevision: + type: string + disconnected: + type: boolean + observedRequestId: + type: string + template: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + namePrefix: + type: string + presetId: + type: string + requestId: + type: string + source: + type: string + spec: + description: SpritzSpec defines the desired state of Spritz. + properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned + external agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object + annotations: + additionalProperties: + type: string + type: object + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + features: + description: SpritzFeatures toggles optional capabilities. + properties: + ssh: + default: false + type: boolean + web: + 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 + ingress: + description: SpritzIngress configures optional HTTP routing. + properties: + annotations: + additionalProperties: + type: string + type: object + className: + description: ClassName is only used when Mode=ingress. + type: string + gatewayName: + description: GatewayName is required when Mode=gateway. + type: string + gatewayNamespace: + description: GatewayNamespace defaults to the spritz namespace + when empty. + type: string + gatewaySectionName: + description: GatewaySectionName can be used to target + a specific Gateway listener. + type: string + host: + type: string + mode: + enum: + - ingress + - gateway + type: string + path: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + owner: + description: SpritzOwner identifies the creator of a spritz. + properties: + id: + minLength: 1 + type: string + team: + type: string + required: + - id + type: object + ports: + items: + description: SpritzPort exposes a container port via a Service. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + name: + minLength: 1 + type: string + protocol: + description: Protocol defines network protocols supported + for things like container ports. + enum: + - TCP + - UDP + - SCTP + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - containerPort + - name + type: object + type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides + for UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object + repo: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key containing + the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key containing + the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + repos: + items: + description: SpritzRepo describes the repository to clone + inside the workload. + properties: + auth: + description: SpritzRepoAuth describes how to authenticate + git clone operations. + properties: + netrcKey: + description: NetrcKey points to a Secret key containing + a full .netrc file. + type: string + passwordKey: + description: PasswordKey points to a Secret key + containing the password/token to use. + type: string + secretName: + minLength: 1 + type: string + usernameKey: + description: UsernameKey points to a Secret key + containing the username to use. + type: string + required: + - secretName + type: object + branch: + type: string + depth: + minimum: 1 + type: integer + dir: + type: string + revision: + type: string + submodules: + type: boolean + url: + format: uri + type: string + required: + - url + type: object + type: array + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + runtimePolicy: + description: SpritzRuntimePolicy stores deployment-resolved + infrastructure policy profile references. + properties: + exposureProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + mountProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + networkProfile: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + revision: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + type: object + x-kubernetes-validations: + - message: runtimePolicy requires networkProfile, mountProfile, + exposureProfile, and revision together + rule: '!has(self.networkProfile) && !has(self.mountProfile) + && !has(self.exposureProfile) && !has(self.revision) || + has(self.networkProfile) && has(self.mountProfile) && + has(self.exposureProfile) && has(self.revision)' + serviceAccountName: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + sharedMounts: + description: SharedMounts configures per-spritz shared directories. + items: + properties: + mode: + type: string + mountPath: + type: string + name: + type: string + pollSeconds: + type: integer + publishSeconds: + type: integer + scope: + type: string + syncMode: + type: string + required: + - mountPath + - name + type: object + type: array + ssh: + description: SpritzSSH configures SSH access behavior. + properties: + containerPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + enabled: + type: boolean + gatewayNamespace: + type: string + gatewayPort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + gatewayService: + type: string + mode: + enum: + - service + - gateway + type: string + servicePort: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + user: + type: string + type: object + ttl: + pattern: ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ + type: string + required: + - image + - owner + type: object + x-kubernetes-validations: + - message: spec.repo and spec.repos are mutually exclusive + rule: '!(has(self.repo) && has(self.repos) && size(self.repos) + > 0)' + required: + - spec + type: object + required: + - bindingKey + - template + type: object + status: + properties: + activeInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + candidateInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + cleanupInstanceRef: + properties: + name: + type: string + namespace: + type: string + phase: + type: string + revision: + type: string + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastErrorCode: + type: string + lastErrorMessage: + type: string + lastReconciledAt: + format: date-time + type: string + nextRuntimeSequence: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + observedRevision: + type: string + phase: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/operator/api/v1/spritz_binding_naming.go b/operator/api/v1/spritz_binding_naming.go new file mode 100644 index 0000000..51c98e2 --- /dev/null +++ b/operator/api/v1/spritz_binding_naming.go @@ -0,0 +1,65 @@ +package v1 + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +// BindingRuntimeNameForSequence returns the deterministic runtime name for one +// binding-owned candidate sequence. +func BindingRuntimeNameForSequence( + bindingKey string, + namePrefix string, + presetID string, + sequence int64, +) string { + prefix := bindingRuntimePrefix(bindingKey, namePrefix, presetID) + base := fmt.Sprintf("%s-%02d", prefix, sequence) + if len(base) <= 63 { + return base + } + return base[:63] +} + +func bindingRuntimePrefix(bindingKey string, namePrefix string, presetID string) string { + prefix := sanitizeBindingNameToken(namePrefix) + if prefix == "" { + prefix = sanitizeBindingNameToken(presetID) + } + if prefix == "" { + prefix = "spritz" + } + sum := sha256.Sum256([]byte(strings.TrimSpace(bindingKey))) + base := fmt.Sprintf("%s-%x", prefix, sum[:6]) + if len(base) <= 56 { + return base + } + return base[:56] +} + +func sanitizeBindingNameToken(value string) string { + raw := strings.ToLower(strings.TrimSpace(value)) + if raw == "" { + return "" + } + var out strings.Builder + lastDash := false + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z': + out.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + out.WriteRune(r) + lastDash = false + default: + if out.Len() == 0 || lastDash { + continue + } + out.WriteByte('-') + lastDash = true + } + } + return strings.Trim(out.String(), "-") +} diff --git a/operator/api/v1/spritz_binding_types.go b/operator/api/v1/spritz_binding_types.go new file mode 100644 index 0000000..a9b1cff --- /dev/null +++ b/operator/api/v1/spritz_binding_types.go @@ -0,0 +1,221 @@ +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + // BindingKeyAnnotationKey stores the canonical external binding key on a runtime. + BindingKeyAnnotationKey = "spritz.sh/binding-key" + // BindingNameLabelKey links a runtime back to its owning SpritzBinding resource. + BindingNameLabelKey = "spritz.sh/binding-name" + // BindingInstanceRoleAnnotationKey marks whether a runtime is active or candidate. + BindingInstanceRoleAnnotationKey = "spritz.sh/binding-instance-role" + // BindingReconcileRequestedAtAnnotationKey nudges the binding controller to reconcile now. + BindingReconcileRequestedAtAnnotationKey = "spritz.sh/binding-reconcile-requested-at" +) + +const ( + BindingPhasePending = "pending" + BindingPhaseCreating = "creating" + BindingPhaseWaitingReady = "waiting_ready" + BindingPhaseCuttingOver = "cutting_over" + BindingPhaseCleaningUp = "cleaning_up" + BindingPhaseReady = "ready" + BindingPhaseFailed = "failed" +) + +type SpritzBindingTemplate struct { + PresetID string `json:"presetId,omitempty"` + NamePrefix string `json:"namePrefix,omitempty"` + Source string `json:"source,omitempty"` + RequestID string `json:"requestId,omitempty"` + Spec SpritzSpec `json:"spec"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type SpritzBindingInstanceRef struct { + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + Revision string `json:"revision,omitempty"` + Phase string `json:"phase,omitempty"` +} + +type SpritzBindingSpec struct { + BindingKey string `json:"bindingKey"` + DesiredRevision string `json:"desiredRevision,omitempty"` + Disconnected bool `json:"disconnected,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Template SpritzBindingTemplate `json:"template"` + AdoptActive *SpritzBindingInstanceRef `json:"adoptActive,omitempty"` + AdoptedRevision string `json:"adoptedRevision,omitempty"` + ObservedRequestID string `json:"observedRequestId,omitempty"` +} + +type SpritzBindingStatus struct { + Phase string `json:"phase,omitempty"` + ObservedRevision string `json:"observedRevision,omitempty"` + ActiveInstanceRef *SpritzBindingInstanceRef `json:"activeInstanceRef,omitempty"` + CandidateInstanceRef *SpritzBindingInstanceRef `json:"candidateInstanceRef,omitempty"` + CleanupInstanceRef *SpritzBindingInstanceRef `json:"cleanupInstanceRef,omitempty"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + LastErrorMessage string `json:"lastErrorMessage,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + NextRuntimeSequence int64 `json:"nextRuntimeSequence,omitempty"` + LastReconciledAt *metav1.Time `json:"lastReconciledAt,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=sprbind +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=".status.phase" +// +kubebuilder:printcolumn:name="Revision",type=string,JSONPath=".status.observedRevision" +// +kubebuilder:printcolumn:name="Active",type=string,JSONPath=".status.activeInstanceRef.name" +// +kubebuilder:printcolumn:name="Candidate",type=string,JSONPath=".status.candidateInstanceRef.name" +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" +// SpritzBinding is the durable logical binding that owns disposable Spritz runtimes. +type SpritzBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SpritzBindingSpec `json:"spec"` + Status SpritzBindingStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// SpritzBindingList contains a list of SpritzBinding objects. +type SpritzBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []SpritzBinding `json:"items"` +} + +func init() { + SchemeBuilder.Register(&SpritzBinding{}, &SpritzBindingList{}) +} + +func (in *SpritzBindingTemplate) DeepCopyInto(out *SpritzBindingTemplate) { + *out = *in + in.Spec.DeepCopyInto(&out.Spec) + if in.Labels != nil { + out.Labels = make(map[string]string, len(in.Labels)) + for k, v := range in.Labels { + out.Labels[k] = v + } + } + if in.Annotations != nil { + out.Annotations = make(map[string]string, len(in.Annotations)) + for k, v := range in.Annotations { + out.Annotations[k] = v + } + } +} + +func (in *SpritzBindingInstanceRef) DeepCopyInto(out *SpritzBindingInstanceRef) { + *out = *in +} + +func (in *SpritzBindingInstanceRef) DeepCopy() *SpritzBindingInstanceRef { + if in == nil { + return nil + } + out := new(SpritzBindingInstanceRef) + in.DeepCopyInto(out) + return out +} + +func (in *SpritzBindingSpec) DeepCopyInto(out *SpritzBindingSpec) { + *out = *in + if in.Attributes != nil { + out.Attributes = make(map[string]string, len(in.Attributes)) + for k, v := range in.Attributes { + out.Attributes[k] = v + } + } + in.Template.DeepCopyInto(&out.Template) + if in.AdoptActive != nil { + out.AdoptActive = in.AdoptActive.DeepCopy() + } +} + +func (in *SpritzBindingStatus) DeepCopyInto(out *SpritzBindingStatus) { + *out = *in + if in.ActiveInstanceRef != nil { + out.ActiveInstanceRef = in.ActiveInstanceRef.DeepCopy() + } + if in.CandidateInstanceRef != nil { + out.CandidateInstanceRef = in.CandidateInstanceRef.DeepCopy() + } + if in.CleanupInstanceRef != nil { + out.CleanupInstanceRef = in.CleanupInstanceRef.DeepCopy() + } + if in.LastReconciledAt != nil { + timestamp := in.LastReconciledAt.DeepCopy() + out.LastReconciledAt = timestamp + } + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + for i := range in.Conditions { + in.Conditions[i].DeepCopyInto(&out.Conditions[i]) + } + } +} + +func (in *SpritzBinding) DeepCopyInto(out *SpritzBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +func (in *SpritzBinding) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := new(SpritzBinding) + in.DeepCopyInto(out) + return out +} + +func (in *SpritzBinding) DeepCopy() *SpritzBinding { + if in == nil { + return nil + } + out := new(SpritzBinding) + in.DeepCopyInto(out) + return out +} + +func (in *SpritzBindingList) DeepCopyInto(out *SpritzBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]SpritzBinding, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +func (in *SpritzBindingList) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := new(SpritzBindingList) + in.DeepCopyInto(out) + return out +} + +func (in *SpritzBindingList) DeepCopy() *SpritzBindingList { + if in == nil { + return nil + } + out := new(SpritzBindingList) + in.DeepCopyInto(out) + return out +} diff --git a/operator/controllers/spritz_binding_controller.go b/operator/controllers/spritz_binding_controller.go new file mode 100644 index 0000000..993f649 --- /dev/null +++ b/operator/controllers/spritz_binding_controller.go @@ -0,0 +1,675 @@ +package controllers + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + spritzv1 "spritz.sh/operator/api/v1" +) + +const spritzBindingFinalizer = "spritz.sh/binding-finalizer" +const ( + bindingPresetIDAnnotationKey = "spritz.sh/preset-id" + bindingTargetRevisionAnnotationKey = "spritz.sh/target-revision" +) + +type bindingIngressDefaults struct { + Mode string + HostTemplate string + Path string + ClassName string + GatewayName string + GatewayNamespace string + GatewaySectionName string +} + +func NewBindingIngressDefaultsFromEnv() bindingIngressDefaults { + return bindingIngressDefaults{ + Mode: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_MODE")), + HostTemplate: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_HOST_TEMPLATE")), + Path: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_PATH")), + ClassName: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_CLASS_NAME")), + GatewayName: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_GATEWAY_NAME")), + GatewayNamespace: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_GATEWAY_NAMESPACE")), + GatewaySectionName: strings.TrimSpace(os.Getenv("SPRITZ_DEFAULT_INGRESS_GATEWAY_SECTION_NAME")), + } +} + +func (d bindingIngressDefaults) enabled() bool { + return d.Mode != "" || d.HostTemplate != "" || d.Path != "" || d.ClassName != "" || + d.GatewayName != "" || d.GatewayNamespace != "" || d.GatewaySectionName != "" +} + +type SpritzBindingReconciler struct { + client.Client + Scheme *runtime.Scheme + IngressDefaults bindingIngressDefaults +} + +type bindingRequeueError struct { + cause error +} + +func (e *bindingRequeueError) Error() string { + if e == nil || e.cause == nil { + return "binding reconcile retry requested" + } + return e.cause.Error() +} + +func (r *SpritzBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + logger := log.FromContext(ctx) + + var binding spritzv1.SpritzBinding + if err := r.Get(ctx, req.NamespacedName, &binding); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if done, err := r.reconcileLifecycle(ctx, &binding); done || err != nil { + return ctrl.Result{}, err + } + + if err := r.reconcileBinding(ctx, &binding); err != nil { + var retryErr *bindingRequeueError + if errors.As(err, &retryErr) { + logger.Info("binding reconcile will retry", "error", retryErr.Error()) + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil + } + logger.Error(err, "failed to reconcile spritz binding") + return ctrl.Result{RequeueAfter: 2 * time.Second}, err + } + + return ctrl.Result{}, nil +} + +func (r *SpritzBindingReconciler) reconcileLifecycle(ctx context.Context, binding *spritzv1.SpritzBinding) (bool, error) { + if !binding.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(binding, spritzBindingFinalizer) { + controllerutil.RemoveFinalizer(binding, spritzBindingFinalizer) + if err := r.Update(ctx, binding); err != nil { + return true, err + } + } + return true, nil + } + if controllerutil.ContainsFinalizer(binding, spritzBindingFinalizer) { + return false, nil + } + controllerutil.AddFinalizer(binding, spritzBindingFinalizer) + if err := r.Update(ctx, binding); err != nil { + return true, err + } + return true, nil +} + +func (r *SpritzBindingReconciler) reconcileBinding(ctx context.Context, binding *spritzv1.SpritzBinding) error { + now := metav1.Now() + + if updated, err := r.adoptExistingRuntime(ctx, binding, &now); err != nil { + return err + } else if updated { + return nil + } + + active, activeExists, err := r.resolveRuntimeRef(ctx, binding, binding.Status.ActiveInstanceRef) + if err != nil { + return err + } + candidate, candidateExists, err := r.resolveRuntimeRef(ctx, binding, binding.Status.CandidateInstanceRef) + if err != nil { + return err + } + cleanup, cleanupExists, err := r.resolveRuntimeRef(ctx, binding, binding.Status.CleanupInstanceRef) + if err != nil { + return err + } + + if cleanupExists { + if binding.Status.CleanupInstanceRef != nil { + binding.Status.CleanupInstanceRef.Phase = strings.TrimSpace(cleanup.Status.Phase) + } + if err := r.deleteRuntimeIfPresent(ctx, cleanup); err != nil { + return r.updateFailureStatusAndRetry( + ctx, + binding, + &now, + spritzv1.BindingPhaseCleaningUp, + "cleanup_failed", + err, + ) + } + } else if binding.Status.CleanupInstanceRef != nil { + binding.Status.CleanupInstanceRef = nil + } + + if activeExists && binding.Status.ActiveInstanceRef != nil { + binding.Status.ActiveInstanceRef.Phase = strings.TrimSpace(active.Status.Phase) + } + if candidateExists && binding.Status.CandidateInstanceRef != nil { + binding.Status.CandidateInstanceRef.Phase = strings.TrimSpace(candidate.Status.Phase) + } + + if binding.Status.ActiveInstanceRef != nil && !activeExists { + binding.Status.ActiveInstanceRef = nil + active = nil + } + if binding.Status.CandidateInstanceRef != nil && !candidateExists { + binding.Status.CandidateInstanceRef = nil + candidate = nil + } + + if binding.Status.ActiveInstanceRef != nil && active != nil && !runtimeIsUsable(active) { + binding.Status.CleanupInstanceRef = cleanupRefFromRuntime( + binding.Status.ActiveInstanceRef, + active, + ) + binding.Status.ActiveInstanceRef = nil + active = nil + } + if binding.Status.CandidateInstanceRef != nil && candidate != nil && runtimeIsTerminal(candidate) { + binding.Status.CleanupInstanceRef = cleanupRefFromRuntime( + binding.Status.CandidateInstanceRef, + candidate, + ) + binding.Status.CandidateInstanceRef = nil + candidate = nil + } + + desiredRevision := strings.TrimSpace(binding.Spec.DesiredRevision) + + if binding.Status.ActiveInstanceRef == nil { + if binding.Status.CandidateInstanceRef == nil { + nextRef, err := r.ensureCandidateRuntime(ctx, binding, desiredRevision) + if err != nil { + return r.updateFailureStatusAndRetry( + ctx, + binding, + &now, + spritzv1.BindingPhaseFailed, + "candidate_create_failed", + err, + ) + } + binding.Status.CandidateInstanceRef = nextRef + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseCreating, "candidate_creating", "creating initial runtime") + return r.updateBindingStatus(ctx, binding) + } + if candidate != nil && runtimeIsReady(candidate) { + binding.Status.ActiveInstanceRef = binding.Status.CandidateInstanceRef.DeepCopy() + binding.Status.CandidateInstanceRef = nil + binding.Status.ObservedRevision = resolveInstanceRevision(binding.Status.ActiveInstanceRef, candidate, desiredRevision) + r.setReadyStatus(binding, &now, spritzv1.BindingPhaseReady) + return r.updateBindingStatus(ctx, binding) + } + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseWaitingReady, "candidate_not_ready", "waiting for initial candidate to become ready") + return r.updateBindingStatus(ctx, binding) + } + + if desiredRevision != "" && strings.TrimSpace(binding.Status.ObservedRevision) != desiredRevision { + if binding.Status.CandidateInstanceRef == nil { + nextRef, err := r.ensureCandidateRuntime(ctx, binding, desiredRevision) + if err != nil { + return r.updateFailureStatusAndRetry( + ctx, + binding, + &now, + spritzv1.BindingPhaseFailed, + "candidate_create_failed", + err, + ) + } + binding.Status.CandidateInstanceRef = nextRef + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseCreating, "candidate_creating", "creating replacement runtime") + return r.updateBindingStatus(ctx, binding) + } + if candidate != nil && runtimeIsReady(candidate) { + previousActiveRef := binding.Status.ActiveInstanceRef.DeepCopy() + binding.Status.ActiveInstanceRef = binding.Status.CandidateInstanceRef.DeepCopy() + binding.Status.CandidateInstanceRef = nil + binding.Status.CleanupInstanceRef = previousActiveRef + binding.Status.ObservedRevision = resolveInstanceRevision(binding.Status.ActiveInstanceRef, candidate, desiredRevision) + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseCleaningUp, "cleanup_pending", "cleaning up replaced runtime") + return r.updateBindingStatus(ctx, binding) + } + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseWaitingReady, "candidate_not_ready", "waiting for replacement runtime to become ready") + return r.updateBindingStatus(ctx, binding) + } + + if binding.Status.CleanupInstanceRef != nil { + r.setProgressingStatus(binding, &now, spritzv1.BindingPhaseCleaningUp, "cleanup_pending", "cleaning up replaced runtime") + return r.updateBindingStatus(ctx, binding) + } + + r.setReadyStatus(binding, &now, spritzv1.BindingPhaseReady) + if active != nil && binding.Status.ActiveInstanceRef != nil { + binding.Status.ObservedRevision = resolveInstanceRevision(binding.Status.ActiveInstanceRef, active, desiredRevision) + } + return r.updateBindingStatus(ctx, binding) +} + +func (r *SpritzBindingReconciler) adoptExistingRuntime( + ctx context.Context, + binding *spritzv1.SpritzBinding, + now *metav1.Time, +) (bool, error) { + if binding.Status.ActiveInstanceRef != nil || binding.Spec.AdoptActive == nil { + return false, nil + } + ref := binding.Spec.AdoptActive.DeepCopy() + if strings.TrimSpace(ref.Namespace) == "" { + ref.Namespace = binding.Namespace + } + var spritz spritzv1.Spritz + if err := r.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, &spritz); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + if err := r.attachBindingOwnership(ctx, binding, &spritz); err != nil { + return false, err + } + ref.Revision = resolveInstanceRevision(ref, &spritz, strings.TrimSpace(binding.Spec.AdoptedRevision)) + ref.Phase = strings.TrimSpace(spritz.Status.Phase) + binding.Status.ActiveInstanceRef = ref + binding.Status.CandidateInstanceRef = nil + binding.Status.CleanupInstanceRef = nil + binding.Status.ObservedRevision = ref.Revision + r.setReadyStatus(binding, now, spritzv1.BindingPhaseReady) + return true, r.updateBindingStatus(ctx, binding) +} + +func (r *SpritzBindingReconciler) ensureCandidateRuntime( + ctx context.Context, + binding *spritzv1.SpritzBinding, + desiredRevision string, +) (*spritzv1.SpritzBindingInstanceRef, error) { + sequence := binding.Status.NextRuntimeSequence + 1 + name := bindingRuntimeName(binding, sequence) + spritz, err := r.buildRuntimeFromBinding(binding, name, desiredRevision, "candidate") + if err != nil { + return nil, err + } + if err := r.Create(ctx, spritz); err != nil { + if !apierrors.IsAlreadyExists(err) { + return nil, err + } + var existing spritzv1.Spritz + if getErr := r.Get(ctx, client.ObjectKey{Namespace: spritz.Namespace, Name: spritz.Name}, &existing); getErr != nil { + return nil, getErr + } + if err := r.attachBindingOwnership(ctx, binding, &existing); err != nil { + return nil, err + } + spritz = &existing + } + binding.Status.NextRuntimeSequence = sequence + return &spritzv1.SpritzBindingInstanceRef{ + Namespace: spritz.Namespace, + Name: spritz.Name, + Revision: resolveInstanceRevision(nil, spritz, desiredRevision), + Phase: strings.TrimSpace(spritz.Status.Phase), + }, nil +} + +func (r *SpritzBindingReconciler) buildRuntimeFromBinding( + binding *spritzv1.SpritzBinding, + name string, + desiredRevision string, + role string, +) (*spritzv1.Spritz, error) { + var spec spritzv1.SpritzSpec + binding.Spec.Template.Spec.DeepCopyInto(&spec) + applyBindingIngressDefaults(&spec, name, binding.Namespace, r.IngressDefaults) + if spec.Ingress != nil && strings.EqualFold(spec.Ingress.Mode, "gateway") && strings.TrimSpace(spec.Ingress.Host) == "" { + return nil, fmt.Errorf("spec.ingress.host is required when spec.ingress.mode=gateway") + } + if spec.Ingress != nil && strings.EqualFold(spec.Ingress.Mode, "gateway") && strings.TrimSpace(spec.Ingress.GatewayName) == "" { + return nil, fmt.Errorf("spec.ingress.gatewayName is required when spec.ingress.mode=gateway") + } + + labels := cloneStringMap(binding.Spec.Template.Labels) + if labels == nil { + labels = map[string]string{} + } + labels["spritz.sh/name"] = name + labels[spritzv1.BindingNameLabelKey] = binding.Name + + annotations := cloneStringMap(binding.Spec.Template.Annotations) + if annotations == nil { + annotations = map[string]string{} + } + if strings.TrimSpace(binding.Spec.Template.PresetID) != "" { + annotations[bindingPresetIDAnnotationKey] = strings.TrimSpace(binding.Spec.Template.PresetID) + } + if strings.TrimSpace(desiredRevision) != "" { + annotations[bindingTargetRevisionAnnotationKey] = strings.TrimSpace(desiredRevision) + } + annotations[spritzv1.BindingKeyAnnotationKey] = strings.TrimSpace(binding.Spec.BindingKey) + annotations[spritzv1.BindingInstanceRoleAnnotationKey] = strings.TrimSpace(role) + + spritz := &spritzv1.Spritz{ + TypeMeta: metav1.TypeMeta{Kind: "Spritz", APIVersion: spritzv1.GroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: binding.Namespace, + Labels: labels, + Annotations: annotations, + }, + Spec: spec, + } + if err := controllerutil.SetControllerReference(binding, spritz, r.Scheme); err != nil { + return nil, err + } + return spritz, nil +} + +func (r *SpritzBindingReconciler) resolveRuntimeRef( + ctx context.Context, + binding *spritzv1.SpritzBinding, + ref *spritzv1.SpritzBindingInstanceRef, +) (*spritzv1.Spritz, bool, error) { + if ref == nil { + return nil, false, nil + } + namespace := strings.TrimSpace(ref.Namespace) + if namespace == "" { + namespace = binding.Namespace + } + name := strings.TrimSpace(ref.Name) + if name == "" { + return nil, false, nil + } + var spritz spritzv1.Spritz + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &spritz); err != nil { + if apierrors.IsNotFound(err) { + return nil, false, nil + } + return nil, false, err + } + return &spritz, true, nil +} + +func (r *SpritzBindingReconciler) deleteRuntimeIfPresent(ctx context.Context, spritz *spritzv1.Spritz) error { + if spritz == nil { + return nil + } + if err := r.Delete(ctx, spritz); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} + +func (r *SpritzBindingReconciler) updateFailureStatusAndRetry( + ctx context.Context, + binding *spritzv1.SpritzBinding, + now *metav1.Time, + phase string, + code string, + cause error, +) error { + r.setFailureStatus(binding, now, phase, code, cause.Error()) + if err := r.updateBindingStatus(ctx, binding); err != nil { + return err + } + return &bindingRequeueError{cause: cause} +} + +func cleanupRefFromRuntime( + ref *spritzv1.SpritzBindingInstanceRef, + spritz *spritzv1.Spritz, +) *spritzv1.SpritzBindingInstanceRef { + if ref == nil { + return nil + } + cleanup := ref.DeepCopy() + if spritz != nil { + cleanup.Phase = strings.TrimSpace(spritz.Status.Phase) + } + return cleanup +} + +func (r *SpritzBindingReconciler) attachBindingOwnership( + ctx context.Context, + binding *spritzv1.SpritzBinding, + spritz *spritzv1.Spritz, +) error { + if spritz == nil { + return nil + } + updated := false + if spritz.Labels == nil { + spritz.Labels = map[string]string{} + } + if spritz.Annotations == nil { + spritz.Annotations = map[string]string{} + } + if !metav1.IsControlledBy(spritz, binding) { + updated = true + } + if spritz.Labels[spritzv1.BindingNameLabelKey] != binding.Name { + spritz.Labels[spritzv1.BindingNameLabelKey] = binding.Name + updated = true + } + if spritz.Annotations[spritzv1.BindingKeyAnnotationKey] != strings.TrimSpace(binding.Spec.BindingKey) { + spritz.Annotations[spritzv1.BindingKeyAnnotationKey] = strings.TrimSpace(binding.Spec.BindingKey) + updated = true + } + if err := controllerutil.SetControllerReference(binding, spritz, r.Scheme); err != nil { + return err + } + if !updated { + return nil + } + return r.Update(ctx, spritz) +} + +func runtimeIsReady(spritz *spritzv1.Spritz) bool { + return spritz != nil && strings.EqualFold(strings.TrimSpace(spritz.Status.Phase), "Ready") +} + +func runtimeIsUsable(spritz *spritzv1.Spritz) bool { + if spritz == nil { + return false + } + phase := strings.TrimSpace(strings.ToLower(spritz.Status.Phase)) + return phase == "ready" || phase == "provisioning" +} + +func runtimeIsTerminal(spritz *spritzv1.Spritz) bool { + if spritz == nil { + return false + } + switch strings.TrimSpace(strings.ToLower(spritz.Status.Phase)) { + case "error", "expired", "terminating": + return true + default: + return false + } +} + +func resolveInstanceRevision(ref *spritzv1.SpritzBindingInstanceRef, spritz *spritzv1.Spritz, fallback string) string { + if ref != nil && strings.TrimSpace(ref.Revision) != "" { + return strings.TrimSpace(ref.Revision) + } + if spritz != nil { + if revision := strings.TrimSpace(spritz.GetAnnotations()[bindingTargetRevisionAnnotationKey]); revision != "" { + return revision + } + } + return strings.TrimSpace(fallback) +} + +func (r *SpritzBindingReconciler) updateBindingStatus(ctx context.Context, binding *spritzv1.SpritzBinding) error { + return r.Status().Update(ctx, binding) +} + +func (r *SpritzBindingReconciler) setReadyStatus(binding *spritzv1.SpritzBinding, now *metav1.Time, phase string) { + binding.Status.Phase = phase + binding.Status.LastErrorCode = "" + binding.Status.LastErrorMessage = "" + binding.Status.ObservedGeneration = binding.Generation + binding.Status.LastReconciledAt = now.DeepCopy() + metaSetBindingReadyCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionTrue, "Ready", "binding is ready") + metaSetBindingProgressingCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionFalse, "Ready", "binding is stable") +} + +func (r *SpritzBindingReconciler) setProgressingStatus( + binding *spritzv1.SpritzBinding, + now *metav1.Time, + phase string, + reason string, + message string, +) { + binding.Status.Phase = phase + binding.Status.LastErrorCode = "" + binding.Status.LastErrorMessage = "" + binding.Status.ObservedGeneration = binding.Generation + binding.Status.LastReconciledAt = now.DeepCopy() + metaSetBindingReadyCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionFalse, reason, message) + metaSetBindingProgressingCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionTrue, reason, message) +} + +func (r *SpritzBindingReconciler) setFailureStatus( + binding *spritzv1.SpritzBinding, + now *metav1.Time, + phase string, + code string, + message string, +) { + binding.Status.Phase = phase + binding.Status.LastErrorCode = strings.TrimSpace(code) + binding.Status.LastErrorMessage = strings.TrimSpace(message) + binding.Status.ObservedGeneration = binding.Generation + binding.Status.LastReconciledAt = now.DeepCopy() + metaSetBindingReadyCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionFalse, code, message) + metaSetBindingProgressingCondition(&binding.Status.Conditions, binding.Generation, metav1.ConditionFalse, code, message) +} + +func metaSetBindingReadyCondition( + conditions *[]metav1.Condition, + generation int64, + status metav1.ConditionStatus, + reason string, + message string, +) { + metaSetBindingCondition(conditions, metav1.Condition{ + Type: "Ready", + Status: status, + ObservedGeneration: generation, + Reason: strings.TrimSpace(reason), + Message: strings.TrimSpace(message), + LastTransitionTime: metav1.Now(), + }) +} + +func metaSetBindingProgressingCondition( + conditions *[]metav1.Condition, + generation int64, + status metav1.ConditionStatus, + reason string, + message string, +) { + metaSetBindingCondition(conditions, metav1.Condition{ + Type: "Progressing", + Status: status, + ObservedGeneration: generation, + Reason: strings.TrimSpace(reason), + Message: strings.TrimSpace(message), + LastTransitionTime: metav1.Now(), + }) +} + +func metaSetBindingCondition(conditions *[]metav1.Condition, condition metav1.Condition) { + for index := range *conditions { + if (*conditions)[index].Type == condition.Type { + (*conditions)[index] = condition + return + } + } + *conditions = append(*conditions, condition) +} + +func bindingRuntimeName(binding *spritzv1.SpritzBinding, sequence int64) string { + return spritzv1.BindingRuntimeNameForSequence( + strings.TrimSpace(binding.Spec.BindingKey), + binding.Spec.Template.NamePrefix, + binding.Spec.Template.PresetID, + sequence, + ) +} + +func applyBindingIngressDefaults(spec *spritzv1.SpritzSpec, name, namespace string, defaults bindingIngressDefaults) { + if spec == nil || !defaults.enabled() { + return + } + if spec.Ingress == nil && bindingIsWebDisabled(spec) { + return + } + if spec.Ingress == nil { + spec.Ingress = &spritzv1.SpritzIngress{} + } + if spec.Ingress.Mode == "" && defaults.Mode != "" { + spec.Ingress.Mode = defaults.Mode + } + if spec.Ingress.Host == "" && defaults.HostTemplate != "" { + spec.Ingress.Host = strings.NewReplacer("{name}", name, "{namespace}", namespace).Replace(defaults.HostTemplate) + } + if spec.Ingress.Path == "" && defaults.Path != "" { + spec.Ingress.Path = strings.NewReplacer("{name}", name, "{namespace}", namespace).Replace(defaults.Path) + } + if spec.Ingress.ClassName == "" && defaults.ClassName != "" { + spec.Ingress.ClassName = defaults.ClassName + } + if spec.Ingress.GatewayName == "" && defaults.GatewayName != "" { + spec.Ingress.GatewayName = defaults.GatewayName + } + if spec.Ingress.GatewayNamespace == "" && defaults.GatewayNamespace != "" { + spec.Ingress.GatewayNamespace = defaults.GatewayNamespace + } + if spec.Ingress.GatewaySectionName == "" && defaults.GatewaySectionName != "" { + spec.Ingress.GatewaySectionName = defaults.GatewaySectionName + } +} + +func bindingIsWebDisabled(spec *spritzv1.SpritzSpec) bool { + if spec == nil || spec.Features == nil || spec.Features.Web == nil { + return false + } + return !*spec.Features.Web +} + +func cloneStringMap(value map[string]string) map[string]string { + if len(value) == 0 { + return nil + } + cloned := make(map[string]string, len(value)) + for key, item := range value { + cloned[key] = item + } + return cloned +} + +func (r *SpritzBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&spritzv1.SpritzBinding{}). + Owns(&spritzv1.Spritz{}). + Complete(r) +} diff --git a/operator/controllers/spritz_binding_controller_test.go b/operator/controllers/spritz_binding_controller_test.go new file mode 100644 index 0000000..51f4fe6 --- /dev/null +++ b/operator/controllers/spritz_binding_controller_test.go @@ -0,0 +1,399 @@ +package controllers + +import ( + "context" + "fmt" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + spritzv1 "spritz.sh/operator/api/v1" +) + +type failingCreateClient struct { + client.Client + err error +} + +func (c *failingCreateClient) Create( + ctx context.Context, + obj client.Object, + opts ...client.CreateOption, +) error { + if _, ok := obj.(*spritzv1.Spritz); ok { + return c.err + } + return c.Client.Create(ctx, obj, opts...) +} + +type lingeringDeleteClient struct { + client.Client +} + +func (c *lingeringDeleteClient) Delete( + ctx context.Context, + obj client.Object, + opts ...client.DeleteOption, +) error { + return nil +} + +func newBindingTestBinding() *spritzv1.SpritzBinding { + return &spritzv1.SpritzBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "channel-installation-binding-1", + Namespace: "spritz-test", + Finalizers: []string{spritzBindingFinalizer}, + }, + Spec: spritzv1.SpritzBindingSpec{ + BindingKey: "channel-installation-binding-1", + DesiredRevision: "sha256:rev-1", + Template: spritzv1.SpritzBindingTemplate{ + PresetID: "zeno", + NamePrefix: "zeno", + Spec: spritzv1.SpritzSpec{ + Image: "example.com/openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-1"}, + }, + }, + }, + } +} + +func newBindingReconcilerForTest(t *testing.T, objects ...runtime.Object) (*SpritzBindingReconciler, client.Client) { + t.Helper() + scheme := newControllerTestScheme(t) + builder := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&spritzv1.SpritzBinding{}) + if len(objects) > 0 { + builder = builder.WithRuntimeObjects(objects...) + } + k8sClient := builder.Build() + return &SpritzBindingReconciler{ + Client: k8sClient, + Scheme: scheme, + }, k8sClient +} + +func TestReconcileBindingCreatesInitialCandidateRuntime(t *testing.T) { + binding := newBindingTestBinding() + reconciler, k8sClient := newBindingReconcilerForTest(t, binding) + + if err := reconciler.reconcileBinding(context.Background(), binding); err != nil { + t.Fatalf("reconcileBinding returned error: %v", err) + } + + var storedBinding spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &storedBinding); err != nil { + t.Fatalf("failed to load binding: %v", err) + } + if storedBinding.Status.Phase != spritzv1.BindingPhaseCreating { + t.Fatalf("expected creating phase, got %#v", storedBinding.Status) + } + if storedBinding.Status.CandidateInstanceRef == nil { + t.Fatalf("expected candidate instance ref, got %#v", storedBinding.Status) + } + if storedBinding.Status.NextRuntimeSequence != 1 { + t.Fatalf("expected next runtime sequence 1, got %#v", storedBinding.Status) + } + + var candidate spritzv1.Spritz + if err := k8sClient.Get( + context.Background(), + client.ObjectKey{Namespace: binding.Namespace, Name: storedBinding.Status.CandidateInstanceRef.Name}, + &candidate, + ); err != nil { + t.Fatalf("failed to load candidate runtime: %v", err) + } + if candidate.Annotations[spritzv1.BindingKeyAnnotationKey] != binding.Spec.BindingKey { + t.Fatalf("expected binding key annotation on candidate, got %#v", candidate.Annotations) + } + if candidate.Labels[spritzv1.BindingNameLabelKey] != binding.Name { + t.Fatalf("expected binding name label on candidate, got %#v", candidate.Labels) + } +} + +func TestReconcileBindingPromotesReadyInitialCandidate(t *testing.T) { + binding := newBindingTestBinding() + candidateName := bindingRuntimeName(binding, 1) + candidate := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: candidateName, + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: binding.Spec.DesiredRevision, + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + binding.Status.CandidateInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: candidate.Name, + Revision: binding.Spec.DesiredRevision, + Phase: "Ready", + } + binding.Status.NextRuntimeSequence = 1 + reconciler, k8sClient := newBindingReconcilerForTest(t, binding, candidate) + + if err := reconciler.reconcileBinding(context.Background(), binding); err != nil { + t.Fatalf("reconcileBinding returned error: %v", err) + } + + var storedBinding spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &storedBinding); err != nil { + t.Fatalf("failed to load binding: %v", err) + } + if storedBinding.Status.ActiveInstanceRef == nil || storedBinding.Status.ActiveInstanceRef.Name != candidate.Name { + t.Fatalf("expected candidate to become active, got %#v", storedBinding.Status) + } + if storedBinding.Status.CandidateInstanceRef != nil { + t.Fatalf("expected candidate ref to be cleared, got %#v", storedBinding.Status) + } + if storedBinding.Status.ObservedRevision != binding.Spec.DesiredRevision { + t.Fatalf("expected observed revision %q, got %#v", binding.Spec.DesiredRevision, storedBinding.Status) + } + if storedBinding.Status.Phase != spritzv1.BindingPhaseReady { + t.Fatalf("expected ready phase, got %#v", storedBinding.Status) + } +} + +func TestReconcileBindingCutsOverReadyReplacementAndCleansUpOldRuntime(t *testing.T) { + binding := newBindingTestBinding() + oldRuntimeName := bindingRuntimeName(binding, 1) + newRuntimeName := bindingRuntimeName(binding, 2) + oldRuntime := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: oldRuntimeName, + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-1", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + newRuntime := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: newRuntimeName, + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-2", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + binding.Spec.DesiredRevision = "sha256:rev-2" + binding.Status.ObservedRevision = "sha256:rev-1" + binding.Status.ActiveInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: oldRuntime.Name, + Revision: "sha256:rev-1", + Phase: "Ready", + } + binding.Status.CandidateInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: newRuntime.Name, + Revision: "sha256:rev-2", + Phase: "Ready", + } + binding.Status.NextRuntimeSequence = 2 + reconciler, k8sClient := newBindingReconcilerForTest(t, binding, oldRuntime, newRuntime) + + if err := reconciler.reconcileBinding(context.Background(), binding); err != nil { + t.Fatalf("first reconcileBinding returned error: %v", err) + } + + var cuttingOver spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &cuttingOver); err != nil { + t.Fatalf("failed to load binding after cutover: %v", err) + } + if cuttingOver.Status.ActiveInstanceRef == nil || cuttingOver.Status.ActiveInstanceRef.Name != newRuntime.Name { + t.Fatalf("expected replacement to become active, got %#v", cuttingOver.Status) + } + if cuttingOver.Status.CleanupInstanceRef == nil || cuttingOver.Status.CleanupInstanceRef.Name != oldRuntime.Name { + t.Fatalf("expected old runtime to move into cleanup, got %#v", cuttingOver.Status) + } + if cuttingOver.Status.Phase != spritzv1.BindingPhaseCleaningUp { + t.Fatalf("expected cleaning_up phase, got %#v", cuttingOver.Status) + } + + if err := reconciler.reconcileBinding(context.Background(), &cuttingOver); err != nil { + t.Fatalf("second reconcileBinding returned error: %v", err) + } + + var cleanupPending spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &cleanupPending); err != nil { + t.Fatalf("failed to load binding after cleanup request: %v", err) + } + if cleanupPending.Status.CleanupInstanceRef == nil || cleanupPending.Status.CleanupInstanceRef.Name != oldRuntime.Name { + t.Fatalf("expected cleanup ref to stay until the next observe pass, got %#v", cleanupPending.Status) + } + if err := reconciler.reconcileBinding(context.Background(), &cleanupPending); err != nil { + t.Fatalf("third reconcileBinding returned error: %v", err) + } + + var finalized spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &finalized); err != nil { + t.Fatalf("failed to load binding after cleanup completion: %v", err) + } + if finalized.Status.CleanupInstanceRef != nil { + t.Fatalf("expected cleanup ref to be cleared, got %#v", finalized.Status) + } + if finalized.Status.Phase != spritzv1.BindingPhaseReady { + t.Fatalf("expected ready phase after cleanup, got %#v", finalized.Status) + } +} + +func TestReconcileBindingRequeuesAfterCandidateCreateFailure(t *testing.T) { + binding := newBindingTestBinding() + reconciler, k8sClient := newBindingReconcilerForTest(t, binding) + reconciler.Client = &failingCreateClient{ + Client: reconciler.Client, + err: fmt.Errorf("transient create failure"), + } + + result, err := reconciler.Reconcile( + context.Background(), + ctrl.Request{NamespacedName: client.ObjectKeyFromObject(binding)}, + ) + if err != nil { + t.Fatalf("expected create failure to be converted into a retry, got %v", err) + } + if result.RequeueAfter != 2*time.Second { + t.Fatalf("expected reconcile to requeue after 2s, got %#v", result) + } + + var storedBinding spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &storedBinding); err != nil { + t.Fatalf("failed to load binding: %v", err) + } + if storedBinding.Status.Phase != spritzv1.BindingPhaseFailed { + t.Fatalf("expected failed phase after create error, got %#v", storedBinding.Status) + } + if storedBinding.Status.LastErrorCode != "candidate_create_failed" { + t.Fatalf("expected candidate_create_failed code, got %#v", storedBinding.Status) + } +} + +func TestReconcileBindingKeepsCleanupRefWhileDeletionIsStillInFlight(t *testing.T) { + binding := newBindingTestBinding() + activeRuntime := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: bindingRuntimeName(binding, 2), + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-2", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + cleanupRuntime := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: bindingRuntimeName(binding, 1), + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-1", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + binding.Spec.DesiredRevision = "sha256:rev-2" + binding.Status.ObservedRevision = "sha256:rev-2" + binding.Status.ActiveInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: activeRuntime.Name, + Revision: "sha256:rev-2", + Phase: "Ready", + } + binding.Status.CleanupInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: cleanupRuntime.Name, + Revision: "sha256:rev-1", + Phase: "Ready", + } + reconciler, k8sClient := newBindingReconcilerForTest(t, binding, activeRuntime, cleanupRuntime) + reconciler.Client = &lingeringDeleteClient{Client: reconciler.Client} + + if err := reconciler.reconcileBinding(context.Background(), binding); err != nil { + t.Fatalf("reconcileBinding returned error: %v", err) + } + + var storedBinding spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &storedBinding); err != nil { + t.Fatalf("failed to load binding: %v", err) + } + if storedBinding.Status.CleanupInstanceRef == nil || storedBinding.Status.CleanupInstanceRef.Name != cleanupRuntime.Name { + t.Fatalf("expected cleanup ref to stay until runtime deletion completes, got %#v", storedBinding.Status) + } + if storedBinding.Status.Phase != spritzv1.BindingPhaseCleaningUp { + t.Fatalf("expected cleaning_up phase, got %#v", storedBinding.Status) + } + + var lingering spritzv1.Spritz + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(cleanupRuntime), &lingering); err != nil { + t.Fatalf("expected cleanup runtime to remain present while terminating: %v", err) + } +} + +func TestReconcileBindingMovesTerminalCandidateIntoCleanup(t *testing.T) { + binding := newBindingTestBinding() + activeRuntime := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: bindingRuntimeName(binding, 1), + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-1", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Ready"}, + } + terminalCandidate := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: bindingRuntimeName(binding, 2), + Namespace: binding.Namespace, + Annotations: map[string]string{ + bindingTargetRevisionAnnotationKey: "sha256:rev-2", + }, + }, + Status: spritzv1.SpritzStatus{Phase: "Error"}, + } + binding.Spec.DesiredRevision = "sha256:rev-2" + binding.Status.ObservedRevision = "sha256:rev-1" + binding.Status.NextRuntimeSequence = 2 + binding.Status.ActiveInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: activeRuntime.Name, + Revision: "sha256:rev-1", + Phase: "Ready", + } + binding.Status.CandidateInstanceRef = &spritzv1.SpritzBindingInstanceRef{ + Namespace: binding.Namespace, + Name: terminalCandidate.Name, + Revision: "sha256:rev-2", + Phase: "Error", + } + reconciler, k8sClient := newBindingReconcilerForTest(t, binding, activeRuntime, terminalCandidate) + + if err := reconciler.reconcileBinding(context.Background(), binding); err != nil { + t.Fatalf("reconcileBinding returned error: %v", err) + } + + var storedBinding spritzv1.SpritzBinding + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(binding), &storedBinding); err != nil { + t.Fatalf("failed to load binding: %v", err) + } + if storedBinding.Status.CleanupInstanceRef == nil || storedBinding.Status.CleanupInstanceRef.Name != terminalCandidate.Name { + t.Fatalf("expected terminal candidate to move into cleanup, got %#v", storedBinding.Status) + } + if storedBinding.Status.CandidateInstanceRef == nil { + t.Fatalf("expected a fresh candidate to be created after terminal cleanup, got %#v", storedBinding.Status) + } + if storedBinding.Status.CandidateInstanceRef.Name == terminalCandidate.Name { + t.Fatalf("expected a new candidate identity, got %#v", storedBinding.Status) + } +} diff --git a/operator/main.go b/operator/main.go index a981886..37b058d 100644 --- a/operator/main.go +++ b/operator/main.go @@ -71,6 +71,14 @@ func main() { logger.Error(err, "unable to create controller") os.Exit(1) } + if err := (&controllers.SpritzBindingReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressDefaults: controllers.NewBindingIngressDefaultsFromEnv(), + }).SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to create binding controller") + os.Exit(1) + } if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { logger.Error(err, "problem running manager")