feat(controllers): land blueprint-controller (slice C3, #1095) (#1126)

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) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-08 23:58:51 +04:00 committed by GitHub
parent 6d137f2821
commit 47baa42a50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2980 additions and 0 deletions

View File

@ -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"]

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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=

View File

@ -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.<location-code>.<sovereign-domain>/catalog/<bp-name>/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/<name>/
// - 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.<key>.
func stringFromSpec(bp *unstructured.Unstructured, key string) string {
v, _, _ := unstructured.NestedString(bp.Object, "spec", key)
return v
}
// stringFromStatus safely reads a string at status.<key>.
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/<bp-name>/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 }

View File

@ -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/<org>/<repo> GET probe
// /api/v1/orgs/<org>/repos POST create
// /api/v1/repos/<org>/<repo>/contents/<path> 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":"<v>"` 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
}

View File

@ -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 `<bp-name>`. 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, "/")
}

View File

@ -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/<org>/<repo> GET (probe), POST (create from /orgs/.../repos)
// /api/v1/orgs/<org>/repos POST
// /api/v1/repos/<org>/<repo>/contents/<path...> 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/<org>/repos -> parts: ["", "api", "v1", "orgs", <org>, "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/<org>/<repo>
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/<org>/<repo>/contents/<path...>
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")
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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-<name>` 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-<lowercase-kebab>`.
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-<kebab>
// 2. <kebab> 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-<kebab> 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-<card.title-kebab> (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-<kebab> 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-<x>` vs `<x>`.
// 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
}

View File

@ -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-<kebab(title)> 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-<kebab(title)> 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/<name>/blueprint.yaml` adds both `<name>` and
// `bp-<name>` 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()
}