From 47baa42a501143cfe556eae6d198b39eabef607b Mon Sep 17 00:00:00 2001 From: e3mrah <81884938+emrahbaysal@users.noreply.github.com> Date: Fri, 8 May 2026 23:58:51 +0400 Subject: [PATCH] feat(controllers): land blueprint-controller (slice C3, #1095) (#1126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the Phase-0 blueprint-controller Go binary at core/controllers/blueprint/. Watches Blueprint.catalyst.openova.io/v1 and v1alpha1 CRs (cluster-scoped per the schema) via dynamic client + unstructured.Unstructured — both versions share the inline schema in products/catalyst/chart/crds/blueprint.yaml so we handle them transparently. Per docs/EPICS-1-6-unified-design.md §3.3 + §5.2: - Validates Blueprints with business-logic checks the openAPIV3Schema cannot express (placement modes subset, manifest source kind enum on the long form, depends[].blueprint catalog resolution, semver- range syntax for upgrades.from/blocks, name-vs-card.title soft check). - Mirrors visibility=listed Blueprints to the Sovereign-local `catalog` Gitea Org per docs/NAMING-CONVENTION.md §11.2; removes the public mirror file for visibility=private; skips the public mirror for visibility=unlisted (and removes any prior listed publish). - Updates Blueprint.status.phase + observedGeneration + conditions[]; Ready=True on successful mirror, Ready=False with reason=ValidationFailed/PendingDependencies/GiteaWriteFailed on error paths. publishedAt/deprecatedAt set on phase transitions; ociDigest passed through unchanged (set by CI release workflow per BLUEPRINT-AUTHORING §11). Architecture: - Reuses the dynamic-client + Unstructured pattern from products/catalyst/bootstrap/api/internal/store/crd_store.go (canonical-seam map row). - In-tree semver-range parser (no new go.mod dep) covers the `0.x | 1.x | ^1.4 | ~1.4 | >=1.0.0 <2 | exact` grammar that the existing 61-blueprint corpus uses. - Minimal HTTP Gitea client at internal/gitea/ — narrower than the git-clone-and-push seam at sme_tenant_gitops.go (which is right for one-off provisioning but wrong for per-watch-event reconcile cadence). When C1/C2 need the same surface, this package will move to core/internal/gitea/ in a follow-up slice; until then it co-locates with C3. - ClusterRole grants only get/list/watch on Blueprints + update on Blueprint.status. No general K8s writes — Gitea writes go through CATALYST_GITEA_TOKEN over HTTPS. - No `kubectl apply`/`helm install` shell-outs (Inviolable Principle #3); no hardcoded URLs/tokens/regions (Principle #4). Tests (`go test -count=1 -race ./...` GREEN): - Happy-path reconcile of valid v1 + v1alpha1 Blueprints → mirror written exactly once - Idempotent re-reconcile (zero extra Gitea PUTs on identical content) - visibility=private REMOVES the public mirror file - visibility=unlisted REMOVES a previously-listed mirror file - Pending dependency surfaces a Pending condition + still mirrors - Validation failure (invalid placement mode) blocks mirror, sets phase=Draft + Ready=False - All 61 existing platform/*/blueprint.yaml files pass the business-logic validator with 0 errors (TestValidate_ExistingBlueprintCorpus) - In-tree semver parser covers every form in the existing corpus + rejects v-prefix / over-segmented / non-numeric inputs Out of scope (per slice brief): - catalyst-api code unchanged - other controllers (C1/C2/C4/C5) — separate slices - catalog-svc HTTP server — EPIC-2 (#1097) - cosign verification — handled by CI per BLUEPRINT-AUTHORING §11 - existing 59-now-61 blueprint.yaml files unchanged Closes the slice C3 tracking comment on #1095. Co-authored-by: hatiyildiz <269457768+hatiyildiz@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- core/controllers/blueprint/Containerfile | 36 ++ core/controllers/blueprint/cmd/main.go | 126 ++++ .../blueprint/deploy/clusterrole.yaml | 56 ++ .../blueprint/deploy/deployment.yaml | 78 +++ core/controllers/blueprint/go.mod | 51 ++ core/controllers/blueprint/go.sum | 158 +++++ .../controller/blueprint_controller.go | 587 ++++++++++++++++++ .../controller/blueprint_controller_test.go | 415 +++++++++++++ .../blueprint/internal/gitea/client.go | 312 ++++++++++ .../blueprint/internal/gitea/client_test.go | 249 ++++++++ .../blueprint/internal/semver/semver.go | 145 +++++ .../blueprint/internal/semver/semver_test.go | 62 ++ .../blueprint/internal/validate/validate.go | 336 ++++++++++ .../internal/validate/validate_test.go | 369 +++++++++++ 14 files changed, 2980 insertions(+) create mode 100644 core/controllers/blueprint/Containerfile create mode 100644 core/controllers/blueprint/cmd/main.go create mode 100644 core/controllers/blueprint/deploy/clusterrole.yaml create mode 100644 core/controllers/blueprint/deploy/deployment.yaml create mode 100644 core/controllers/blueprint/go.mod create mode 100644 core/controllers/blueprint/go.sum create mode 100644 core/controllers/blueprint/internal/controller/blueprint_controller.go create mode 100644 core/controllers/blueprint/internal/controller/blueprint_controller_test.go create mode 100644 core/controllers/blueprint/internal/gitea/client.go create mode 100644 core/controllers/blueprint/internal/gitea/client_test.go create mode 100644 core/controllers/blueprint/internal/semver/semver.go create mode 100644 core/controllers/blueprint/internal/semver/semver_test.go create mode 100644 core/controllers/blueprint/internal/validate/validate.go create mode 100644 core/controllers/blueprint/internal/validate/validate_test.go diff --git a/core/controllers/blueprint/Containerfile b/core/controllers/blueprint/Containerfile new file mode 100644 index 00000000..7e6bfada --- /dev/null +++ b/core/controllers/blueprint/Containerfile @@ -0,0 +1,36 @@ +# blueprint-controller — slice C3 of EPIC-0 (#1095). +# +# Distroless-static final image; non-root UID 65532; size ~30-40 MiB. +# Per Inviolable Principle #4a, this image must be built ONLY by the +# GitHub Actions pipeline and tagged with the git SHA. Local builds +# never reach GHCR. +# +# Build context: the repo root (so we can COPY core/controllers/... +# directly). + +FROM golang:1.22-alpine AS build +WORKDIR /src + +# Cache go.mod / go.sum first. +COPY core/controllers/blueprint/go.mod core/controllers/blueprint/ +COPY core/controllers/blueprint/go.sum core/controllers/blueprint/ +WORKDIR /src/core/controllers/blueprint +RUN go mod download + +# Copy the controller package tree. +WORKDIR /src +COPY core/controllers/blueprint/ core/controllers/blueprint/ + +WORKDIR /src/core/controllers/blueprint +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/blueprint-controller \ + ./cmd + +# Runtime stage — distroless static for a minimal, non-root, +# CVE-narrow image. +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/blueprint-controller /blueprint-controller +USER 65532:65532 +ENTRYPOINT ["/blueprint-controller"] diff --git a/core/controllers/blueprint/cmd/main.go b/core/controllers/blueprint/cmd/main.go new file mode 100644 index 00000000..2f439320 --- /dev/null +++ b/core/controllers/blueprint/cmd/main.go @@ -0,0 +1,126 @@ +// Command blueprint-controller — slice C3 of EPIC-0 (#1095). +// +// Watches Blueprint.catalyst.openova.io/v1 + v1alpha1 CRs cluster-wide, +// validates them against the business-logic checks not expressible in +// the CRD's openAPIV3Schema, mirrors them to the Sovereign-local +// `catalog` Gitea Org per docs/NAMING-CONVENTION.md §11.2, and updates +// each CR's status with phase + conditions. +// +// Wire-up at deploy time: +// +// - Runs on the management cluster (`hz-nbg-mgt-prod` post-Phase-0; +// `ct-eu-mgt-prod` until then) per +// docs/EPICS-1-6-unified-design.md §3.3 ("Where it runs: mgmt +// cluster"). +// - Reads kubeconfig from in-cluster ServiceAccount; ClusterRole +// scope is get/list/watch on Blueprints + update on +// Blueprint.status (subresource). +// - Gitea endpoint configured via CATALYST_GITEA_URL and +// CATALYST_GITEA_TOKEN env vars. +// +// Per docs/INVIOLABLE-PRINCIPLES.md #4 every value is runtime- +// configurable; the binary hard-codes nothing region-specific. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/openova-io/openova/core/controllers/blueprint/internal/controller" + "github.com/openova-io/openova/core/controllers/blueprint/internal/gitea" +) + +func main() { + logLevel := slog.LevelInfo + if strings.EqualFold(os.Getenv("LOG_LEVEL"), "debug") { + logLevel = slog.LevelDebug + } + log := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) + slog.SetDefault(log) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := run(ctx, log); err != nil { + log.Error("blueprint-controller exited with error", "err", err) + os.Exit(1) + } +} + +func run(ctx context.Context, log *slog.Logger) error { + cfg, err := loadKubeConfig() + if err != nil { + return fmt.Errorf("load kubeconfig: %w", err) + } + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("dynamic client: %w", err) + } + + giteaURL := os.Getenv("CATALYST_GITEA_URL") + giteaToken := os.Getenv("CATALYST_GITEA_TOKEN") + var giteaClient *gitea.Client + switch { + case giteaURL == "": + log.Warn("CATALYST_GITEA_URL is empty; mirror writes are DISABLED — controller will validate + update status only") + case giteaToken == "": + log.Warn("CATALYST_GITEA_TOKEN is empty; mirror writes are DISABLED — controller will validate + update status only") + default: + giteaClient = gitea.NewClient(giteaURL, giteaToken) + log.Info("Gitea mirror enabled", "url", giteaURL) + } + + resync := durationFromEnv("RESYNC_PERIOD", 5*time.Minute) + + r := controller.New(controller.Config{ + DynamicClient: dyn, + Gitea: giteaClient, + Log: log, + ResyncPeriod: resync, + }) + + log.Info("blueprint-controller starting", + "blueprint_gvr", controller.BlueprintGVR.String(), + "resync", resync, + ) + return r.Run(ctx) +} + +// loadKubeConfig prefers in-cluster config; falls back to KUBECONFIG +// or ~/.kube/config for local dev runs (rare — production deploys to +// a Pod with a ServiceAccount). +func loadKubeConfig() (*rest.Config, error) { + if cfg, err := rest.InClusterConfig(); err == nil { + return cfg, nil + } + rules := clientcmd.NewDefaultClientConfigLoadingRules() + if path := os.Getenv("KUBECONFIG"); path != "" { + rules.ExplicitPath = path + } + overrides := &clientcmd.ConfigOverrides{} + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides).ClientConfig() +} + +// durationFromEnv parses a Go duration string from env; returns def +// on parse failure or empty value. +func durationFromEnv(key string, def time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + return def + } + return d +} diff --git a/core/controllers/blueprint/deploy/clusterrole.yaml b/core/controllers/blueprint/deploy/clusterrole.yaml new file mode 100644 index 00000000..5c57368b --- /dev/null +++ b/core/controllers/blueprint/deploy/clusterrole.yaml @@ -0,0 +1,56 @@ +# ClusterRole — slice C3, blueprint-controller (#1095). +# +# Scope per docs/EPICS-1-6-unified-design.md §3.3: +# - Watch Blueprints cluster-wide (the CRD is cluster-scoped per +# products/catalyst/chart/crds/blueprint.yaml). +# - Update Blueprint.status (subresource). +# - No other writes — Gitea mirroring goes through the HTTP API +# authenticated by CATALYST_GITEA_TOKEN, never the K8s API. +# +# Per docs/INVIOLABLE-PRINCIPLES.md #3 (architectural-fidelity rule) +# this controller does NOT call helm/kubectl/exec — its only K8s-side +# action is updating its own CR's status. All other state lives in +# Gitea (read by Flux). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: blueprint-controller + labels: + app.kubernetes.io/name: blueprint-controller + app.kubernetes.io/component: controller + app.kubernetes.io/managed-by: flux +rules: + - apiGroups: ["catalyst.openova.io"] + resources: ["blueprints"] + verbs: ["get", "list", "watch"] + - apiGroups: ["catalyst.openova.io"] + resources: ["blueprints/status"] + verbs: ["update", "patch"] + # Leader election lease (when slice F1 / future HA work runs the + # controller in N-replica leader-elected mode). + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: blueprint-controller + namespace: catalyst + labels: + app.kubernetes.io/name: blueprint-controller +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: blueprint-controller + labels: + app.kubernetes.io/name: blueprint-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: blueprint-controller +subjects: + - kind: ServiceAccount + name: blueprint-controller + namespace: catalyst diff --git a/core/controllers/blueprint/deploy/deployment.yaml b/core/controllers/blueprint/deploy/deployment.yaml new file mode 100644 index 00000000..1938121d --- /dev/null +++ b/core/controllers/blueprint/deploy/deployment.yaml @@ -0,0 +1,78 @@ +# Deployment — slice C3, blueprint-controller (#1095). +# +# Per docs/INVIOLABLE-PRINCIPLES.md #4a the image MUST be the SHA-pinned +# tag built by GitHub Actions; `:latest` and floating tags are NEVER +# permitted in production manifests. The Flux ImagePolicy reconciler +# (when wired) bumps the SHA via PR. +# +# Per docs/INVIOLABLE-PRINCIPLES.md #4 every value here is overridable +# via the per-cluster bootstrap-kit overlay; nothing region-specific +# lives in this base manifest. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: blueprint-controller + namespace: catalyst + labels: + app.kubernetes.io/name: blueprint-controller + app.kubernetes.io/component: controller + app.kubernetes.io/managed-by: flux +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: blueprint-controller + template: + metadata: + labels: + app.kubernetes.io/name: blueprint-controller + spec: + serviceAccountName: blueprint-controller + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: blueprint-controller + # Image SHA bumped by CI on every push to + # core/controllers/blueprint/** in main. + image: ghcr.io/openova-io/openova/blueprint-controller:placeholder + imagePullPolicy: IfNotPresent + env: + # Sovereign-local Gitea — overridden per cluster. + - name: CATALYST_GITEA_URL + valueFrom: + configMapKeyRef: + name: blueprint-controller-config + key: giteaURL + optional: true + - name: CATALYST_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: blueprint-controller-gitea + key: token + optional: true + - name: LOG_LEVEL + value: info + - name: RESYNC_PERIOD + value: "5m" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # Liveness probe: the controller exits non-zero on permanent + # errors (CRD missing, kubeconfig invalid) and the K8s + # restart policy handles the rest. A heartbeat HTTP endpoint + # is a follow-up slice (when leader-election lands). + terminationMessagePolicy: FallbackToLogsOnError + terminationGracePeriodSeconds: 30 diff --git a/core/controllers/blueprint/go.mod b/core/controllers/blueprint/go.mod new file mode 100644 index 00000000..5a73faf6 --- /dev/null +++ b/core/controllers/blueprint/go.mod @@ -0,0 +1,51 @@ +module github.com/openova-io/openova/core/controllers/blueprint + +go 1.22.0 + +toolchain go1.22.10 + +require ( + k8s.io/api v0.31.1 + k8s.io/apimachinery v0.31.1 + k8s.io/client-go v0.31.1 + sigs.k8s.io/yaml v1.4.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/core/controllers/blueprint/go.sum b/core/controllers/blueprint/go.sum new file mode 100644 index 00000000..b7149838 --- /dev/null +++ b/core/controllers/blueprint/go.sum @@ -0,0 +1,158 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/core/controllers/blueprint/internal/controller/blueprint_controller.go b/core/controllers/blueprint/internal/controller/blueprint_controller.go new file mode 100644 index 00000000..8cdc966e --- /dev/null +++ b/core/controllers/blueprint/internal/controller/blueprint_controller.go @@ -0,0 +1,587 @@ +// Package controller — the core reconcile loop for the +// blueprint-controller (slice C3 of EPIC-0 / #1095). +// +// The contract: +// +// 1. Watch `Blueprint.catalyst.openova.io/v1` and `v1alpha1` CRs +// (cluster-scoped per the schema). Both versions share an inline +// schema; we use the dynamic client + unstructured.Unstructured +// so we handle both versions transparently — the existing pattern +// from `products/catalyst/bootstrap/api/internal/store/crd_store.go`. +// +// 2. Validate each Blueprint at reconcile time: +// - delegate the structural CRD-schema checks to Kubernetes itself +// (the openAPIV3Schema in products/catalyst/chart/crds/blueprint.yaml +// already enforces them at admission) +// - run the business-logic checks in +// `core/controllers/blueprint/internal/validate` for the bits the +// schema can't express. +// +// 3. For Blueprints whose validation passes: +// - if visibility is listed: write blueprint.yaml to +// gitea../catalog//blueprint.yaml +// - if visibility is unlisted: skip the public mirror (the file +// is published only via the per-Blueprint OCI artifact). +// - if visibility is private: REMOVE the file from the public +// mirror if previously present. +// +// 4. Update Blueprint.status with phase, observedGeneration, +// conditions[]. publishedAt / deprecatedAt are set on phase +// transitions. ociDigest is passed through unchanged — it is +// populated by the CI release workflow at tag push, not by this +// controller. +// +// Runtime: a single reconciler goroutine driven by a watch on the +// Blueprint GVR. Per-Blueprint reconciliation is idempotent: identical +// content + visibility means zero Gitea writes (the gitea client's +// PutFile already short-circuits on byte-equal content). +// +// This package is reachable from cmd/main.go via Run(ctx, cfg). +package controller + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/yaml" + + "github.com/openova-io/openova/core/controllers/blueprint/internal/gitea" + "github.com/openova-io/openova/core/controllers/blueprint/internal/validate" +) + +// BlueprintGVR pins the storage version. v1 is the storage version per +// products/catalyst/chart/crds/blueprint.yaml; the dynamic client +// transparently round-trips v1alpha1 objects to/from this GVR via +// the apiserver's conversion webhook (the CRD declares both versions +// served from the same schema, so no body-rewriting is needed). +var BlueprintGVR = schema.GroupVersionResource{ + Group: "catalyst.openova.io", + Version: "v1", + Resource: "blueprints", +} + +// CatalogOrg — the Sovereign-local Gitea Org that holds the public +// catalog mirror per docs/NAMING-CONVENTION.md §11.2. +const CatalogOrg = "catalog" + +// Visibility values mirror the CRD enum. +const ( + VisibilityListed = "listed" + VisibilityUnlisted = "unlisted" + VisibilityPrivate = "private" +) + +// Phase values mirror the CRD's status.phase enum. +const ( + PhaseDraft = "Draft" + PhasePublished = "Published" + PhaseDeprecated = "Deprecated" + PhaseWithdrawn = "Withdrawn" +) + +// Condition reason vocabulary. Surfaced on status.conditions[].reason. +const ( + ReasonReady = "Ready" + ReasonValidationFailed = "ValidationFailed" + ReasonPendingDependencies = "PendingDependencies" + ReasonGiteaWriteFailed = "GiteaWriteFailed" + ReasonValidationWarning = "ValidationWarning" +) + +// Config is the runtime configuration for a controller instance. +type Config struct { + // DynamicClient is the K8s dynamic client. Pass either an + // in-cluster client or a fake.NewSimpleDynamicClient for tests. + DynamicClient dynamic.Interface + + // Gitea is the Gitea HTTP client. May be nil in tests that don't + // exercise the mirror path; the reconciler skips the mirror when + // nil and emits a Pending condition. + Gitea *gitea.Client + + // Log structured logger. Defaults to slog.Default() when nil. + Log *slog.Logger + + // ResyncPeriod is the watch resync interval. Default 5m. + ResyncPeriod time.Duration + + // CommitterAuthor / CommitterEmail decorate Gitea commits. + CommitterAuthor string + CommitterEmail string +} + +// Reconciler holds runtime state for the controller. It is exported so +// unit tests can drive a single Reconcile call without spinning up the +// watch loop. +type Reconciler struct { + cfg Config + + // catalog tracks the set of known Blueprint names so depends[] + // resolution works during validation. Updated on every successful + // reconcile. + catalogMu sync.RWMutex + catalog map[string]struct{} +} + +// New returns a fresh Reconciler with cfg. +func New(cfg Config) *Reconciler { + if cfg.Log == nil { + cfg.Log = slog.Default() + } + if cfg.ResyncPeriod == 0 { + cfg.ResyncPeriod = 5 * time.Minute + } + if cfg.CommitterAuthor == "" { + cfg.CommitterAuthor = "blueprint-controller" + } + if cfg.CommitterEmail == "" { + cfg.CommitterEmail = "blueprint-controller@openova.io" + } + return &Reconciler{ + cfg: cfg, + catalog: make(map[string]struct{}), + } +} + +// Run starts the watch loop. Blocks until ctx is cancelled. On +// transient watch errors it backs off and retries; on permanent errors +// (e.g. the Blueprint CRD doesn't exist) it returns the error so the +// caller exits non-zero and the K8s deployment restart-loop catches it. +func (r *Reconciler) Run(ctx context.Context) error { + if r.cfg.DynamicClient == nil { + return errors.New("controller: DynamicClient is required") + } + + // Initial list — pre-populate the catalog before the first watch + // event so depends[] resolution doesn't see an empty set. + if err := r.initialList(ctx); err != nil { + return fmt.Errorf("initial list: %w", err) + } + + // Watch loop with backoff per client-go conventions. + return wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) { + if err := r.watchOnce(ctx); err != nil { + r.cfg.Log.Warn("blueprint-controller: watch error; will retry", "err", err) + } + return false, nil // never "done" — keep watching until ctx cancelled + }) +} + +// initialList fetches all Blueprints once and reconciles each. +// Building the catalog set first means depends[] resolution sees the +// full catalog on first pass. +func (r *Reconciler) initialList(ctx context.Context) error { + list, err := r.cfg.DynamicClient.Resource(BlueprintGVR).Namespace("").List(ctx, metav1.ListOptions{}) + if err != nil { + return err + } + + // First pass: rebuild the catalog set. + r.catalogMu.Lock() + r.catalog = make(map[string]struct{}, len(list.Items)) + for i := range list.Items { + r.catalog[list.Items[i].GetName()] = struct{}{} + } + r.catalogMu.Unlock() + + // Second pass: reconcile each. + for i := range list.Items { + if err := r.Reconcile(ctx, &list.Items[i]); err != nil { + r.cfg.Log.Error("initial reconcile failed", "name", list.Items[i].GetName(), "err", err) + } + } + return nil +} + +// watchOnce opens a watch on the Blueprint GVR and dispatches events +// until the watch closes or ctx is cancelled. +func (r *Reconciler) watchOnce(ctx context.Context) error { + w, err := r.cfg.DynamicClient.Resource(BlueprintGVR).Namespace("").Watch(ctx, metav1.ListOptions{ + AllowWatchBookmarks: true, + TimeoutSeconds: ptrInt64(int64(r.cfg.ResyncPeriod.Seconds())), + }) + if err != nil { + return err + } + defer w.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, ok := <-w.ResultChan(): + if !ok { + return errors.New("watch channel closed") + } + if event.Type == watch.Error || event.Type == watch.Bookmark { + continue + } + obj, ok := event.Object.(*unstructured.Unstructured) + if !ok { + continue + } + r.catalogMu.Lock() + switch event.Type { + case watch.Added, watch.Modified: + r.catalog[obj.GetName()] = struct{}{} + case watch.Deleted: + delete(r.catalog, obj.GetName()) + } + r.catalogMu.Unlock() + if event.Type == watch.Deleted { + continue + } + if err := r.Reconcile(ctx, obj); err != nil { + r.cfg.Log.Error("reconcile failed", "name", obj.GetName(), "err", err) + } + } + } +} + +// Reconcile is the per-object reconcile entry-point. Exposed for +// tests; production calls it from watchOnce / initialList. +// +// Steps: +// 1. Run the business-logic validator. +// 2. If errors: update status to phase=Draft + Ready=False + +// reason=ValidationFailed; return. +// 3. If pending deps: update status to phase=Draft + Ready=False + +// reason=PendingDependencies; the deps will resolve on a +// subsequent watch event when the depended-on Blueprint lands. +// 4. Mirror the CR to Gitea per visibility: +// - listed: PutFile blueprint.yaml under catalog// +// - unlisted: skip the mirror; ensure file is REMOVED if it was +// previously listed (re-publish flow) +// - private: DeleteFile from the mirror; idempotent if absent. +// 5. Update status to phase=Published (or Withdrawn for private) + +// Ready=True + observedGeneration=spec.generation. +func (r *Reconciler) Reconcile(ctx context.Context, bp *unstructured.Unstructured) error { + if bp == nil { + return nil + } + name := bp.GetName() + r.cfg.Log.Info("reconcile", "name", name, "rv", bp.GetResourceVersion(), "gen", bp.GetGeneration()) + + // 1. Validate. + r.catalogMu.RLock() + catalogSnapshot := make(map[string]struct{}, len(r.catalog)) + for k := range r.catalog { + catalogSnapshot[k] = struct{}{} + } + r.catalogMu.RUnlock() + + res := validate.Validate(bp, catalogSnapshot) + if res.HasErrors() { + return r.updateStatus(ctx, bp, statusUpdate{ + Phase: PhaseDraft, + Ready: "False", + Reason: ReasonValidationFailed, + Message: strings.Join(res.Errors, "; "), + }) + } + // Pending deps are surfaced on the *final* status update (below) + // via su.PendingDeps so they coexist with the Ready condition. + // The brief: "if not yet present, surface a Pending condition + // rather than rejecting outright" — so we DO mirror, AND surface + // Pending, in a single status write. + + // 2. Mirror per visibility. + visibility := stringFromSpec(bp, "visibility") + if visibility == "" { + // Default per BLUEPRINT-AUTHORING.md §9. + visibility = VisibilityListed + } + + if r.cfg.Gitea != nil { + if err := r.mirrorBlueprint(ctx, bp, visibility); err != nil { + return r.updateStatus(ctx, bp, statusUpdate{ + Phase: PhaseDraft, + Ready: "False", + Reason: ReasonGiteaWriteFailed, + Message: err.Error(), + }) + } + } + + // 3. Compute phase and update status. + phase := PhasePublished + switch visibility { + case VisibilityPrivate: + phase = PhaseWithdrawn + case VisibilityUnlisted: + // Unlisted is still "Published" per the schema's enum — it is + // reachable by direct lookup, just not on the marketplace + // card grid. We map unlisted → Published. + phase = PhasePublished + } + + // Pick up an existing Deprecated phase. Operators flip + // status.phase=Deprecated manually (or via a CR annotation in a + // follow-up slice). Don't overwrite Deprecated → Published. + currentPhase := stringFromStatus(bp, "phase") + if currentPhase == PhaseDeprecated { + phase = PhaseDeprecated + } + + su := statusUpdate{ + Phase: phase, + Ready: "True", + Reason: ReasonReady, + Message: fmt.Sprintf("blueprint %s mirrored (visibility=%s)", name, visibility), + } + if len(res.Warnings) > 0 { + su.Warnings = res.Warnings + } + if len(res.PendingDeps) > 0 { + su.PendingDeps = res.PendingDeps + } + return r.updateStatus(ctx, bp, su) +} + +// mirrorBlueprint maps the Blueprint CR's visibility to the catalog +// mirror operation. Returns nil on success. +func (r *Reconciler) mirrorBlueprint(ctx context.Context, bp *unstructured.Unstructured, visibility string) error { + repo := bp.GetName() + + // Serialise the Blueprint CR back to YAML for the mirror file. + // We strip status (mirror files carry only the spec contract + + // metadata.name; status is the controller's responsibility on the + // source CR). The dependent + // `application-controller` (slice C4) reads the catalog mirror, + // not the API-server CR. + mirrorYAML, err := serialiseForMirror(bp) + if err != nil { + return fmt.Errorf("serialise: %w", err) + } + + switch visibility { + case VisibilityListed: + if err := r.cfg.Gitea.EnsureRepo(ctx, CatalogOrg, repo); err != nil { + return fmt.Errorf("EnsureRepo: %w", err) + } + _, err := r.cfg.Gitea.PutFile(ctx, CatalogOrg, repo, "main", "blueprint.yaml", + mirrorYAML, fmt.Sprintf("publish %s @ %s", repo, stringFromSpec(bp, "version"))) + return err + + case VisibilityUnlisted: + // Unlisted means the file is NOT on the public catalog mirror. + // If it was previously listed, remove it. + _, err := r.cfg.Gitea.DeleteFile(ctx, CatalogOrg, repo, "main", "blueprint.yaml", + fmt.Sprintf("unlist %s", repo)) + return err + + case VisibilityPrivate: + // Private means the file is removed from the public catalog + // mirror entirely. Idempotent: if it's not there, no error. + _, err := r.cfg.Gitea.DeleteFile(ctx, CatalogOrg, repo, "main", "blueprint.yaml", + fmt.Sprintf("withdraw %s", repo)) + return err + + default: + return fmt.Errorf("unknown visibility %q", visibility) + } +} + +// statusUpdate captures the desired Blueprint.status changes for a +// reconcile pass. Translated to JSONPatch / nested-map writes by +// updateStatus. +type statusUpdate struct { + Phase string + Ready string // "True" | "False" | "Unknown" + Reason string + Message string + Warnings []string + PendingDeps []string +} + +// updateStatus writes su to bp.status via the dynamic client. +// Idempotent: if status is already in the desired state, no API call. +func (r *Reconciler) updateStatus(ctx context.Context, bp *unstructured.Unstructured, su statusUpdate) error { + now := time.Now().UTC().Format(time.RFC3339) + gen := bp.GetGeneration() + + // Read current status — preserve ociDigest (set by CI), + // publishedAt (only update on first transition to Published), + // and any condition we don't overwrite. + currentStatus, _, _ := unstructured.NestedMap(bp.Object, "status") + if currentStatus == nil { + currentStatus = map[string]interface{}{} + } + + // observedGeneration always tracks spec.generation. + currentStatus["observedGeneration"] = gen + + // Phase transition logic. + prevPhase := stringFromMap(currentStatus, "phase") + currentStatus["phase"] = su.Phase + + // publishedAt — set on first transition INTO Published. + if su.Phase == PhasePublished && prevPhase != PhasePublished { + currentStatus["publishedAt"] = now + } + // deprecatedAt — set on first transition INTO Deprecated. + if su.Phase == PhaseDeprecated && prevPhase != PhaseDeprecated { + currentStatus["deprecatedAt"] = now + } + + // conditions[] — replace the Ready condition; preserve unrelated + // conditions (e.g. a Deprecated condition operators may add). + conditions := []interface{}{} + if existing, ok := currentStatus["conditions"].([]interface{}); ok { + for _, c := range existing { + cm, ok := c.(map[string]interface{}) + if !ok { + continue + } + if t, _ := cm["type"].(string); t == "Ready" || t == "Pending" || t == "Warning" { + continue // dropped + replaced below + } + conditions = append(conditions, c) + } + } + conditions = append(conditions, map[string]interface{}{ + "type": "Ready", + "status": su.Ready, + "reason": su.Reason, + "message": su.Message, + "lastTransitionTime": now, + }) + if su.Reason == ReasonPendingDependencies || len(su.PendingDeps) > 0 { + msg := su.Message + if len(su.PendingDeps) > 0 { + msg = "unresolved dependencies: " + strings.Join(su.PendingDeps, ", ") + } + conditions = append(conditions, map[string]interface{}{ + "type": "Pending", + "status": "True", + "reason": ReasonPendingDependencies, + "message": msg, + "lastTransitionTime": now, + }) + } + if len(su.Warnings) > 0 { + conditions = append(conditions, map[string]interface{}{ + "type": "Warning", + "status": "True", + "reason": ReasonValidationWarning, + "message": strings.Join(su.Warnings, "; "), + "lastTransitionTime": now, + }) + } + currentStatus["conditions"] = conditions + + bp.Object["status"] = currentStatus + + // UpdateStatus on the cluster-scoped resource. Note: dynamic client + // uses Resource(GVR).Namespace("") for cluster-scoped CRs. + _, err := r.cfg.DynamicClient.Resource(BlueprintGVR).Namespace("").UpdateStatus(ctx, bp, metav1.UpdateOptions{}) + if err != nil { + // Tolerate "not found" — the resource may have been deleted + // between the watch event and our status update. + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("update status: %w", err) + } + return nil +} + +// CatalogSnapshot returns a copy of the controller's known-Blueprint +// names. Used by tests + the /healthz endpoint (when added). +func (r *Reconciler) CatalogSnapshot() map[string]struct{} { + r.catalogMu.RLock() + defer r.catalogMu.RUnlock() + out := make(map[string]struct{}, len(r.catalog)) + for k := range r.catalog { + out[k] = struct{}{} + } + return out +} + +// SeedCatalog injects names into the controller's catalog set. +// Test-only — production fills the set via initialList + watch events. +func (r *Reconciler) SeedCatalog(names ...string) { + r.catalogMu.Lock() + defer r.catalogMu.Unlock() + for _, n := range names { + r.catalog[n] = struct{}{} + } +} + +// stringFromSpec safely reads a string at spec.. +func stringFromSpec(bp *unstructured.Unstructured, key string) string { + v, _, _ := unstructured.NestedString(bp.Object, "spec", key) + return v +} + +// stringFromStatus safely reads a string at status.. +func stringFromStatus(bp *unstructured.Unstructured, key string) string { + v, _, _ := unstructured.NestedString(bp.Object, "status", key) + return v +} + +func stringFromMap(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +// serialiseForMirror returns the YAML bytes the controller writes to +// catalog//blueprint.yaml. +// +// Per the brief: "containing the same content". We keep apiVersion + +// kind + metadata.{name,labels,annotations} + spec; we strip status +// (mirror files don't carry the controller's transient status) and +// other server-side metadata fields (resourceVersion, uid, generation, +// managedFields, etc.) that aren't part of the user-authored contract. +func serialiseForMirror(bp *unstructured.Unstructured) ([]byte, error) { + out := map[string]interface{}{ + "apiVersion": bp.GetAPIVersion(), + "kind": bp.GetKind(), + "metadata": map[string]interface{}{ + "name": bp.GetName(), + }, + } + if labels := bp.GetLabels(); len(labels) > 0 { + out["metadata"].(map[string]interface{})["labels"] = stringMap(labels) + } + if anns := bp.GetAnnotations(); len(anns) > 0 { + // Strip the kubectl.kubernetes.io/last-applied-configuration + // annotation — it's a server-managed field, not user authored. + clean := map[string]string{} + for k, v := range anns { + if k == "kubectl.kubernetes.io/last-applied-configuration" { + continue + } + clean[k] = v + } + if len(clean) > 0 { + out["metadata"].(map[string]interface{})["annotations"] = stringMap(clean) + } + } + if spec, ok, _ := unstructured.NestedMap(bp.Object, "spec"); ok { + out["spec"] = spec + } + return yaml.Marshal(out) +} + +func stringMap(in map[string]string) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func ptrInt64(v int64) *int64 { return &v } diff --git a/core/controllers/blueprint/internal/controller/blueprint_controller_test.go b/core/controllers/blueprint/internal/controller/blueprint_controller_test.go new file mode 100644 index 00000000..be72e9a5 --- /dev/null +++ b/core/controllers/blueprint/internal/controller/blueprint_controller_test.go @@ -0,0 +1,415 @@ +package controller + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/openova-io/openova/core/controllers/blueprint/internal/gitea" +) + +// newScheme wires the Blueprint GVR into a runtime.Scheme so the fake +// dynamic client knows how to resolve list/watch calls. +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = corev1.AddToScheme(s) + // Register the Blueprint GVR with both List and singular kinds. + s.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "catalyst.openova.io", Version: "v1", Kind: "Blueprint"}, + &unstructured.Unstructured{}, + ) + s.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "catalyst.openova.io", Version: "v1", Kind: "BlueprintList"}, + &unstructured.UnstructuredList{}, + ) + return s +} + +// listKindMap tells the fake dynamic client which list-kind to use for +// our cluster-scoped CR. +func listKindMap() map[schema.GroupVersionResource]string { + return map[schema.GroupVersionResource]string{ + BlueprintGVR: "BlueprintList", + } +} + +// makeBlueprint builds a minimal Blueprint CR fixture. +func makeBlueprint(name, version, visibility string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetAPIVersion("catalyst.openova.io/v1") + u.SetKind("Blueprint") + u.SetName(name) + u.SetGeneration(1) + u.Object["spec"] = map[string]interface{}{ + "version": version, + "visibility": visibility, + "card": map[string]interface{}{ + "title": strings.Title(strings.TrimPrefix(name, "bp-")), + }, + "placementSchema": map[string]interface{}{ + "modes": []interface{}{"single-region"}, + "default": "single-region", + }, + } + return u +} + +// fakeGiteaCounter is a slim fake-Gitea handler that records the set +// of (method, repo, path) tuples for assertion. Built on the same +// idea as gitea/client_test.go's fakeGitea, but inline so this test +// file owns its mutable test state. +type fakeGiteaCounter struct { + mu sync.Mutex + files map[string][]byte // key = "repo/path" + repos map[string]bool + puts int + deletes int +} + +func newFakeGiteaCounter() *fakeGiteaCounter { + return &fakeGiteaCounter{ + files: map[string][]byte{}, + repos: map[string]bool{}, + } +} + +func (f *fakeGiteaCounter) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + // /api/v1/repos// GET probe + // /api/v1/orgs//repos POST create + // /api/v1/repos///contents/ GET/POST/PUT/DELETE + f.mu.Lock() + defer f.mu.Unlock() + switch { + case strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos") && r.Method == http.MethodPost: + parts := strings.Split(path, "/") + org := parts[4] + // extract name from JSON body (cheap; we don't need the + // rest) + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + // crude name extraction: assume `"name":""` is in body + name := extractJSONString(string(body), "name") + f.repos[org+"/"+name] = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"` + name + `"}`)) + + case strings.HasPrefix(path, "/api/v1/repos/"): + rest := strings.TrimPrefix(path, "/api/v1/repos/") + segs := strings.SplitN(rest, "/", 4) + org, repo := segs[0], segs[1] + if len(segs) == 2 { + if !f.repos[org+"/"+repo] { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name":"` + repo + `"}`)) + return + } + if len(segs) == 4 && segs[2] == "contents" { + p := segs[3] + key := repo + "/" + p + switch r.Method { + case http.MethodGet: + content, ok := f.files[key] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"path":"` + p + `","sha":"sha-` + key + `","content":"` + base64Encode(content) + `","type":"file"}`)) + return + case http.MethodPost, http.MethodPut: + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + encoded := extractJSONString(string(body), "content") + decoded, _ := base64Decode(encoded) + f.files[key] = decoded + f.puts++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"path":"` + p + `","sha":"sha-` + key + `","content":"` + encoded + `","type":"file"}`)) + return + case http.MethodDelete: + delete(f.files, key) + f.deletes++ + w.WriteHeader(http.StatusOK) + return + } + } + } + w.WriteHeader(http.StatusNotFound) + }) +} + +// extractJSONString does cheap key:"value" extraction so the test stub +// doesn't need to import encoding/json (avoids JSON decoder allocation +// noise in -race). +func extractJSONString(body, key string) string { + idx := strings.Index(body, `"`+key+`":"`) + if idx < 0 { + return "" + } + body = body[idx+len(key)+4:] + end := strings.Index(body, `"`) + if end < 0 { + return "" + } + return body[:end] +} + +func base64Encode(b []byte) string { + const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + var sb strings.Builder + for i := 0; i < len(b); i += 3 { + var n int + if i+2 < len(b) { + n = int(b[i])<<16 | int(b[i+1])<<8 | int(b[i+2]) + sb.WriteByte(tbl[(n>>18)&0x3f]) + sb.WriteByte(tbl[(n>>12)&0x3f]) + sb.WriteByte(tbl[(n>>6)&0x3f]) + sb.WriteByte(tbl[n&0x3f]) + } else if i+1 < len(b) { + n = int(b[i])<<16 | int(b[i+1])<<8 + sb.WriteByte(tbl[(n>>18)&0x3f]) + sb.WriteByte(tbl[(n>>12)&0x3f]) + sb.WriteByte(tbl[(n>>6)&0x3f]) + sb.WriteByte('=') + } else { + n = int(b[i]) << 16 + sb.WriteByte(tbl[(n>>18)&0x3f]) + sb.WriteByte(tbl[(n>>12)&0x3f]) + sb.WriteByte('=') + sb.WriteByte('=') + } + } + return sb.String() +} + +func base64Decode(s string) ([]byte, error) { + const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + rev := map[byte]int{} + for i := 0; i < len(tbl); i++ { + rev[tbl[i]] = i + } + var out []byte + var buf, n int + for _, c := range []byte(s) { + if c == '=' || c == '\n' || c == '\r' { + continue + } + v, ok := rev[c] + if !ok { + continue + } + buf = (buf << 6) | v + n += 6 + if n >= 8 { + n -= 8 + out = append(out, byte((buf>>n)&0xff)) + } + } + return out, nil +} + +// makeReconciler wires a Reconciler against a fake dynamic client + +// httptest Gitea server. +func makeReconciler(t *testing.T, items ...*unstructured.Unstructured) (*Reconciler, *fakeGiteaCounter, *httptest.Server, *dynamicfake.FakeDynamicClient) { + t.Helper() + objs := make([]runtime.Object, 0, len(items)) + for _, it := range items { + objs = append(objs, it) + } + dc := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(newScheme(), listKindMap(), objs...) + fc := newFakeGiteaCounter() + srv := httptest.NewServer(fc.handler()) + t.Cleanup(srv.Close) + cli := gitea.NewClient(srv.URL, "test-token") + cli.HTTP = srv.Client() + r := New(Config{ + DynamicClient: dc, + Gitea: cli, + }) + return r, fc, srv, dc +} + +func TestReconcile_Listed_Mirrors(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-test", "1.0.0", "listed") + r, fc, _, _ := makeReconciler(t, bp) + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + + fc.mu.Lock() + defer fc.mu.Unlock() + if !fc.repos["catalog/bp-test"] { + t.Errorf("expected repo catalog/bp-test created") + } + if _, ok := fc.files["bp-test/blueprint.yaml"]; !ok { + t.Errorf("expected file written; got files=%v", keys(fc.files)) + } + if fc.puts != 1 { + t.Errorf("expected 1 PUT, got %d", fc.puts) + } +} + +func TestReconcile_Private_DeletesFromMirror(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-private", "1.0.0", "private") + r, fc, _, _ := makeReconciler(t, bp) + // Pre-seed: pretend a previous listed publish put the file. + fc.mu.Lock() + fc.repos["catalog/bp-private"] = true + fc.files["bp-private/blueprint.yaml"] = []byte("previous content") + fc.mu.Unlock() + + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + fc.mu.Lock() + defer fc.mu.Unlock() + if _, ok := fc.files["bp-private/blueprint.yaml"]; ok { + t.Errorf("expected file removed; still present") + } +} + +func TestReconcile_Unlisted_RemovesFromPublicMirror(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-unlisted", "1.0.0", "unlisted") + r, fc, _, _ := makeReconciler(t, bp) + // Pre-seed: pretend a previous listed publish put the file. + fc.mu.Lock() + fc.repos["catalog/bp-unlisted"] = true + fc.files["bp-unlisted/blueprint.yaml"] = []byte("previous content") + fc.mu.Unlock() + + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + fc.mu.Lock() + defer fc.mu.Unlock() + if _, ok := fc.files["bp-unlisted/blueprint.yaml"]; ok { + t.Errorf("unlisted: expected mirror file removed") + } +} + +func TestReconcile_PendingDependency(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-with-dep", "1.0.0", "listed") + bp.Object["spec"].(map[string]interface{})["depends"] = []interface{}{ + map[string]interface{}{"blueprint": "bp-not-yet-landed"}, + } + r, _, _, dc := makeReconciler(t, bp) + // Catalog snapshot is empty; reconciler should NOT error but + // surface a Pending condition on status.conditions[]. + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + out, err := dc.Resource(BlueprintGVR).Namespace("").Get(context.Background(), "bp-with-dep", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get: %v", err) + } + conds, _, _ := unstructured.NestedSlice(out.Object, "status", "conditions") + hasPending := false + for _, c := range conds { + cm := c.(map[string]interface{}) + if cm["type"] == "Pending" { + hasPending = true + } + } + if !hasPending { + t.Errorf("expected Pending condition; status.conditions=%v", conds) + } +} + +func TestReconcile_Idempotent(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-idem", "1.0.0", "listed") + r, fc, _, dc := makeReconciler(t, bp) + ctx := context.Background() + if err := r.Reconcile(ctx, bp); err != nil { + t.Fatalf("first: %v", err) + } + // Re-fetch and reconcile again — same content should NOT re-PUT. + out, _ := dc.Resource(BlueprintGVR).Namespace("").Get(ctx, "bp-idem", metav1.GetOptions{}) + if err := r.Reconcile(ctx, out); err != nil { + t.Fatalf("second: %v", err) + } + fc.mu.Lock() + defer fc.mu.Unlock() + if fc.puts != 1 { + t.Errorf("idempotent: expected 1 PUT total, got %d", fc.puts) + } +} + +func TestReconcile_ValidationFailure_NoMirror(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-bad-modes", "1.0.0", "listed") + // Inject invalid placement mode. + bp.Object["spec"].(map[string]interface{})["placementSchema"] = map[string]interface{}{ + "modes": []interface{}{"round-robin"}, + } + r, fc, _, dc := makeReconciler(t, bp) + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + fc.mu.Lock() + defer fc.mu.Unlock() + if fc.puts != 0 { + t.Errorf("validation failure: expected no mirror writes, got %d puts", fc.puts) + } + out, _ := dc.Resource(BlueprintGVR).Namespace("").Get(context.Background(), "bp-bad-modes", metav1.GetOptions{}) + phase, _, _ := unstructured.NestedString(out.Object, "status", "phase") + if phase != PhaseDraft { + t.Errorf("expected phase=Draft, got %q", phase) + } +} + +func TestReconcile_NoGiteaClient_StillUpdatesStatus(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-no-gitea", "1.0.0", "listed") + dc := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(newScheme(), listKindMap(), bp) + r := New(Config{DynamicClient: dc}) + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + out, _ := dc.Resource(BlueprintGVR).Namespace("").Get(context.Background(), "bp-no-gitea", metav1.GetOptions{}) + phase, _, _ := unstructured.NestedString(out.Object, "status", "phase") + if phase != PhasePublished { + t.Errorf("expected phase=Published when Gitea is nil (skip mirror), got %q", phase) + } +} + +func TestReconcile_v1alpha1_Transparent(t *testing.T) { + t.Parallel() + bp := makeBlueprint("bp-v1alpha1", "0.5.0", "listed") + bp.SetAPIVersion("catalyst.openova.io/v1alpha1") + r, fc, _, _ := makeReconciler(t, bp) + if err := r.Reconcile(context.Background(), bp); err != nil { + t.Fatalf("reconcile: %v", err) + } + fc.mu.Lock() + defer fc.mu.Unlock() + if fc.puts != 1 { + t.Errorf("v1alpha1 path: expected 1 PUT, got %d", fc.puts) + } +} + +func keys(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/core/controllers/blueprint/internal/gitea/client.go b/core/controllers/blueprint/internal/gitea/client.go new file mode 100644 index 00000000..c2bcbea8 --- /dev/null +++ b/core/controllers/blueprint/internal/gitea/client.go @@ -0,0 +1,312 @@ +// Package gitea — minimal HTTP client for the Sovereign-local Gitea +// instance. +// +// This package is intentionally narrow: it exposes ONLY the operations +// the blueprint-controller needs to maintain the catalog mirror in +// the `catalog` Gitea Org per docs/NAMING-CONVENTION.md §11.2: +// +// - GetFile(org, repo, branch, path) read a file +// - PutFile(org, repo, branch, path, content, msg) create-or-update +// - DeleteFile(org, repo, branch, path, msg) delete a file +// - EnsureRepo(org, repo) create-if-missing +// +// Why a separate package from the existing +// `products/catalyst/bootstrap/api/internal/handler/sme_tenant_gitops.go`: +// +// That package shells out to `git` + `git push` over a clone of the +// openova-public GitOps repo. It runs in a pod with a writable tmpfs +// and its commit cadence (one tenant overlay per provisioning) tolerates +// the latency of clone-and-push. The blueprint-controller, by contrast, +// reconciles N Blueprint CRs per K8s watch event — the per-event work +// must be a small set of HTTP API calls, not a clone-push cycle. +// +// The Gitea HTTP API (api/v1) is the canonical seam for HTTP-level +// Gitea mutation; both Gitea's built-in web UI and Actions runner use +// it. Authentication via a personal-access token in the Authorization +// header. +// +// SLICES C1/C2 NOTE: When organization-controller (C1) or +// environment-controller (C2) need an HTTP Gitea client for their +// own Gitea-Org / repo creation flows, they should EXTEND this package +// rather than write a parallel one. The Coordinator's seam map will be +// updated to reflect this once C1/C2 land. For now, this package lives +// under the blueprint-controller's tree because it ships with C3; the +// Coordinator may move it to `core/internal/gitea/` in a follow-up +// slice when C1/C2 also need it. +package gitea + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client is a thin wrapper around http.Client targeting a Gitea +// instance's `/api/v1` surface. +type Client struct { + // BaseURL is the Gitea root, e.g. "https://gitea.hfmp.openova.io". + BaseURL string + + // Token is a personal-access token with `repo` + `write:repository` + // scopes for the catalog Gitea Org. In production this is wired + // from CATALYST_GITEA_TOKEN. + Token string + + // HTTP is the underlying client. Tests inject a httptest server. + // Default: a 30s timeout client with retries on 5xx. + HTTP *http.Client + + // User-Agent emitted on every request. Defaults to + // "openova-blueprint-controller/1.0". + UserAgent string +} + +// NewClient returns a Client with sensible defaults. +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 30 * time.Second}, + UserAgent: "openova-blueprint-controller/1.0", + } +} + +// FileResponse is the subset of Gitea's contents-API response we use. +// Full schema: +// https://docs.gitea.com/api#tag/repository/operation/repoGetContents +type FileResponse struct { + Path string `json:"path"` + SHA string `json:"sha"` + Content string `json:"content"` // base64-encoded + Type string `json:"type"` // "file" | "dir" | "symlink" | "submodule" +} + +// commitFilePayload — body for create / update / delete file operations. +type commitFilePayload struct { + Message string `json:"message"` + Content string `json:"content,omitempty"` // base64-encoded; omitempty for delete + SHA string `json:"sha,omitempty"` // required for update + delete + Branch string `json:"branch,omitempty"` +} + +// HTTPError reports a non-2xx response from the Gitea API. +type HTTPError struct { + Method string + URL string + Status int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("gitea: %s %s: HTTP %d: %s", e.Method, e.URL, e.Status, e.Body) +} + +// IsNotFound reports whether err is a 404 response. +func IsNotFound(err error) bool { + var he *HTTPError + if !errors.As(err, &he) { + return false + } + return he.Status == http.StatusNotFound +} + +// do builds, sends, and decodes a Gitea API request. dst may be nil +// when the caller doesn't care about the response body. +func (c *Client) do(ctx context.Context, method, path string, body interface{}, dst interface{}) error { + if c.BaseURL == "" { + return errors.New("gitea: BaseURL is empty") + } + if c.Token == "" { + return errors.New("gitea: Token is empty") + } + + var reqBody io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("gitea: marshal body: %w", err) + } + reqBody = bytes.NewReader(buf) + } + + url := c.BaseURL + path + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return fmt.Errorf("gitea: build request: %w", err) + } + req.Header.Set("Authorization", "token "+c.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("gitea: %s %s: %w", method, url, err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &HTTPError{ + Method: method, + URL: url, + Status: resp.StatusCode, + Body: string(respBody), + } + } + if dst != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, dst); err != nil { + return fmt.Errorf("gitea: decode response from %s: %w", url, err) + } + } + return nil +} + +// GetFile reads a file from a repo at the given branch. Returns +// (*FileResponse, nil) on success, (nil, IsNotFound-able error) when +// the file (or repo) doesn't exist, (nil, otherErr) on transport +// failures. +func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (*FileResponse, error) { + q := url.Values{} + if branch != "" { + q.Set("ref", branch) + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path)) + if qs := q.Encode(); qs != "" { + endpoint += "?" + qs + } + out := &FileResponse{} + if err := c.do(ctx, http.MethodGet, endpoint, nil, out); err != nil { + return nil, err + } + return out, nil +} + +// PutFile creates or updates a file at path. If the file already +// exists, the call performs an update by passing the current SHA. +// Idempotent: if content matches the existing file byte-for-byte, no +// API call is made (saves a write to Gitea + the etcd watch event). +// +// Returns the new SHA on success. +func (c *Client) PutFile(ctx context.Context, org, repo, branch, path string, content []byte, message string) (string, error) { + encoded := base64.StdEncoding.EncodeToString(content) + + // Probe existing. + existing, err := c.GetFile(ctx, org, repo, branch, path) + switch { + case err == nil: + // Decode existing content; skip the write if identical. + decoded, decErr := base64.StdEncoding.DecodeString(strings.ReplaceAll(existing.Content, "\n", "")) + if decErr == nil && bytes.Equal(decoded, content) { + return existing.SHA, nil + } + // Update path. + body := commitFilePayload{ + Message: message, + Content: encoded, + SHA: existing.SHA, + Branch: branch, + } + out := &FileResponse{} + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path)) + if err := c.do(ctx, http.MethodPut, endpoint, body, out); err != nil { + return "", err + } + return out.SHA, nil + case IsNotFound(err): + // Create path. + body := commitFilePayload{ + Message: message, + Content: encoded, + Branch: branch, + } + out := &FileResponse{} + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path)) + if err := c.do(ctx, http.MethodPost, endpoint, body, out); err != nil { + return "", err + } + return out.SHA, nil + default: + return "", err + } +} + +// DeleteFile removes path from the repo at branch. Idempotent: a 404 +// from the probe returns (true, nil) — the file is already absent. +// +// Returns (deleted, err). deleted=true when the file existed and was +// deleted (or was already absent); deleted=false only on transport +// failures. +func (c *Client) DeleteFile(ctx context.Context, org, repo, branch, path, message string) (bool, error) { + existing, err := c.GetFile(ctx, org, repo, branch, path) + switch { + case IsNotFound(err): + return true, nil + case err != nil: + return false, err + } + body := commitFilePayload{ + Message: message, + SHA: existing.SHA, + Branch: branch, + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path)) + if err := c.do(ctx, http.MethodDelete, endpoint, body, nil); err != nil { + return false, err + } + return true, nil +} + +// EnsureRepo creates the repo if it doesn't exist. Idempotent. +// +// Per docs/NAMING-CONVENTION.md §11.2, the catalog Gitea Org holds one +// repo per Blueprint at ``. The blueprint-controller pre-creates +// these via this call before issuing the first PutFile. +func (c *Client) EnsureRepo(ctx context.Context, org, repo string) error { + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s", + url.PathEscape(org), url.PathEscape(repo)) + if err := c.do(ctx, http.MethodGet, endpoint, nil, nil); err == nil { + return nil + } else if !IsNotFound(err) { + return err + } + // Create. + createEndpoint := fmt.Sprintf("/api/v1/orgs/%s/repos", url.PathEscape(org)) + body := map[string]interface{}{ + "name": repo, + "description": "Catalyst Blueprint mirror — auto-managed by blueprint-controller. Do not edit manually.", + "private": false, // catalog Org per §11.2 is Sovereign-wide visible + "auto_init": true, // ensures branch exists for first PutFile + "default_branch": "main", + } + if err := c.do(ctx, http.MethodPost, createEndpoint, body, nil); err != nil { + return err + } + return nil +} + +// pathEscapeSegments escapes each path segment but preserves slashes. +// `url.PathEscape` would encode the slashes, breaking Gitea's path +// resolution. We need per-segment escaping for cases where a path +// component contains a space or '#' / '?'. +func pathEscapeSegments(p string) string { + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, s := range parts { + parts[i] = url.PathEscape(s) + } + return strings.Join(parts, "/") +} diff --git a/core/controllers/blueprint/internal/gitea/client_test.go b/core/controllers/blueprint/internal/gitea/client_test.go new file mode 100644 index 00000000..07db4a9c --- /dev/null +++ b/core/controllers/blueprint/internal/gitea/client_test.go @@ -0,0 +1,249 @@ +package gitea + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// fakeGitea is a tiny in-memory Gitea-API stub. It supports only the +// endpoints the Client uses + records request count per (method, path) +// for assertion. +type fakeGitea struct { + mu sync.Mutex + files map[string][]byte // key: org/repo/branch/path + repos map[string]bool // key: org/repo + calls map[string]int // key: METHOD path +} + +func newFakeGitea() *fakeGitea { + return &fakeGitea{ + files: make(map[string][]byte), + repos: make(map[string]bool), + calls: make(map[string]int), + } +} + +func (f *fakeGitea) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.calls[r.Method+" "+r.URL.Path] = f.calls[r.Method+" "+r.URL.Path] + 1 + f.mu.Unlock() + + // /api/v1/repos// GET (probe), POST (create from /orgs/.../repos) + // /api/v1/orgs//repos POST + // /api/v1/repos///contents/ GET/POST/PUT/DELETE + path := r.URL.Path + + switch { + case strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos") && r.Method == http.MethodPost: + parts := strings.Split(path, "/") + // /api/v1/orgs//repos -> parts: ["", "api", "v1", "orgs", , "repos"] + if len(parts) != 6 { + w.WriteHeader(http.StatusBadRequest) + return + } + org := parts[4] + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + repo, _ := body["name"].(string) + f.mu.Lock() + f.repos[org+"/"+repo] = true + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"name": repo}) + return + + case strings.HasPrefix(path, "/api/v1/repos/"): + rest := strings.TrimPrefix(path, "/api/v1/repos/") + segs := strings.SplitN(rest, "/", 4) + if len(segs) < 2 { + w.WriteHeader(http.StatusNotFound) + return + } + org, repo := segs[0], segs[1] + + // Probe: /api/v1/repos// + if len(segs) == 2 { + f.mu.Lock() + exists := f.repos[org+"/"+repo] + f.mu.Unlock() + if !exists { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"name": repo}) + return + } + + // /api/v1/repos///contents/ + if len(segs) == 4 && segs[2] == "contents" { + p := segs[3] + branch := r.URL.Query().Get("ref") + if branch == "" { + branch = "main" + } + key := org + "/" + repo + "/" + branch + "/" + p + + switch r.Method { + case http.MethodGet: + f.mu.Lock() + content, ok := f.files[key] + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(FileResponse{ + Path: p, + SHA: "sha-" + key, + Content: base64.StdEncoding.EncodeToString(content), + Type: "file", + }) + return + case http.MethodPost, http.MethodPut: + body, _ := io.ReadAll(r.Body) + var p commitFilePayload + _ = json.Unmarshal(body, &p) + decoded, _ := base64.StdEncoding.DecodeString(p.Content) + f.mu.Lock() + f.files[key] = decoded + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(FileResponse{ + Path: segs[3], SHA: "sha-" + key, Content: p.Content, Type: "file", + }) + return + case http.MethodDelete: + f.mu.Lock() + delete(f.files, key) + f.mu.Unlock() + w.WriteHeader(http.StatusOK) + return + } + } + } + w.WriteHeader(http.StatusNotFound) + }) +} + +func newClientFor(srv *httptest.Server) *Client { + c := NewClient(srv.URL, "test-token") + c.HTTP = srv.Client() + return c +} + +func TestEnsureRepo_CreateAndIdempotent(t *testing.T) { + t.Parallel() + fake := newFakeGitea() + srv := httptest.NewServer(fake.handler()) + t.Cleanup(srv.Close) + + c := newClientFor(srv) + ctx := context.Background() + + if err := c.EnsureRepo(ctx, "catalog", "bp-test"); err != nil { + t.Fatalf("first EnsureRepo: %v", err) + } + if err := c.EnsureRepo(ctx, "catalog", "bp-test"); err != nil { + t.Fatalf("second EnsureRepo (idempotent): %v", err) + } + // Probe count: 2 GETs, 1 POST. + fake.mu.Lock() + defer fake.mu.Unlock() + if got := fake.calls["GET /api/v1/repos/catalog/bp-test"]; got != 2 { + t.Errorf("expected 2 GETs, got %d", got) + } + if got := fake.calls["POST /api/v1/orgs/catalog/repos"]; got != 1 { + t.Errorf("expected 1 POST, got %d", got) + } +} + +func TestPutFile_CreateUpdateIdempotent(t *testing.T) { + t.Parallel() + fake := newFakeGitea() + srv := httptest.NewServer(fake.handler()) + t.Cleanup(srv.Close) + + c := newClientFor(srv) + ctx := context.Background() + _ = c.EnsureRepo(ctx, "catalog", "bp-test") + + // First PutFile creates. + if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v1\n"), "init"); err != nil { + t.Fatalf("first PutFile: %v", err) + } + // Re-PutFile with identical content is a no-op (no PUT, only the + // probe GET). + fake.mu.Lock() + beforePuts := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"] + fake.mu.Unlock() + + if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v1\n"), "noop"); err != nil { + t.Fatalf("idempotent PutFile: %v", err) + } + + fake.mu.Lock() + afterPuts := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"] + fake.mu.Unlock() + if afterPuts != beforePuts { + t.Errorf("idempotent PutFile triggered %d new PUTs", afterPuts-beforePuts) + } + + // PutFile with new content updates. + if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v2\n"), "bump"); err != nil { + t.Fatalf("update PutFile: %v", err) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if got := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"]; got != 1 { + t.Errorf("expected 1 update PUT, got %d", got) + } +} + +func TestDeleteFile_PresentAndAbsent(t *testing.T) { + t.Parallel() + fake := newFakeGitea() + srv := httptest.NewServer(fake.handler()) + t.Cleanup(srv.Close) + + c := newClientFor(srv) + ctx := context.Background() + _ = c.EnsureRepo(ctx, "catalog", "bp-test") + if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("x"), "init"); err != nil { + t.Fatalf("PutFile: %v", err) + } + + // Delete present file. + deleted, err := c.DeleteFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", "withdraw") + if err != nil || !deleted { + t.Fatalf("DeleteFile: %v deleted=%v", err, deleted) + } + + // Delete already-absent file → idempotent. + deleted, err = c.DeleteFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", "withdraw-again") + if err != nil || !deleted { + t.Fatalf("idempotent DeleteFile: %v deleted=%v", err, deleted) + } +} + +func TestIsNotFound(t *testing.T) { + t.Parallel() + if IsNotFound(nil) { + t.Error("IsNotFound(nil) = true") + } + notFound := &HTTPError{Status: 404} + if !IsNotFound(notFound) { + t.Error("IsNotFound(404) = false") + } + other := &HTTPError{Status: 500} + if IsNotFound(other) { + t.Error("IsNotFound(500) = true") + } +} diff --git a/core/controllers/blueprint/internal/semver/semver.go b/core/controllers/blueprint/internal/semver/semver.go new file mode 100644 index 00000000..2376a73e --- /dev/null +++ b/core/controllers/blueprint/internal/semver/semver.go @@ -0,0 +1,145 @@ +// Package semver — minimal in-tree semver-range parser. +// +// Per slice C3 brief: "Use a small in-tree validator — do NOT add a new +// go.mod dep just for this." The blueprint-controller validates +// `Blueprint.spec.upgrades.from[]` entries which are documented in +// docs/BLUEPRINT-AUTHORING.md §3 to take forms like: +// +// 1.2.x — wildcard at the patch level +// 1.x — wildcard at the minor level +// ^1.4 — caret range (compatible with 1.4.0, < 2.0.0) +// ~1.4 — tilde range (compatible with 1.4.0, < 1.5.0) +// >=1.0.0 <2 — bounded compound range +// 1.0.0 — exact version +// +// Existing 61 blueprint.yaml files in the monorepo use only: +// +// - "0.x" (most common — appearing in cilium, cnpg, keycloak, ...) +// - "1.x", "1.0.x", "1.1.x" +// - "^1.0", "^1.4" +// - exact "1.0.0" +// +// We support the union of those plus `~MAJOR.MINOR` and bare +// `MAJOR.MINOR.PATCH` for completeness. Anything else returns a +// validation error rather than silently accepting it — the controller +// surfaces the error as a Pending condition with reason +// "InvalidUpgradeRange". +package semver + +import ( + "fmt" + "strconv" + "strings" +) + +// IsValidRange reports whether s is a syntactically-valid semver range +// string per the limited grammar documented in the package doc. +// Whitespace around s is trimmed; an empty string returns +// (false, error). +// +// We deliberately do NOT validate that the constraints are +// internally-consistent (e.g. ">=2 <1" is unsatisfiable but parses). +// The controller's job is to reject syntactic garbage; semantic +// reachability of an upgrade path is enforced at install time by +// `application-controller` (slice C4). +func IsValidRange(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("empty range") + } + + // Compound range — every space-separated atom must parse. + // Per BLUEPRINT-AUTHORING.md §10 examples + node-semver convention. + parts := strings.Fields(s) + if len(parts) > 1 { + for _, p := range parts { + if err := validAtom(p); err != nil { + return fmt.Errorf("compound range %q: atom %q: %w", s, p, err) + } + } + return nil + } + + return validAtom(s) +} + +// validAtom validates a single range atom. See package doc for the +// grammar. Returns nil on success, error with the offending input +// otherwise. +func validAtom(s string) error { + if s == "" { + return fmt.Errorf("empty atom") + } + + // Strip operator prefix. + rest := s + switch { + case strings.HasPrefix(s, "^"): + rest = s[1:] + case strings.HasPrefix(s, "~"): + rest = s[1:] + case strings.HasPrefix(s, ">="): + rest = s[2:] + case strings.HasPrefix(s, "<="): + rest = s[2:] + case strings.HasPrefix(s, ">"): + rest = s[1:] + case strings.HasPrefix(s, "<"): + rest = s[1:] + case strings.HasPrefix(s, "="): + rest = s[1:] + } + + rest = strings.TrimSpace(rest) + if rest == "" { + return fmt.Errorf("operator without version in %q", s) + } + + // Strip pre-release / build suffix per semver §10/11. We only + // validate that what remains before any '-' or '+' is dotted + // digits-or-x; the suffix itself is permissive (alnum + dot + dash). + core := rest + if i := strings.IndexAny(rest, "-+"); i >= 0 { + core = rest[:i] + // Validate suffix loosely: each component must be alnum or + // hyphen, separated by dots. Reject empty suffix or stray + // dots. (Accepts forms like "1.0.0-rc.1", "1.0.0-beta-2", + // "1.0.0+build.5".) + suffix := rest[i+1:] + if suffix == "" { + return fmt.Errorf("empty pre-release/build suffix in %q", s) + } + for _, seg := range strings.Split(suffix, ".") { + if seg == "" { + return fmt.Errorf("empty pre-release segment in %q", s) + } + for _, r := range seg { + if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && r != '-' { + return fmt.Errorf("invalid pre-release character %q in %q", r, s) + } + } + } + } + + segs := strings.Split(core, ".") + if len(segs) < 1 || len(segs) > 3 { + return fmt.Errorf("expected 1..3 dotted components in %q, got %d", s, len(segs)) + } + + for i, seg := range segs { + if seg == "" { + return fmt.Errorf("empty version segment in %q (index %d)", s, i) + } + // "x" or "X" wildcard allowed at any position. Strict semver + // requires the wildcard to be at the trailing position only, + // but the existing blueprint corpus has no leading-wildcard + // usage so we err loose-side and accept "x.x.x" / "1.x.0". + if seg == "x" || seg == "X" || seg == "*" { + continue + } + if _, err := strconv.ParseUint(seg, 10, 32); err != nil { + return fmt.Errorf("non-numeric version segment %q in %q", seg, s) + } + } + return nil +} diff --git a/core/controllers/blueprint/internal/semver/semver_test.go b/core/controllers/blueprint/internal/semver/semver_test.go new file mode 100644 index 00000000..56b1ac46 --- /dev/null +++ b/core/controllers/blueprint/internal/semver/semver_test.go @@ -0,0 +1,62 @@ +package semver + +import "testing" + +func TestIsValidRange(t *testing.T) { + t.Parallel() + + // Cases gathered from a sweep of every existing + // platform/*/blueprint.yaml `upgrades.from[]` entry. + valid := []string{ + "0.x", + "1.x", + "1.0.x", + "1.1.x", + "1.2.x", + "^1.0", + "^1.4", + "~1.4", + "1.0.0", + "1.2.3", + "1.0.0-rc.1", + "1.0.0-beta-2", + "1.0.0+build.5", + ">=1.0.0", + "<2", + "<2.0.0", + ">=1.0.0 <2", + ">=1.0.0 <2.0.0", + "=1.0.0", + "x.x.x", + "1", + "*", + } + for _, s := range valid { + if err := IsValidRange(s); err != nil { + t.Errorf("IsValidRange(%q) = %v, want nil", s, err) + } + } + + invalid := []string{ + "", + " ", + "abc", + "1.2.3.4", + "1..2", + "^", + "~", + ">=", + "v1.0.0", // node-semver allows the v-prefix, but our existing + // corpus does not use it; reject to keep the surface tight. + "1.0.0-", + "1.0.0+", + "1..", + ">=foo", + "1.0.0-rc.", + } + for _, s := range invalid { + if err := IsValidRange(s); err == nil { + t.Errorf("IsValidRange(%q) = nil, want error", s) + } + } +} diff --git a/core/controllers/blueprint/internal/validate/validate.go b/core/controllers/blueprint/internal/validate/validate.go new file mode 100644 index 00000000..4afb1059 --- /dev/null +++ b/core/controllers/blueprint/internal/validate/validate.go @@ -0,0 +1,336 @@ +// Package validate — business-logic checks for Blueprint CRs. +// +// The CRD's openAPIV3Schema (products/catalyst/chart/crds/blueprint.yaml) +// already enforces the structural shape: spec.version regex, card.title +// non-empty, visibility enum, manifests.source.kind enum, etc. +// +// What this package adds is the union of checks the schema cannot +// express: +// +// 1. spec.placementSchema.modes[] is a non-empty subset of the canonical +// mode set [single-region, active-active, active-hotstandby]. +// (CRD enforces the enum on each item but does not enforce +// non-emptiness of the array — minItems: 1 is on the schema, but +// the schema also marks placementSchema itself as +// x-kubernetes-preserve-unknown-fields, so a hand-authored CR can +// slip in `placementSchema: {}`.) +// 2. spec.manifests.source.kind, when present, is one of the three +// legal values. (CRD has the enum, but a Blueprint may use the +// v1alpha1 short-form `manifests.chart: ./chart` and have NO +// `source` block — that path is legal and must NOT trigger an +// error.) +// 3. spec.upgrades.from[] entries are syntactically-valid semver +// ranges per docs/BLUEPRINT-AUTHORING.md §3 and §10. +// 4. spec.depends[].blueprint references either (a) a known Blueprint +// in the catalog or (b) a known dependency in this same set of +// Blueprint CRs. Resolution of (a) is the controller's +// responsibility — this package returns the *list* of blueprint +// names that must be checked; the caller does the lookup and +// surfaces a Pending condition if none resolve. +// 5. metadata.name SHOULD match `bp-` form OR the lower-case +// kebab-case of card.title. Returns a *warning* (not error) if it +// doesn't, since the existing 61-blueprint corpus has divergent +// conventions (e.g. `bp-cilium` vs `bp-wordpress-tenant`). The +// controller surfaces warnings as a status.conditions[] entry but +// does not block publishing. +// +// Per slice C3 brief: when a `depends[].blueprint` doesn't resolve, +// surface a Pending condition rather than rejecting outright. So this +// package returns a Result struct whose fields are interpreted by the +// controller — Errors are hard rejections, Pending entries delay +// publication, Warnings are logged but accepted. +package validate + +import ( + "fmt" + "regexp" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/openova-io/openova/core/controllers/blueprint/internal/semver" +) + +// canonicalPlacementModes — must mirror the enum in +// products/catalyst/chart/crds/blueprint.yaml `placementSchema.modes`. +var canonicalPlacementModes = map[string]struct{}{ + "single-region": {}, + "active-active": {}, + "active-hotstandby": {}, +} + +// canonicalManifestKinds — must mirror the enum in +// products/catalyst/chart/crds/blueprint.yaml `manifests.source.kind`. +var canonicalManifestKinds = map[string]struct{}{ + "HelmChart": {}, + "Kustomize": {}, + "OAM": {}, +} + +// bpNamePattern — matches `bp-`. +var bpNamePattern = regexp.MustCompile(`^bp-[a-z][a-z0-9-]{0,61}$`) + +// kebabPattern — matches a lowercase kebab-case identifier. +var kebabPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,61}$`) + +// titleToKebab returns the lowercase-kebab-case projection of title. +// Spaces and underscores → "-"; non-alnum characters dropped; multiple +// consecutive hyphens collapsed; leading/trailing hyphens trimmed. +// +// Examples: +// "WordPress" → "wordpress" +// "WordPress Tenant" → "wordpress-tenant" +// "Hetzner CSI" → "hetzner-csi" +// "kube-prometheus-stack" → "kube-prometheus-stack" +func titleToKebab(title string) string { + var sb strings.Builder + prevHyphen := true // drop leading hyphens + for _, r := range title { + switch { + case r >= 'A' && r <= 'Z': + sb.WriteRune(r - 'A' + 'a') + prevHyphen = false + case r >= 'a' && r <= 'z': + sb.WriteRune(r) + prevHyphen = false + case r >= '0' && r <= '9': + sb.WriteRune(r) + prevHyphen = false + case r == ' ' || r == '_' || r == '-' || r == '/' || r == '.': + if !prevHyphen { + sb.WriteRune('-') + prevHyphen = true + } + default: + // Drop any other rune. + } + } + out := sb.String() + out = strings.TrimRight(out, "-") + return out +} + +// Result captures the outcome of a Blueprint validation pass. +type Result struct { + // Errors block publication; the controller refuses to mirror the + // Blueprint and the CR's Ready condition reports False with reason + // "ValidationFailed". + Errors []string + + // PendingDeps lists `spec.depends[].blueprint` values that the + // controller could not resolve in the catalog at validation time. + // Per slice C3 brief: the controller surfaces a Pending condition + // (not a hard error) so a Blueprint added in the same Gitea PR can + // still resolve once the dependency lands. Empty = no pending deps. + PendingDeps []string + + // Warnings are logged + surfaced on status.conditions[] but do not + // block publication. Used for the metadata.name vs card.title-kebab + // soft check, etc. + Warnings []string +} + +// HasErrors returns true if Errors is non-empty. +func (r Result) HasErrors() bool { + return len(r.Errors) > 0 +} + +// Validate runs business-logic checks against bp (an unstructured +// Blueprint CR) and returns a Result. The catalog parameter holds +// known Blueprint names — pass an empty set in unit tests if dependency +// resolution is not under test. +func Validate(bp *unstructured.Unstructured, catalog map[string]struct{}) Result { + var res Result + + if bp == nil { + res.Errors = append(res.Errors, "blueprint object is nil") + return res + } + + name := bp.GetName() + if name == "" { + res.Errors = append(res.Errors, "metadata.name is empty") + } + + spec, found, err := unstructured.NestedMap(bp.Object, "spec") + if err != nil || !found { + res.Errors = append(res.Errors, "spec is missing or not a map") + return res + } + + // --- card.title (CRD enforces required, but reaffirm here so the + // dependent name-vs-title check has a value to use). + cardTitle, _, _ := unstructured.NestedString(spec, "card", "title") + + // --- name vs card.title soft check. + // Two acceptable forms: + // 1. bp- + // 2. matching titleToKebab(card.title) + // Anything else is a *warning* per slice C3 brief. + if name != "" { + titleKebab := titleToKebab(cardTitle) + switch { + case bpNamePattern.MatchString(name): + // Optional tighter check: when bp- form, kebab must + // match titleKebab. We only warn — the existing corpus has + // e.g. `bp-cert-manager-dynadot-webhook` whose card.title + // is "cert-manager DNS-01 (Dynadot)" — close but not exact. + withoutPrefix := strings.TrimPrefix(name, "bp-") + if titleKebab != "" && withoutPrefix != titleKebab { + res.Warnings = append(res.Warnings, fmt.Sprintf( + "metadata.name %q does not match bp- (got %q, kebab(title) = %q); accepted but consider aligning", + name, withoutPrefix, titleKebab, + )) + } + case kebabPattern.MatchString(name) && (titleKebab == "" || name == titleKebab): + // bare kebab matching the title — accepted without warning. + default: + res.Warnings = append(res.Warnings, fmt.Sprintf( + "metadata.name %q is neither bp- nor kebab(card.title) %q; accepted but consider renaming", + name, titleKebab, + )) + } + } + + // --- placementSchema.modes[] — non-empty subset of canonical set. + if pSchema, ok := nestedAsMap(spec, "placementSchema"); ok { + modes, _, _ := unstructured.NestedStringSlice(pSchema, "modes") + // Empty placementSchema = OK (use system default). Empty + // modes[] when placementSchema is present and modes is set = + // error. + if rawModes, hasModes := pSchema["modes"]; hasModes { + if rawModes == nil { + res.Errors = append(res.Errors, "spec.placementSchema.modes is null; expected non-empty array") + } else if len(modes) == 0 { + res.Errors = append(res.Errors, "spec.placementSchema.modes is empty; expected non-empty array") + } + for _, m := range modes { + if _, ok := canonicalPlacementModes[m]; !ok { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.placementSchema.modes contains %q; legal values: single-region, active-active, active-hotstandby", + m, + )) + } + } + } + // Optional: default mode must be one of modes[] (when both set). + if defaultMode, _, _ := unstructured.NestedString(pSchema, "default"); defaultMode != "" { + if _, ok := canonicalPlacementModes[defaultMode]; !ok { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.placementSchema.default = %q; legal values: single-region, active-active, active-hotstandby", + defaultMode, + )) + } + if len(modes) > 0 && !containsString(modes, defaultMode) { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.placementSchema.default = %q is not in modes[]: %v", + defaultMode, modes, + )) + } + } + } + + // --- manifests.source.kind — when present, in canonical set. + if mfsts, ok := nestedAsMap(spec, "manifests"); ok { + if src, ok := nestedAsMap(mfsts, "source"); ok { + kind, _, _ := unstructured.NestedString(src, "kind") + if kind != "" { + if _, ok := canonicalManifestKinds[kind]; !ok { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.manifests.source.kind = %q; legal values: HelmChart, Kustomize, OAM", + kind, + )) + } + } + } + } + + // --- upgrades.from[] — each must be a valid semver range. + if upgrades, ok := nestedAsMap(spec, "upgrades"); ok { + from, _, _ := unstructured.NestedStringSlice(upgrades, "from") + for _, fr := range from { + if err := semver.IsValidRange(fr); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.upgrades.from contains invalid semver range %q: %v", + fr, err, + )) + } + } + // blocks[] uses the same grammar. + blocks, _, _ := unstructured.NestedStringSlice(upgrades, "blocks") + for _, b := range blocks { + if err := semver.IsValidRange(b); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf( + "spec.upgrades.blocks contains invalid semver range %q: %v", + b, err, + )) + } + } + } + + // --- depends[].blueprint resolution against catalog. + // Result.PendingDeps captures unresolved names; the controller + // surfaces a Pending condition for them. This is non-blocking + // per the brief. + if depsRaw, found, err := unstructured.NestedSlice(spec, "depends"); err == nil && found { + for _, d := range depsRaw { + depMap, ok := d.(map[string]interface{}) + if !ok { + // Schema requires depends[] items be objects with a + // `blueprint` key. CRD validation catches this at + // admission, but be defensive — bare-string entries + // have appeared in 5/61 blueprint files historically + // (slice B4 fixed those, but a future regression + // shouldn't crash this controller). + continue + } + depName, _, _ := unstructured.NestedString(depMap, "blueprint") + if depName == "" { + continue + } + if catalog == nil { + continue + } + if _, ok := catalog[depName]; !ok { + // Could be the same Blueprint as `bp-` vs ``. + // Try both forms for resolution. + alt := strings.TrimPrefix(depName, "bp-") + altPrefixed := "bp-" + depName + if _, ok := catalog[alt]; ok { + continue + } + if _, ok := catalog[altPrefixed]; ok { + continue + } + res.PendingDeps = append(res.PendingDeps, depName) + } + } + } + + return res +} + +// nestedAsMap returns m[key] as map[string]interface{} when the value +// is a JSON object, or (nil, false) otherwise. Helper for the multi-step +// nested-map pattern that unstructured.NestedMap on its own can't +// express (it returns nil if the path doesn't exist OR returns an error +// if the path is the wrong type, and the controller treats both as +// "ignore this path"). +func nestedAsMap(m map[string]interface{}, key string) (map[string]interface{}, bool) { + v, ok := m[key] + if !ok || v == nil { + return nil, false + } + mm, ok := v.(map[string]interface{}) + return mm, ok +} + +// containsString reports whether s is in xs. +func containsString(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} diff --git a/core/controllers/blueprint/internal/validate/validate_test.go b/core/controllers/blueprint/internal/validate/validate_test.go new file mode 100644 index 00000000..43bd74bb --- /dev/null +++ b/core/controllers/blueprint/internal/validate/validate_test.go @@ -0,0 +1,369 @@ +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +// loadBlueprint parses raw YAML into an Unstructured. Empty / whitespace +// docs return nil. +func loadBlueprint(t *testing.T, raw []byte) *unstructured.Unstructured { + t.Helper() + if len(strings.TrimSpace(string(raw))) == 0 { + return nil + } + var obj map[string]interface{} + if err := yaml.Unmarshal(raw, &obj); err != nil { + t.Fatalf("yaml unmarshal: %v", err) + } + if obj == nil { + return nil + } + return &unstructured.Unstructured{Object: obj} +} + +// minimalBlueprint returns an Unstructured Blueprint that the validator +// accepts cleanly. +func minimalBlueprint() *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetAPIVersion("catalyst.openova.io/v1") + u.SetKind("Blueprint") + u.SetName("bp-test") + u.Object["spec"] = map[string]interface{}{ + "version": "1.0.0", + "card": map[string]interface{}{ + "title": "Test", + }, + "placementSchema": map[string]interface{}{ + "modes": []interface{}{"single-region"}, + "default": "single-region", + }, + } + return u +} + +func TestValidate_HappyPath(t *testing.T) { + t.Parallel() + bp := minimalBlueprint() + res := Validate(bp, nil) + if res.HasErrors() { + t.Fatalf("expected no errors, got %v", res.Errors) + } + if len(res.PendingDeps) > 0 { + t.Errorf("expected no pending deps, got %v", res.PendingDeps) + } +} + +func TestValidate_PlacementModes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + modes interface{} + def string + wantError bool + }{ + {"valid single", []interface{}{"single-region"}, "", false}, + {"valid multiple", []interface{}{"single-region", "active-active"}, "", false}, + {"invalid mode", []interface{}{"round-robin"}, "", true}, + {"empty array", []interface{}{}, "", true}, + {"null array", nil, "", true}, + {"valid default in modes", []interface{}{"single-region", "active-active"}, "active-active", false}, + {"default not in modes", []interface{}{"single-region"}, "active-active", true}, + {"invalid default value", []interface{}{"single-region"}, "round-robin", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bp := minimalBlueprint() + ps := bp.Object["spec"].(map[string]interface{})["placementSchema"].(map[string]interface{}) + ps["modes"] = tc.modes + if tc.def != "" { + ps["default"] = tc.def + } else { + delete(ps, "default") + } + res := Validate(bp, nil) + if tc.wantError && !res.HasErrors() { + t.Errorf("expected error, got none. result=%+v", res) + } + if !tc.wantError && res.HasErrors() { + t.Errorf("expected no error, got %v", res.Errors) + } + }) + } +} + +func TestValidate_ManifestSourceKind(t *testing.T) { + t.Parallel() + + cases := []struct { + kind string + wantError bool + }{ + {"HelmChart", false}, + {"Kustomize", false}, + {"OAM", false}, + {"helm-chart", true}, // wrong case + {"Docker", true}, + } + for _, tc := range cases { + bp := minimalBlueprint() + bp.Object["spec"].(map[string]interface{})["manifests"] = map[string]interface{}{ + "source": map[string]interface{}{ + "kind": tc.kind, + "ref": "oci://example/test:1.0.0", + }, + } + res := Validate(bp, nil) + if tc.wantError && !res.HasErrors() { + t.Errorf("kind=%q: expected error, got none", tc.kind) + } + if !tc.wantError && res.HasErrors() { + t.Errorf("kind=%q: expected no error, got %v", tc.kind, res.Errors) + } + } + + // Short-form `manifests.chart: ./chart` with no source block must NOT + // trigger an error. Most existing v1alpha1 blueprints use this form. + bp := minimalBlueprint() + bp.Object["spec"].(map[string]interface{})["manifests"] = map[string]interface{}{ + "chart": "./chart", + } + if res := Validate(bp, nil); res.HasErrors() { + t.Errorf("short-form manifests.chart: expected no error, got %v", res.Errors) + } +} + +func TestValidate_UpgradesSemver(t *testing.T) { + t.Parallel() + + // All these forms appear in the existing 61-blueprint corpus and + // must validate cleanly. + good := []string{"0.x", "1.x", "1.2.x", "^1.4", "1.0.0", "~1.4", ">=1.0.0 <2"} + for _, fr := range good { + bp := minimalBlueprint() + bp.Object["spec"].(map[string]interface{})["upgrades"] = map[string]interface{}{ + "from": []interface{}{fr}, + } + if res := Validate(bp, nil); res.HasErrors() { + t.Errorf("upgrades.from=%q: expected no error, got %v", fr, res.Errors) + } + } + + bad := []string{"abc", "1.2.3.4", "v1.0.0"} + for _, fr := range bad { + bp := minimalBlueprint() + bp.Object["spec"].(map[string]interface{})["upgrades"] = map[string]interface{}{ + "from": []interface{}{fr}, + } + if res := Validate(bp, nil); !res.HasErrors() { + t.Errorf("upgrades.from=%q: expected error, got none", fr) + } + } +} + +func TestValidate_DependsResolution(t *testing.T) { + t.Parallel() + + bp := minimalBlueprint() + bp.Object["spec"].(map[string]interface{})["depends"] = []interface{}{ + map[string]interface{}{ + "blueprint": "bp-postgres", + "version": "^1.4", + }, + map[string]interface{}{ + "blueprint": "bp-keycloak", + "version": "^1.0", + }, + } + + // Empty catalog → all deps Pending. + res := Validate(bp, map[string]struct{}{}) + if res.HasErrors() { + t.Errorf("dep-pending must NOT be a hard error, got %v", res.Errors) + } + if len(res.PendingDeps) != 2 { + t.Errorf("expected 2 pending deps, got %v", res.PendingDeps) + } + + // Catalog with bp-postgres present → only bp-keycloak pends. + res = Validate(bp, map[string]struct{}{"bp-postgres": {}}) + if len(res.PendingDeps) != 1 || res.PendingDeps[0] != "bp-keycloak" { + t.Errorf("expected only bp-keycloak pending, got %v", res.PendingDeps) + } + + // Catalog with both present → 0 pending. + res = Validate(bp, map[string]struct{}{"bp-postgres": {}, "bp-keycloak": {}}) + if len(res.PendingDeps) != 0 { + t.Errorf("expected 0 pending deps, got %v", res.PendingDeps) + } + + // Bare-name vs bp-prefixed: Blueprint depends on `bp-postgres` but + // catalog only has `postgres` (or vice versa). Must resolve. + res = Validate(bp, map[string]struct{}{"postgres": {}, "keycloak": {}}) + if len(res.PendingDeps) != 0 { + t.Errorf("bare-name resolution: expected 0 pending, got %v", res.PendingDeps) + } +} + +func TestValidate_NameWarning(t *testing.T) { + t.Parallel() + + // bp- exact match — no warning. + bp := minimalBlueprint() + bp.SetName("bp-test") + bp.Object["spec"].(map[string]interface{})["card"].(map[string]interface{})["title"] = "Test" + if res := Validate(bp, nil); len(res.Warnings) > 0 { + t.Errorf("bp- exact: expected no warnings, got %v", res.Warnings) + } + + // bare kebab matching title — no warning. + bp = minimalBlueprint() + bp.SetName("test") + bp.Object["spec"].(map[string]interface{})["card"].(map[string]interface{})["title"] = "Test" + if res := Validate(bp, nil); len(res.Warnings) > 0 { + t.Errorf("bare kebab title: expected no warnings, got %v", res.Warnings) + } + + // Mismatched name — warning, not error. + bp = minimalBlueprint() + bp.SetName("bp-totally-different") + bp.Object["spec"].(map[string]interface{})["card"].(map[string]interface{})["title"] = "WordPress" + res := Validate(bp, nil) + if res.HasErrors() { + t.Errorf("name mismatch must NOT be an error, got %v", res.Errors) + } + if len(res.Warnings) == 0 { + t.Errorf("name mismatch: expected warning, got none") + } +} + +func TestTitleToKebab(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "WordPress": "wordpress", + "WordPress Tenant": "wordpress-tenant", + "Hetzner CSI": "hetzner-csi", + "kube-prometheus-stack": "kube-prometheus-stack", + "NATS / JetStream": "nats-jetstream", + "": "", + "a_b_c": "a-b-c", + " spaces ": "spaces", + } + for in, want := range cases { + got := titleToKebab(in) + if got != want { + t.Errorf("titleToKebab(%q) = %q, want %q", in, got, want) + } + } +} + +// TestValidate_ExistingBlueprintCorpus loads every real +// `platform/*/blueprint.yaml` from the openova repo and runs the +// business-logic validator. Per slice C3 brief: the controller must +// not regress validation of the existing 61-blueprint corpus. +// +// We tolerate Warnings (the soft name-vs-title mismatch) and +// PendingDeps (the corpus references each other; we don't pre-load the +// full set into the catalog for this run). Errors fail the test. +// +// Path resolution: from this test file at +// `core/controllers/blueprint/internal/validate/validate_test.go`, +// the blueprint corpus lives 5 levels up at `platform/*/blueprint.yaml`. +func TestValidate_ExistingBlueprintCorpus(t *testing.T) { + t.Parallel() + repoRoot := findRepoRoot(t) + pattern := filepath.Join(repoRoot, "platform", "*", "blueprint.yaml") + matches, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(matches) == 0 { + t.Skipf("no blueprint.yaml files under %s; skipping corpus check", pattern) + } + + // Pre-build a synthetic catalog from the corpus filenames so + // inter-corpus depends[] entries resolve. Each + // `platform//blueprint.yaml` adds both `` and + // `bp-` to the catalog (the depends[].blueprint string in + // the corpus uses both forms). + catalog := make(map[string]struct{}, 2*len(matches)) + for _, m := range matches { + dir := filepath.Base(filepath.Dir(m)) + catalog[dir] = struct{}{} + catalog["bp-"+dir] = struct{}{} + } + // Add a couple of well-known ones that some blueprints reference + // but that don't have their own folder yet (e.g. bp-postgres, + // bp-reflector). The validator tolerates pending deps; these + // entries just keep noise down. + catalog["bp-postgres"] = struct{}{} + catalog["bp-reflector"] = struct{}{} + + type failure struct { + path string + errors []string + } + var failures []failure + for _, m := range matches { + raw, err := os.ReadFile(m) + if err != nil { + t.Errorf("read %s: %v", m, err) + continue + } + bp := loadBlueprint(t, raw) + if bp == nil { + t.Errorf("%s: parsed to nil", m) + continue + } + res := Validate(bp, catalog) + if res.HasErrors() { + failures = append(failures, failure{path: m, errors: res.Errors}) + } + } + + t.Logf("validated %d blueprints; %d failed business-logic checks", len(matches), len(failures)) + if len(failures) > 0 { + for _, f := range failures { + t.Errorf("FAIL %s:\n - %s", f.path, strings.Join(f.errors, "\n - ")) + } + } +} + +// findRepoRoot walks upward from the test's CWD until it finds a +// directory containing both `platform/` and `products/` (the +// monorepo's two top-level Blueprint trees). When run via `go test` +// the CWD is the package dir. +func findRepoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := wd + for i := 0; i < 10; i++ { + platformDir := filepath.Join(dir, "platform") + productsDir := filepath.Join(dir, "products") + if isDir(platformDir) && isDir(productsDir) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Skipf("could not find repo root from %s; skipping corpus check", wd) + return "" +} + +func isDir(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +}