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:
parent
6d137f2821
commit
47baa42a50
36
core/controllers/blueprint/Containerfile
Normal file
36
core/controllers/blueprint/Containerfile
Normal 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"]
|
||||
126
core/controllers/blueprint/cmd/main.go
Normal file
126
core/controllers/blueprint/cmd/main.go
Normal 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
|
||||
}
|
||||
56
core/controllers/blueprint/deploy/clusterrole.yaml
Normal file
56
core/controllers/blueprint/deploy/clusterrole.yaml
Normal 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
|
||||
78
core/controllers/blueprint/deploy/deployment.yaml
Normal file
78
core/controllers/blueprint/deploy/deployment.yaml
Normal 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
|
||||
51
core/controllers/blueprint/go.mod
Normal file
51
core/controllers/blueprint/go.mod
Normal 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
|
||||
)
|
||||
158
core/controllers/blueprint/go.sum
Normal file
158
core/controllers/blueprint/go.sum
Normal 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=
|
||||
@ -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 }
|
||||
@ -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
|
||||
}
|
||||
312
core/controllers/blueprint/internal/gitea/client.go
Normal file
312
core/controllers/blueprint/internal/gitea/client.go
Normal 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, "/")
|
||||
}
|
||||
249
core/controllers/blueprint/internal/gitea/client_test.go
Normal file
249
core/controllers/blueprint/internal/gitea/client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
145
core/controllers/blueprint/internal/semver/semver.go
Normal file
145
core/controllers/blueprint/internal/semver/semver.go
Normal 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
|
||||
}
|
||||
62
core/controllers/blueprint/internal/semver/semver_test.go
Normal file
62
core/controllers/blueprint/internal/semver/semver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
336
core/controllers/blueprint/internal/validate/validate.go
Normal file
336
core/controllers/blueprint/internal/validate/validate.go
Normal 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
|
||||
}
|
||||
369
core/controllers/blueprint/internal/validate/validate_test.go
Normal file
369
core/controllers/blueprint/internal/validate/validate_test.go
Normal 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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user