diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 000000000..1bfd4eb86 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,91 @@ +--- +name: code-reviewer +description: Expert code review specialist for the Theatre Kubernetes extensions project. Reviews Go code, CRD definitions, controllers, webhooks, and Kubernetes manifests for quality, security, correctness, and adherence to project conventions. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +You are a senior Go and Kubernetes engineer performing code reviews on the Theatre project — GoCardless' Kubernetes extensions repository (`github.com/gocardless/theatre/v5`). + +## Project Context + +Theatre provides Kubernetes operators & admission controller webhooks. + +## How to get the diff + +- If given a PR number: run `gh pr diff ` and `gh pr view ` for title/description context +- If given a branch name: `git diff ...` +- Otherwise: `git diff ...HEAD` + +After getting the diff, identify all changed files and **read each one in full** to understand surrounding context before starting the review. + +## When Invoked + +1. Get the diff +2. Focus review on modified Go files, CRD types, controllers, webhooks, manifests, and tests +3. Begin the review immediately without preamble + +## Review Checklist + +### Go Code Quality + +- Code is clear, idiomatic Go — follows standard patterns used elsewhere in the codebase +- Functions and variables are well-named and scoped appropriately +- No duplicated logic; shared helpers belong in `pkg/` +- Proper use of Go error handling (no swallowed errors, errors wrapped with context) +- Interfaces used appropriately; avoid over-abstraction +- Concurrency is safe (race-free); check mutex usage and goroutine lifecycle + +### Kubernetes / Controller Patterns + +- Controllers follow the reconcile loop pattern correctly (idempotent, returns `ctrl.Result`) +- Status conditions updated correctly; avoid patching the full object when a status patch suffices +- RBAC markers (`+kubebuilder:rbac:...`) present and correct for new resource access +- Webhook handlers validate inputs and return informative `admission.Denied` / `admission.Errored` responses +- CRD type changes include updated `+kubebuilder:validation:` markers where appropriate +- No direct `pods/exec` permissions granted unnecessarily (security-sensitive in this codebase) +- The `pkg/recutil` package is used across the board to standardise reconciliation patterns and event emission +- Follow the good practice guides in https://kubebuilder.io/reference/good-practices.html and https://github.com/kubernetes-sigs/controller-runtime/blob/main/FAQ.md. You might open any links in those pages, but do not open exceed going deeper than 2 levels. + +### Security + +- No secrets, API keys, or credentials hardcoded or logged +- Vault integration: secrets fetched at runtime, not embedded in images or manifests +- Admission webhooks fail closed (deny on error) where appropriate +- RBAC minimal-privilege: roles grant only required verbs/resources + +### Testing + +- New behaviour is covered by tests at the appropriate level (unit → integration → acceptance) +- Ginkgo tests use `Describe`/`Context`/`It` structure consistent with existing suites +- Integration tests use `envtest`; acceptance tests use the Kind cluster via `cmd/acceptance` +- No tests deleted or weakened without explicit justification + +### Manifests & Kustomize + +- CRD changes regenerated via `make manifests generate` +- Kustomize overlays reference versioned bases (`?ref=vX.Y.Z`) +- New CRDs included in both `config/crd` and referenced in `config/base` if needed + +## Output format + +``` +## Code Review: + +### 🔴 Critical Issues +- [file:line] + +### 🟡 Major Concerns +- [file:line] + +### 🔵 Minor Improvements +- [file:line] + +### ✅ Positive Observations +- + +### Summary & Next Steps +<1-3 sentence overall assessment and clear recommended actions> +``` + +Only include sections that have content. Always include file and line number. Explain _why_ each issue matters, not just what it is. diff --git a/.github/workflows/build-integration.yml b/.github/workflows/build-integration.yml index 570e084a5..0eefb3f8e 100644 --- a/.github/workflows/build-integration.yml +++ b/.github/workflows/build-integration.yml @@ -9,6 +9,7 @@ on: pull_request: branches: - master + - pre-release-5.1.0 types: - opened - reopened @@ -17,6 +18,7 @@ on: push: branches: - master + - pre-release-5.1.0 workflow_dispatch: @@ -29,7 +31,7 @@ jobs: name: Set up Go uses: actions/setup-go@v6 with: - go-version: 1.24.5 + go-version: 1.25.5 - name: Ensure generated CRDs and manifests are up to date run: make manifests && git diff --exit-code config/ @@ -68,7 +70,7 @@ jobs: chmod a+x /usr/local/bin/kustomize /usr/local/bin/kubectl /usr/local/bin/kind EOF - name: Prepare the cluster - run: bin/acceptance.linux prepare --verbose && sleep 10 + run: bin/acceptance.linux prepare --verbose && sleep 30 - name: Run acceptance tests run: bin/acceptance.linux run --verbose - name: Show all pods diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..e98dc1e61 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,41 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') && + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@3ac52d0da9f8ec9ca7b4dc23bb477e36ef9c77a9 # v1.0.79 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--agent code-reviewer" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21a244c8d..264781c22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v6 with: - go-version: 1.24.5 + go-version: 1.25.5 - name: Create tag for new version run: | CURRENT_VERSION="v$(cat VERSION)" diff --git a/.gitignore b/.gitignore index 04ce36270..a735a234a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ go.work .vscode *.swp *.swo -*~ \ No newline at end of file +*~ + +# Ignore tmp folder files used by air +tmp/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1362cc202 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# Theatre — CLAUDE.md + +## Project Overview + +Theatre is GoCardless' Kubernetes extensions project, providing operators, admission controller webhooks, and supporting CLIs. The Go module is `github.com/gocardless/theatre/v5`. + +## Repository Structure + +- `api/` — CRD API types (RBAC, Vault, Workloads) +- `internal/` — Controllers and webhooks (unexported) +- `pkg/` — Shared/exported packages +- `cmd/` — CLI entry points (`rbac-manager`, `vault-manager`, `workloads-manager`, `theatre-consoles`, `theatre-secrets`, `acceptance`) +- `config/` — Kustomize manifests, CRDs, base configs + +## Key API Groups + +- **RBAC** (`rbac.crd.gocardless.com`) — `DirectoryRoleBinding`: provisions `RoleBinding`s from Google group members +- **Workloads** (`workloads.crd.gocardless.com`) — `Console`: temporary dedicated pods for operational tasks +- **Vault** (`vault.crd.gocardless.com`) — `secrets-injector` webhook for injecting Vault secrets into pods +- **Deploy** (`deploy.crd.gocardless.com`) — `Release`, `Rollback`, `AutomatedRollbackPolicy`: release management and rollback controls + +## Build & Development Commands + +```shell +make build # Build all binaries +make test # Run unit + integration tests (requires setup-envtest) +make lint # Run golangci-lint +make lint-fix # Run golangci-lint with auto-fix +make fmt # go fmt ./... +make vet # go vet ./... +make generate # Generate DeepCopy methods via controller-gen +make manifests # Generate CRDs/RBAC/Webhook configs via controller-gen +make acceptance-e2e # Full E2E: prepare Kind cluster + run + destroy +make acceptance-run # Run acceptance tests against existing cluster +make install-tools # Download all dev tool binaries into ./bin/ +``` + +## Testing + +Tests use [Ginkgo](https://onsi.github.io/ginkgo) and run with `-race -randomizeSuites -randomizeAllSpecs`. + +**Setup before running tests:** + +```shell +make install-tools +eval $(setup-envtest use -i -p env 1.24.x) +``` + +Three test levels: + +- **Unit** — `make test` (fast, no cluster needed) +- **Integration** — `make test` (uses `envtest` with a temporary API server, no nodes) +- **Acceptance** — `make acceptance-e2e` (full Kind cluster, slow, used sparingly) + +## Local Development Cluster (Kind) + +```shell +make build +make test +make acceptance-e2e +# or step-by-step: +make prepare # provisions Kind cluster +make acceptance-run --verbose +make acceptance-destroy +``` + +Re-run `make acceptance-prepare` after any code changes to rebuild and redeploy images. + +## Toolchain + +- **Go** +- **controller-gen** — CRD/webhook/RBAC manifest generation +- **kustomize** +- **golangci-lint** +- **ginkgo** +- **setup-envtest** — manages `etcd`/`kube-apiserver` binaries for integration tests +- **kind** — Kubernetes-in-Docker for acceptance tests + +All tool binaries are installed locally into `./bin/` via `make install-tools`. diff --git a/Dockerfile b/Dockerfile index c85f948b2..10ef02224 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build Go binary without cgo dependencies -FROM golang:1.24.5 as builder +FROM golang:1.25.5 as builder WORKDIR /go/src/github.com/gocardless/theatre COPY . /go/src/github.com/gocardless/theatre diff --git a/Makefile b/Makefile index 676ca1457..552bfd322 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PROG=bin/rbac-manager bin/vault-manager bin/theatre-secrets bin/workloads-manager bin/theatre-consoles +PROG=bin/rbac-manager bin/vault-manager bin/theatre-secrets bin/workloads-manager bin/theatre-consoles bin/release-manager bin/rollback-manager bin/automated-rollback-manager PROJECT=github.com/gocardless/theatre IMAGE=eu.gcr.io/gc-containers/gocardless/theatre VERSION=$(shell git describe --tags --dirty --long) @@ -58,7 +58,7 @@ vet: ## Run go vet against code. go vet ./... test: manifests generate fmt vet setup-envtest setup-ginkgo ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" ginkgo -race -randomizeSuites -randomizeAllSpecs -r ./... + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" ginkgo -race -randomize-suites -randomize-all -r ./... acceptance-e2e: install-tools acceptance-prepare acceptance-run acceptance-destroy ## Requires the following binaries: kubectl, kustomize, kind, docker @@ -99,7 +99,7 @@ bin/%: ## Build a binary CGO_ENABLED=0 $(BUILD_COMMAND) -o $@ ./cmd/$*/. clean: ## Clean up the build artifacts - rm -rvf $(PROG) $(PROG:%=%.linux) $(PROG:%=%.darwin) hack/boilerplate.go.txt + rm -rvf $(PROG) $(PROG:%=%.linux) $(PROG:%=%.darwin) # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. @@ -139,7 +139,7 @@ ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') GOLANGCI_LINT_VERSION ?= v2.4.0 -GINKGO_VERSION ?= v1.16.5 +GINKGO_VERSION ?= v2.27.4 install-tools: kustomize controller-gen setup-envtest golangci-lint setup-ginkgo @@ -160,7 +160,7 @@ setup-envtest: envtest ## Download the binaries required for ENVTEST in the loca setup-ginkgo: $(GINKGO) ## Download ginkgo locally if necessary. $(GINKGO): $(LOCALBIN) - go install github.com/onsi/ginkgo/ginkgo@$(GINKGO_VERSION) + go install github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION) envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. $(ENVTEST): $(LOCALBIN) diff --git a/PROJECT b/PROJECT index 5a03d6f4c..eeaa3fcd1 100644 --- a/PROJECT +++ b/PROJECT @@ -28,4 +28,31 @@ resources: kind: Console path: github.com/gocardless/theatre/api/workloads/v1alpha1 version: v1alpha1 + - api: + crdVersion: v1 + namespaced: true + controller: true + domain: crd.gocardless.com + group: deploy + kind: Release + path: github.com/gocardless/theatre/api/deploy/v1alpha1 + version: v1alpha1 + - api: + crdVersion: v1 + namespaced: true + controller: true + domain: crd.gocardless.com + group: deploy + kind: Rollback + path: github.com/gocardless/theatre/api/deploy/v1alpha1 + version: v1alpha1 + - api: + crdVersion: v1 + namespaced: true + controller: true + domain: crd.gocardless.com + group: deploy + kind: AutomatedRollbackPolicy + path: github.com/gocardless/theatre/api/deploy/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/deploy/v1alpha1/README.md b/api/deploy/v1alpha1/README.md new file mode 100644 index 000000000..d1ce60245 --- /dev/null +++ b/api/deploy/v1alpha1/README.md @@ -0,0 +1,127 @@ +# Deploy CRDs + +The deployment CRDs are a set of API definitions that are used to provide release, +release health analysis, and rollback management. All of the CRDs define a `target` +field that specifies the resource to which the CRD applies. Target is a GoCardless +specific field that is used to identify the resource to which the CRD applies. + +- Release - records release information like a set of revisions +- Rollback - records rollback information, like from/to which release, deployment options +- AutomatedRollbackPolicy - controls automated rollback behavior (trigger, rollback deployment options) + +## Release + +**Short name:** `rel` + +Records a deployment event for a given target. Each `Release` captures a set of +revisions (e.g. git commit SHAs, container image digests, Helm chart versions) that +were deployed together, and tracks the lifecycle of that deployment through status +conditions. + +### Spec (`config`) + +| Field | Required | Description | +| ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `targetName` | Yes | Namespace-unique identifier for the release target | +| `revisions` | Yes | List of revisions (1–10 items). Each revision has `name`, `id`, `source`, `type`, and optional `metadata` (author, branch, message) | + +### Status + +| Field | Description | +| --------------------- | ------------------------------------------------------------ | +| `conditions` | Observed conditions: `Active`, `Healthy`, `RollbackRequired` | +| `message` | Human-readable state description | +| `deploymentStartTime` | When the deployment started | +| `deploymentEndTime` | When the deployment completed | +| `previousRelease` | Reference to the release that was superseded | +| `signature` | Deterministic hash of the revision names and IDs | + +### Conditions + +| Condition | Status=True | Status=False | +| ------------------ | ----------------------------------- | ------------------------------ | +| `Active` | Release is actively serving traffic | Release has been superseded | +| `Healthy` | Release passed health analysis | Release failed health analysis | +| `RollbackRequired` | Release should be rolled back | Release does not need rollback | + +--- + +## Rollback + +**Short name:** `rb` + +Represents a historical record of a rollback operation. A `Rollback` resource is +created (manually or automatically) to roll a target back to a previously healthy +`Release`. The controller carries out the rollback via the CI/CD system and tracks +progress through status conditions. + +### Spec + +| Field | Required | Description | +| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| `toReleaseRef.target` | Yes | Target name identifying which release target to roll back (immutable) | +| `toReleaseRef.name` | No | Name of the specific `Release` to roll back to; if empty the controller picks the latest healthy release | +| `reason` | Yes | Human-readable explanation for why the rollback was initiated (1–512 chars) | +| `initiatedBy.principal` | No | Identifier of the person or system that triggered the rollback | +| `initiatedBy.type` | No | Type of initiator, e.g. `user` or `system` | +| `deploymentOptions` | No | Provider-specific options passed to the CI/CD system | + +### Status + +| Field | Description | +| ---------------- | --------------------------------------------------------------------- | +| `conditions` | Observed conditions: `InProgress`, `Succeeded` | +| `message` | Human-readable state description | +| `fromReleaseRef` | The release being rolled back from | +| `automatic` | Whether this rollback was triggered automatically | +| `startTime` | When the rollback operation started | +| `completionTime` | When the rollback operation completed | +| `deploymentID` | Unique identifier for the CI/CD deployment job | +| `deploymentURL` | URL to the CI job performing the rollback | +| `attemptCount` | Number of times the controller has attempted to initiate the rollback | + +### Conditions + +| Condition | Status=True | Status=False | +| ------------ | -------------------------------------------------- | ----------------------------------------- | +| `InProgress` | Rollback is in progress (e.g. ArgoCD sync running) | Rollback has not started or has completed | +| `Succeeded` | Rollback completed successfully | Rollback has not yet succeeded | + +--- + +## AutomatedRollbackPolicy + +**Short name:** `arbp` + +Controls whether the operator should automatically create a `Rollback` resource when +a `Release` for a given target enters a trigger condition. The policy can be enabled +or disabled, and the controller will disable it automatically after performing one +automated rollback to prevent rollback loops. + +### Spec + +| Field | Required | Description | +| ----------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- | +| `targetName` | Yes | Identifies which releases this policy applies to, matching `Release.config.targetName` (immutable) | +| `enabled` | Yes | Whether automated rollbacks are active (default: `false`) | +| `trigger.conditionType` | No | The `Release` condition type to watch (default: `RollbackRequired`) | +| `trigger.conditionStatus` | No | The condition status value that triggers a rollback (`True` or `False`, default: `True`) | +| `rollbackTemplate.metadata.labels` | No | Labels to apply to the created `Rollback` resource | +| `rollbackTemplate.metadata.annotations` | No | Annotations to apply to the created `Rollback` resource | +| `rollbackTemplate.spec.deploymentOptions` | No | Provider-specific options passed to the created `Rollback` spec | + +### Status + +| Field | Description | +| --------------------------- | --------------------------------------------------------------- | +| `conditions` | Observed conditions: `Automated` | +| `lastAutomatedRollbackTime` | Timestamp of the last automated rollback created by this policy | + +### Conditions + +| Condition | Status=True | Status=False | +| ----------- | ------------------------------- | -------------------------------- | +| `Automated` | Automated rollbacks are enabled | Automated rollbacks are disabled | + +Reason values: `SetByUser` (user explicitly configured it) or `DisabledByController` +(controller disabled it after performing an automated rollback). diff --git a/api/deploy/v1alpha1/annotations.go b/api/deploy/v1alpha1/annotations.go new file mode 100644 index 000000000..5a220d0bf --- /dev/null +++ b/api/deploy/v1alpha1/annotations.go @@ -0,0 +1,37 @@ +package v1alpha1 + +const ( + AnnotationKeyBase = "theatre.gocardless.com" + + // AnnotationKeyReleaseActivate is an annotation that can be set on a Release + // to set status.conditions.active` to `true`. + AnnotationKeyReleaseActivate = AnnotationKeyBase + "/active" + + // AnnotationValueReleaseActivateTrue is the only valid value for + // AnnotationKeyReleaseActivate. + AnnotationValueReleaseActivateTrue = "true" + + // AnnotationKeyReleaseDeploymentStartTime is an annotation that can be set on a Release + // to set `status.deploymentStartTime`. + AnnotationKeyReleaseDeploymentStartTime = AnnotationKeyBase + "/deployment-start-time" + + // AnnotationKeyReleaseDeploymentEndTime is an annotation that can be set on a Release + // to set `status.deploymentEndTime`. + AnnotationKeyReleaseDeploymentEndTime = AnnotationKeyBase + "/deployment-end-time" + + // AnnotationKeyReleasePreviousRelease is an annotation that can be set on a Release + // to set `status.previousRelease.releaseRef`. + AnnotationKeyReleasePreviousRelease = AnnotationKeyBase + "/previous-release" + + // AnnotationKeyReleaseAnalysisTemplateSelector is the name of the annotation + // containing optional analysis template selectors + AnnotationKeyReleaseAnalysisTemplateSelector = AnnotationKeyBase + "/analysis-selector" + + // AnnotationKeyReleaseNoGlobalAnalysis is the name of the annotation + // to opt-out of using global analysis templates for the release + AnnotationKeyReleaseNoGlobalAnalysis = AnnotationKeyBase + "/no-global-analysis" + + // AnnotationKeyReleaseLimit is an annotation that can be set on a namespace + // to limit the number of releases per target. + AnnotationKeyReleaseLimit = AnnotationKeyBase + "/release-limit" +) diff --git a/api/deploy/v1alpha1/automated_rollback_policy_types.go b/api/deploy/v1alpha1/automated_rollback_policy_types.go new file mode 100644 index 000000000..a656b6b23 --- /dev/null +++ b/api/deploy/v1alpha1/automated_rollback_policy_types.go @@ -0,0 +1,140 @@ +package v1alpha1 + +import ( + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RollbackTemplate groups all configuration that the controller applies +// when creating a Rollback resource. +type RollbackTemplate struct { + // Metadata fields applied to the created Rollback resource. + // +kubebuilder:validation:Optional + Metadata RollbackTemplateMetadata `json:"metadata,omitempty"` + + // Spec fields applied to the created Rollback resource's spec. + // +kubebuilder:validation:Optional + Spec RollbackTemplateSpec `json:"spec,omitempty"` +} + +// RollbackTemplateMetadata contains metadata fields applied to the created Rollback resource. +type RollbackTemplateMetadata struct { + // Labels to add to the Rollback resource. + // +kubebuilder:validation:Optional + Labels map[string]string `json:"labels,omitempty"` + + // Annotations to add to the Rollback resource. + // +kubebuilder:validation:Optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +// RollbackTemplateSpec contains spec fields applied to the created Rollback resource. +type RollbackTemplateSpec struct { + // DeploymentOptions contains additional rollback provider-specific options. + // +kubebuilder:validation:Optional + DeploymentOptions map[string]apiextv1.JSON `json:"deploymentOptions,omitempty"` +} + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +const ( + // AutomatedRollbackPolicyConditionActive indicates whether the automated rollback policy is enabled. + // Status=True means the automated rollback policy is enabled. + // Status=False means the automated rollback policy is disabled. + AutomatedRollbackPolicyConditionActive = "Automated" + + // AutomatedRollbackPolicyReasonSetByUser indicates that the automated rollback policy is set by the user. + AutomatedRollbackPolicyReasonSetByUser = "SetByUser" + + // AutomatedRollbackPolicyReasonDisabledByController indicates that the automated rollback policy is disabled + // because the controller has disabled it after an automated rollback has been performed. + AutomatedRollbackPolicyReasonDisabledByController = "DisabledByController" +) + +// AutomatedRollbackPolicySpec defines the desired state +type AutomatedRollbackPolicySpec struct { + // TargetName identifies which releases this policy applies to, + // matching Release.config.targetName. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="TargetName is immutable" + TargetName string `json:"targetName"` + + // Trigger defines the Release condition that triggers a rollback. + // +optional + Trigger RollbackTrigger `json:"trigger,omitempty"` + + // Enabled controls whether automated rollbacks are active. + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // RollbackTemplate groups all configuration applied to the Rollback resource + // created by the controller. + // +kubebuilder:validation:Optional + RollbackTemplate RollbackTemplate `json:"rollbackTemplate,omitempty"` +} + +// RollbackTrigger defines the Release condition that triggers a rollback +type RollbackTrigger struct { + // ConditionType is the Release status condition type to watch. + // +kubebuilder:default="RollbackRequired" + // +kubebuilder:validation:Optional + ConditionType string `json:"conditionType,omitempty"` + + // ConditionStatus is the status value that triggers a rollback. + // +kubebuilder:default="True" + // +kubebuilder:validation:Enum=True;False + // +optional + ConditionStatus metav1.ConditionStatus `json:"conditionStatus,omitempty"` +} + +// AutomatedRollbackPolicyStatus defines the observed state +type AutomatedRollbackPolicyStatus struct { + // LastAutomatedRollbackTime is when the last automated rollback was created. + LastAutomatedRollbackTime *metav1.Time `json:"lastAutomatedRollbackTime,omitempty"` + + // Conditions represent the latest observations of the policy's state. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=arbp +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=".spec.targetName" +// +kubebuilder:printcolumn:name="Trigger_Condition",type=string,JSONPath=".spec.trigger.conditionType" +// +kubebuilder:printcolumn:name="Trigger_When",type=string,JSONPath=".spec.trigger.conditionStatus" +// +kubebuilder:printcolumn:name="Automated",type=string,JSONPath=".status.conditions[?(@.type==\"Automated\")].status" +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=".status.conditions[?(@.type==\"Automated\")].reason" +// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=".status.conditions[?(@.type==\"Automated\")].message",priority=10 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" + +// AutomatedRollbackPolicy is the Schema for the automatedrollbackpolicies API. +type AutomatedRollbackPolicy struct { + metav1.TypeMeta `json:",inline"` + + // Metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec defines the desired state of AutomatedRollbackPolicy + // +required + Spec AutomatedRollbackPolicySpec `json:"spec"` + + // Status defines the observed state of AutomatedRollbackPolicy + // +optional + Status AutomatedRollbackPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AutomatedRollbackPolicyList contains a list of AutomatedRollbackPolicy +type AutomatedRollbackPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AutomatedRollbackPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AutomatedRollbackPolicy{}, &AutomatedRollbackPolicyList{}) +} diff --git a/api/deploy/v1alpha1/groupversion_info.go b/api/deploy/v1alpha1/groupversion_info.go new file mode 100644 index 000000000..6a0938d0f --- /dev/null +++ b/api/deploy/v1alpha1/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1alpha1 contains API Schema definitions for the deploy v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=deploy.crd.gocardless.com +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "deploy.crd.gocardless.com", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/deploy/v1alpha1/helpers.go b/api/deploy/v1alpha1/helpers.go new file mode 100644 index 000000000..61ab6b5aa --- /dev/null +++ b/api/deploy/v1alpha1/helpers.go @@ -0,0 +1,250 @@ +package v1alpha1 + +import ( + "bytes" + "cmp" + "crypto/sha256" + "encoding/json" + "fmt" + "slices" + "time" + + "github.com/gocardless/theatre/v5/pkg/recutil" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + SignatureLength = 10 +) + +// Rollback helpers + +func (rollback *Rollback) IsCompleted() bool { + return recutil.IsConditionStatusKnown(rollback.Status.Conditions, []string{RollbackConditionSucceded}) +} + +// GetEffectiveTime returns the effective time of the rollback, which is the completion time +// if set, otherwise the creation time. +func (rollback *Rollback) GetEffectiveTime() time.Time { + if rollback.Status.CompletionTime.IsZero() { + return rollback.ObjectMeta.CreationTimestamp.Time + } + return rollback.Status.CompletionTime.Time +} + +func FindInProgressRollback(rollbackList *RollbackList) *Rollback { + for _, rollback := range rollbackList.Items { + if meta.IsStatusConditionTrue(rollback.Status.Conditions, RollbackConditionInProgress) { + return &rollback + } + } + return nil +} + +// Release helpers + +func (releaseConfig *ReleaseConfig) Equals(other *ReleaseConfig) bool { + return bytes.Equal(releaseConfig.Serialise(), other.Serialise()) +} + +// The serialisation is used to determine if a release has changed. +// For release uniqueness we only take into consideration the target name, +// revision.name and revision.id. +func (releaseConfig *ReleaseConfig) Serialise() []byte { + canonical := ReleaseConfig{ + TargetName: releaseConfig.TargetName, + Revisions: releaseConfig.Revisions, + } + + for _, revision := range canonical.Revisions { + var canonicalRevision Revision + canonicalRevision.Name = revision.Name + canonicalRevision.ID = revision.ID + + canonical.Revisions = append(canonical.Revisions, canonicalRevision) + } + + slices.SortFunc(canonical.Revisions, func(a, b Revision) int { + return cmp.Compare(a.Name, b.Name) + }) + + bytes, _ := json.Marshal(canonical) + + return bytes +} + +func (release *Release) IsStatusInitialised() bool { + return meta.FindStatusCondition(release.Status.Conditions, ReleaseConditionActive) != nil && + release.Status.Signature != "" +} + +func (release *Release) IsAnalysisStatusKnown() bool { + return recutil.IsConditionStatusKnown(release.Status.Conditions, []string{ + ReleaseConditionHealthy, + ReleaseConditionRollbackRequired, + }) +} + +func (release *Release) GenerateSignature() string { + return fmt.Sprintf("%x", sha256.Sum256(release.ReleaseConfig.Serialise())) +} + +func (release *Release) InitialiseStatus(message string) { + if message == "" { + message = "Release initialised successfully" + } + release.Status.Message = message + release.Status.Signature = release.GenerateSignature()[:SignatureLength] + + release.setConditionActive(metav1.ConditionUnknown, ReasonInitialised, message) +} + +func (release *Release) SetDeploymentStartTime(timestamp metav1.Time) { + release.Status.DeploymentStartTime = timestamp +} + +func (release *Release) SetDeploymentEndTime(timestamp metav1.Time) { + release.Status.DeploymentEndTime = timestamp +} + +func (release *Release) Activate(message string) { + release.Status.Message = message + release.setConditionActive(metav1.ConditionTrue, ReasonDeployed, message) +} + +func (release *Release) Deactivate(message string) { + release.Status.Message = message + release.setConditionActive(metav1.ConditionFalse, ReasonSuperseded, message) +} + +func (release *Release) IsConditionActiveTrue() bool { + return meta.IsStatusConditionTrue(release.Status.Conditions, ReleaseConditionActive) +} + +func (release *Release) setConditionActive(status metav1.ConditionStatus, reason, message string) { + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: ReleaseConditionActive, + Status: status, + Reason: reason, + Message: message, + }) +} + +func (release *Release) SetPreviousRelease(previousRelease string) { + release.Status.PreviousRelease.ReleaseRef = previousRelease + if previousRelease != "" { + release.Status.PreviousRelease.TransitionTime = metav1.Now() + } else { + release.Status.PreviousRelease.TransitionTime = metav1.Time{} + } +} + +func FindActiveRelease(releaseList *ReleaseList) *Release { + for _, release := range releaseList.Items { + if meta.IsStatusConditionTrue(release.Status.Conditions, ReleaseConditionActive) { + return &release + } + } + return nil +} + +// FindLastHealthyRelease walks back from the active release using PreviousRelease +// to find the most recent healthy release that is not the active release itself +func FindLastHealthyRelease(releaseList *ReleaseList) *Release { + activeRelease := FindActiveRelease(releaseList) + if activeRelease == nil { + return nil + } + + releaseMap := make(map[string]*Release) + for i := range releaseList.Items { + release := &releaseList.Items[i] + releaseMap[release.Name] = release + } + + // Start from the previous release of the active one + prevRef := activeRelease.Status.PreviousRelease.ReleaseRef + if prevRef == "" { + return nil + } + + // Walk back through the release chain + visited := make(map[string]bool) + currentRef := prevRef + + for currentRef != "" && !visited[currentRef] { + visited[currentRef] = true + + release, ok := releaseMap[currentRef] + if !ok { + // Release not found, stop walking + break + } + + // Check if this release is healthy + if meta.IsStatusConditionTrue(release.Status.Conditions, ReleaseConditionHealthy) { + return release + } + + // Move to the previous release + currentRef = release.Status.PreviousRelease.ReleaseRef + } + + return nil +} + +// Returns the effective time of the release, which is the deployment end time, +// if set, otherwise the creation time. +func (r *Release) GetEffectiveTime() time.Time { + if r.Status.DeploymentEndTime.IsZero() { + return r.ObjectMeta.CreationTimestamp.Time + } + return r.Status.DeploymentEndTime.Time +} + +// AutomatedRollbackPolicy helpers + +// PolicyEvaluation is used as a result of evaluating the constraints of an automated rollback policy. +// It indicates whether the policy is allowed to trigger with the relevant reason and message. +// +kubebuilder:object:generate=false +type PolicyEvaluation struct { + Allowed bool + Reason string + Message string +} + +// EvaluatePolicyConstraints evaluates the constraints of the automated rollback policy +// and returns a policy evaluation, which indicates whether the policy is allowed to trigger +// with the relevant reason and message. +func (policy *AutomatedRollbackPolicy) EvaluatePolicyConstraints(release *Release) PolicyEvaluation { + if !policy.Spec.Enabled { + return PolicyEvaluation{ + Allowed: false, + Reason: AutomatedRollbackPolicyReasonSetByUser, + Message: "Automated rollback policy is disabled", + } + } + + // Check if the trigger condition on the release has recovered and whether automated rollback can be re-enabled + if release != nil && meta.IsStatusConditionFalse(policy.Status.Conditions, AutomatedRollbackPolicyConditionActive) { + + releaseRecovered := recutil.IsConditionStatusKnown(release.Status.Conditions, []string{policy.Spec.Trigger.ConditionType}) && + !meta.IsStatusConditionPresentAndEqual(release.Status.Conditions, policy.Spec.Trigger.ConditionType, policy.Spec.Trigger.ConditionStatus) + automatedCond := meta.FindStatusCondition(policy.Status.Conditions, AutomatedRollbackPolicyConditionActive) + + if !releaseRecovered { + return PolicyEvaluation{ + Allowed: false, + Reason: automatedCond.Reason, + Message: automatedCond.Message, + } + } + } + + return PolicyEvaluation{ + Allowed: true, + Reason: AutomatedRollbackPolicyReasonSetByUser, + Message: "Automated rollback is enabled", + } +} diff --git a/api/deploy/v1alpha1/helpers_test.go b/api/deploy/v1alpha1/helpers_test.go new file mode 100644 index 000000000..2cf78210e --- /dev/null +++ b/api/deploy/v1alpha1/helpers_test.go @@ -0,0 +1,305 @@ +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Helpers", func() { + Context("Release", func() { + Context("Equals", func() { + var a, b ReleaseConfig + + BeforeEach(func() { + a = ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "application", ID: "abc123"}, + {Name: "infrastructure", ID: "xyz789"}, + }, + } + b = ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "application", ID: "abc123"}, + {Name: "infrastructure", ID: "xyz789"}, + }, + } + }) + + It("Should be equal for identical configs", func() { + Expect(a.Equals(&b)).To(BeTrue()) + }) + + It("Should not be equal if targetNames are different", func() { + b.TargetName = "different-target" + Expect(a.Equals(&b)).To(BeFalse()) + }) + + It("Should not be equal if revisions are different", func() { + b.Revisions[0].ID = "different-id" + Expect(a.Equals(&b)).To(BeFalse()) + }) + + It("Should not be equal if number of revisions are different", func() { + b.Revisions = append(b.Revisions, Revision{Name: "additional", ID: "extra123"}) + Expect(a.Equals(&b)).To(BeFalse()) + }) + + It("Should not be equal if revision names are different", func() { + b.Revisions[0].Name = "different-app" + Expect(a.Equals(&b)).To(BeFalse()) + }) + }) + + Context("InitialiseStatus", func() { + var release Release + + BeforeEach(func() { + release = Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "application", ID: "abc123"}, + }, + }, + } + }) + + It("should set the signature when initialised", func() { + Expect(release.Status.Signature).To(BeEmpty()) + + release.InitialiseStatus("test message") + + Expect(release.Status.Signature).NotTo(BeEmpty()) + Expect(release.Status.Signature).To(HaveLen(SignatureLength)) + }) + + It("should set conditions when initialised", func() { + Expect(release.Status.Conditions).To(BeEmpty()) + + release.InitialiseStatus("test message") + + Expect(release.Status.Conditions).To(ContainElement(HaveField("Type", ReleaseConditionActive))) + }) + + It("should set the message when initialised", func() { + release.InitialiseStatus("custom message") + + Expect(release.Status.Message).To(Equal("custom message")) + }) + + It("should use default message when empty string provided", func() { + release.InitialiseStatus("") + + Expect(release.Status.Message).To(Equal("Release initialised successfully")) + }) + }) + + Context("Signature", func() { + It("should produce different signatures for releases with different target names", func() { + releaseA := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "target-a", + Revisions: []Revision{ + {Name: "app", ID: "abc123"}, + }, + }, + } + releaseB := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "target-b", + Revisions: []Revision{ + {Name: "app", ID: "abc123"}, + }, + }, + } + + releaseA.InitialiseStatus("init") + releaseB.InitialiseStatus("init") + + Expect(releaseA.Status.Signature).NotTo(Equal(releaseB.Status.Signature)) + }) + + It("should produce different signatures for releases with different revision IDs", func() { + releaseA := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app", ID: "abc123"}, + }, + }, + } + releaseB := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app", ID: "xyz789"}, + }, + }, + } + + releaseA.InitialiseStatus("init") + releaseB.InitialiseStatus("init") + + Expect(releaseA.Status.Signature).NotTo(Equal(releaseB.Status.Signature)) + }) + + It("should produce different signatures for releases with different revision names", func() { + releaseA := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app-a", ID: "abc123"}, + }, + }, + } + releaseB := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app-b", ID: "abc123"}, + }, + }, + } + + releaseA.InitialiseStatus("init") + releaseB.InitialiseStatus("init") + + Expect(releaseA.Status.Signature).NotTo(Equal(releaseB.Status.Signature)) + }) + + It("should produce identical signatures for identical release configs", func() { + releaseA := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app", ID: "abc123"}, + }, + }, + } + releaseB := Release{ + ReleaseConfig: ReleaseConfig{ + TargetName: "test-target", + Revisions: []Revision{ + {Name: "app", ID: "abc123"}, + }, + }, + } + + releaseA.InitialiseStatus("init") + releaseB.InitialiseStatus("init") + + Expect(releaseA.Status.Signature).To(Equal(releaseB.Status.Signature)) + }) + }) + }) + + Context("AutomatedRollbackPolicy", func() { + var ( + policy AutomatedRollbackPolicy + ) + + BeforeEach(func() { + policy = AutomatedRollbackPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "default", + }, + Spec: AutomatedRollbackPolicySpec{ + TargetName: "test-target", + Trigger: RollbackTrigger{ + ConditionType: ReleaseConditionRollbackRequired, + ConditionStatus: metav1.ConditionTrue, + }, + }, + Status: AutomatedRollbackPolicyStatus{}, + } + }) + + Context("evaluatePolicyConstraints", func() { + Context("when spec.enabled=false", func() { + It("should return allowed=false with reason SetByUser", func() { + policy.Spec.Enabled = false + result := policy.EvaluatePolicyConstraints(nil) + Expect(result.Allowed).To(BeFalse()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonSetByUser)) + Expect(result.Message).To(Equal("Automated rollback policy is disabled")) + }) + }) + + Context("when spec.enabled=true", func() { + BeforeEach(func() { + policy.Spec.Enabled = true + }) + + It("should return allowed=true", func() { + result := policy.EvaluatePolicyConstraints(nil) + Expect(result.Allowed).To(BeTrue()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonSetByUser)) + }) + + Context("when policy is disabled by the controller", func() { + var release *Release + + BeforeEach(func() { + meta.SetStatusCondition(&policy.Status.Conditions, metav1.Condition{ + Type: AutomatedRollbackPolicyConditionActive, + Status: metav1.ConditionFalse, + Reason: AutomatedRollbackPolicyReasonDisabledByController, + }) + + release = &Release{ + Status: ReleaseStatus{}, + } + }) + + It("should return allowed=true when release has recovered from failure", func() { + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: ReleaseConditionRollbackRequired, + Status: metav1.ConditionFalse, + Reason: "AnalysisSucceeded", + }) + + result := policy.EvaluatePolicyConstraints(release) + Expect(result.Allowed).To(BeTrue()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonSetByUser)) + }) + + It("should return allowed=false when release has not recovered from failure RollbackRequired=True", func() { + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: ReleaseConditionRollbackRequired, + Status: metav1.ConditionTrue, + Reason: "AnalysisFailed", + }) + + result := policy.EvaluatePolicyConstraints(release) + Expect(result.Allowed).To(BeFalse()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonDisabledByController)) + }) + + It("should return allowed=false when release has not recovered from failure RollbackRequired=Unknown", func() { + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: ReleaseConditionRollbackRequired, + Status: metav1.ConditionUnknown, + Reason: "AnalysisFailed", + }) + + result := policy.EvaluatePolicyConstraints(release) + Expect(result.Allowed).To(BeFalse()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonDisabledByController)) + }) + + It("should return allowed=false when release has not recovered from failure RollbackRequired is not set", func() { + // No condition is set, so the condition is unknown + result := policy.EvaluatePolicyConstraints(release) + Expect(result.Allowed).To(BeFalse()) + Expect(result.Reason).To(Equal(AutomatedRollbackPolicyReasonDisabledByController)) + }) + }) + }) + }) + }) +}) diff --git a/api/deploy/v1alpha1/release_types.go b/api/deploy/v1alpha1/release_types.go new file mode 100644 index 000000000..afcdfe988 --- /dev/null +++ b/api/deploy/v1alpha1/release_types.go @@ -0,0 +1,181 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// Condition types for Release resources +const ( + // ReleaseConditionActive indicates whether the release is currently active in the cluster. + // Status=True means the release is actively serving traffic. + // Status=False means the release has been superseded by another release. + ReleaseConditionActive = "Active" + + // ReleaseConditionHealthy indicates whether the release has passed health analysis. + // Status=True means the release passed health checks/analysis. + // Status=False means the release failed health checks/analysis. + // Status=Unknown means health status has not been determined yet. + ReleaseConditionHealthy = "Healthy" + + // ReleaseConditionRollbackRequired indicates whether the release should be + // rolled back, as long as automated rollbacks are enabled. + // Status=True means the release should be rolled back. + // Status=False means the release should not be rolled back. + // Status=Unknown means rollback analysis has not been determined yet. + ReleaseConditionRollbackRequired = "RollbackRequired" + + // Reasons for condition status changes + + // ReasonInitialised indicates the release was successfully initialised. + ReasonInitialised = "Initialised" + + // ReasonDeployed indicates the release was successfully deployed and is now active. + ReasonDeployed = "Deployed" + + // ReasonSuperseded indicates the release was superseded by a different release. + ReasonSuperseded = "Superseded" + + // ReasonRollback indicates the release is active due to a rollback + ReasonRollback = "Rollback" + + // ReasonAnalysisSucceeded indicates the release passed health analysis checks. + ReasonAnalysisSucceeded = "AnalysisSucceeded" + + // ReasonAnalysisFailed indicates the release failed health analysis checks. + ReasonAnalysisFailed = "AnalysisFailed" + + // ReasonAnalysisInProgress indicates analysis is in progress for the release. + ReasonAnalysisInProgress = "AnalysisInProgress" + + // ReasonAnalysisError indicates an error occurred during analysis. + ReasonAnalysisError = "AnalysisError" + + // ReasonAnalysisMissing indicates no analysis was found for the release. + ReasonAnalysisMissing = "AnalysisMissing" +) + +// ReleaseConfig defines the desired state of Release +type ReleaseConfig struct { + // TargetName is a namespace-unique identifier for this release target + // +kubebuilder:validation:Required + TargetName string `json:"targetName"` + + // Revisions is a list of revisions to be released. Each revision.name must be + // unique across all revisions + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + Revisions []Revision `json:"revisions"` +} +type Revision struct { + // Name is unique identifier for this revision. E.g. application-revision, chart-revision, etc. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // ID is the unique identifier of the revision (e.g., commit SHA, image digest, chart version) + // +kubebuilder:validation:Required + ID string `json:"id"` + + // Source identifies where this revision comes from (e.g., repository URL, registry URL) + // +kubebuilder:validation:Optional + Source string `json:"source"` + + // Type specifies the kind of revision source (git, container_image, helm_chart) + // +kubebuilder:validation:Optional + Type string `json:"type"` + + // Metadata contains additional optional information about the revision + // +kubebuilder:validation:Optional + Metadata RevisionMetadata `json:"metadata,omitempty"` +} + +type RevisionMetadata struct { + // Author is the author of the commit, if available. The field is optional. + // +kubebuilder:validation:Optional + Author string `json:"author,omitempty"` + + // Branch is the branch of the commit, if available. The field is optional. + // +kubebuilder:validation:Optional + Branch string `json:"branch,omitempty"` + + // Message is the message of the commit, if available. The field is optional. + // +kubebuilder:validation:Optional + Message string `json:"message,omitempty"` +} + +// This is common struct type used to indicate any previous and next releases +type ReleaseTransition struct { + // Other Release associated with this transition + ReleaseRef string `json:"releaseRef,omitempty"` + // When the release transitioned to this state + TransitionTime metav1.Time `json:"transitionTime,omitempty"` +} + +type ReleaseStatus struct { + // Conditions represent the latest available observations of a release's state. + // Known conditions are: + // * Active + // * Healthy + // +kubebuilder:validation:Optional + // +kubebuilder:validation:listType=map + // +kubebuilder:validation:listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Message is a human-readable message indicating the state of the release. + Message string `json:"message,omitempty"` + + // DeploymentStartTime is the time when the release was started. + DeploymentStartTime metav1.Time `json:"deploymentStartTime,omitempty"` + + // DeploymentEndTime is the time when the release was completed. + DeploymentEndTime metav1.Time `json:"deploymentEndTime,omitempty"` + + // PreviousRelease is the name of the release that was superseded by this release. + PreviousRelease ReleaseTransition `json:"previousRelease,omitempty"` + + // Signature is deterministic hash constructed out of the release revisions. + // The signature is constructed out of the sum of names and ids of each revision. + Signature string `json:"signature,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:shortName=rel +// +kubebuilder:printcolumn:name="Target_Name",type="string",JSONPath=".config.targetName" +// +kubebuilder:printcolumn:name="Active",type="string",JSONPath=".status.conditions[?(@.type==\"Active\")].status" +// +kubebuilder:printcolumn:name="Healthy",type="string",JSONPath=".status.conditions[?(@.type==\"Healthy\")].status" +// +kubebuilder:printcolumn:name="Signature",format="",type="string",JSONPath=".status.signature" +// +kubebuilder:printcolumn:name="Started_At",type="string",JSONPath=".status.deploymentStartTime" +// +kubebuilder:printcolumn:name="Ended_At",type="string",JSONPath=".status.deploymentEndTime" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:subresource:status +type Release struct { + metav1.TypeMeta `json:",inline"` + + // Metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // ReleaseConfig the release configuration + // +required + ReleaseConfig `json:"config,omitempty,omitzero"` + + // Status defines the observed state of Release + // +optional + Status ReleaseStatus `json:"status,omitempty,omitzero"` +} + +// +kubebuilder:object:root=true + +// ReleaseList contains a list of Release +type ReleaseList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Release `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Release{}, &ReleaseList{}) +} diff --git a/api/deploy/v1alpha1/rollback_types.go b/api/deploy/v1alpha1/rollback_types.go new file mode 100644 index 000000000..da6486d4c --- /dev/null +++ b/api/deploy/v1alpha1/rollback_types.go @@ -0,0 +1,149 @@ +package v1alpha1 + +import ( + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// Condition types for Rollback resources +const ( + // RollbackConditionInProgress indicates whether the rollback is currently in progress. + // Status=True means the rollback is in progress (e.g. the ArgoCD sync is in progress). + // Status=False means the rollback is yet to start or has completed. + RollbackConditionInProgress = "InProgress" + + // RollbackConditionSucceded indicates whether the rollback has succeeded. + // Status=True means the rollback has succeeded. + // Status=False means the rollback has not failed. + RollbackConditionSucceded = "Succeeded" +) + +// RollbackSpec defines the desired state of Rollback +type RollbackSpec struct { + // ToReleaseRef is the target release to rollback to. This is a reference to + // the Release resource. If the Name field is left empty, the operator will pick + // the latest healthy release for the specified Target to roll back to. + // +kubebuilder:validation:Required + ToReleaseRef ReleaseReference `json:"toReleaseRef"` + + // Reason is a human-readable message explaining why the rollback was initiated. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + Reason string `json:"reason"` + + // InitiatedBy tracks who or what triggered the rollback for audit purposes. + // +kubebuilder:validation:Optional + InitiatedBy RollbackInitiator `json:"initiatedBy,omitempty"` + + // DeploymentOptions contains additional provider-specific options. + // +kubebuilder:validation:Optional + DeploymentOptions map[string]apiextv1.JSON `json:"deploymentOptions,omitempty"` +} + +// ReleaseReference is a reference to a Release resource +type ReleaseReference struct { + // Target is the target name of the release. This is required to identify + // which release target to operate on. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Target is immutable" + Target string `json:"target"` + + // Name is the name of the release resource. If left empty, the system will + // automatically select the appropriate release (e.g., the latest healthy release). + // +kubebuilder:validation:Optional + // +kubebuilder:validation:XValidation:rule="oldSelf == '' || self == oldSelf",message="Name is immutable once set" + Name string `json:"name,omitempty"` +} + +// RollbackInitiator tracks who or what initiated the rollback +type RollbackInitiator struct { + // Principal is the identifier of the person or system who initiated the rollback + // +kubebuilder:validation:Optional + Principal string `json:"principal,omitempty"` + + // Type indicates what type of principal initiated the rollback + // (e.g. "user", "system") + // +kubebuilder:validation:Optional + Type string `json:"type,omitempty"` +} + +// RollbackStatus defines the observed state of Rollback +type RollbackStatus struct { + // Message is a human-readable message indicating the state of the rollback. + Message string `json:"message,omitempty"` + + // FromReleaseRef is the release being rolled back from. This is a reference + // to the Release resource name. + FromReleaseRef *ReleaseReference `json:"fromReleaseRef,omitempty"` + + // Automatic indicates whether this rollback was triggered automatically + // (e.g., by a health check) or manually by a user. + Automatic bool `json:"automatic,omitempty"` + + // StartTime is when the rollback operation started. + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the rollback operation completed (successfully or failed). + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + + // DeploymentID is the unique identifier for the deployment in the CICD system. + // This is used to poll for deployment status. + DeploymentID string `json:"deploymentID,omitempty"` + + // DeploymentURL is the URL to the CI job performing the rollback. + DeploymentURL string `json:"deploymentURL,omitempty"` + + // AttemptCount tracks how many times the controller has attempted to + // initiate the rollback via the CI system. + AttemptCount int32 `json:"attemptCount,omitempty"` + + // Conditions represent the latest observations of the rollback's state. + // Known condition types are: "InProgress", "Succeeded". + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=rb +// +kubebuilder:printcolumn:name="From",type=string,JSONPath=`.status.fromReleaseRef.name` +// +kubebuilder:printcolumn:name="To",type=string,JSONPath=`.spec.toReleaseRef.name` +// +kubebuilder:printcolumn:name="Initiator",type=string,JSONPath=`.spec.initiatedBy.principal` +// +kubebuilder:printcolumn:name="Succeeded",type=string,JSONPath=`.status.conditions[?(@.type=="Succeeded")].status` +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.spec.reason`,priority=10 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Rollback is the Schema for the rollbacks API. It represents a historical +// record of a release rollback operation. +type Rollback struct { + metav1.TypeMeta `json:",inline"` + + // Metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec defines the desired state of Rollback + // +required + Spec RollbackSpec `json:"spec"` + + // Status defines the observed state of Rollback + // +optional + Status RollbackStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RollbackList contains a list of Rollback +type RollbackList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Rollback `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Rollback{}, &RollbackList{}) +} diff --git a/api/deploy/v1alpha1/suite_test.go b/api/deploy/v1alpha1/suite_test.go new file mode 100644 index 000000000..04f37510b --- /dev/null +++ b/api/deploy/v1alpha1/suite_test.go @@ -0,0 +1,13 @@ +package v1alpha1 + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRelease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "api/deploy/v1alpha1") +} diff --git a/api/deploy/v1alpha1/zz_generated.deepcopy.go b/api/deploy/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..a5343783f --- /dev/null +++ b/api/deploy/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,495 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutomatedRollbackPolicy) DeepCopyInto(out *AutomatedRollbackPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutomatedRollbackPolicy. +func (in *AutomatedRollbackPolicy) DeepCopy() *AutomatedRollbackPolicy { + if in == nil { + return nil + } + out := new(AutomatedRollbackPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AutomatedRollbackPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutomatedRollbackPolicyList) DeepCopyInto(out *AutomatedRollbackPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AutomatedRollbackPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutomatedRollbackPolicyList. +func (in *AutomatedRollbackPolicyList) DeepCopy() *AutomatedRollbackPolicyList { + if in == nil { + return nil + } + out := new(AutomatedRollbackPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AutomatedRollbackPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutomatedRollbackPolicySpec) DeepCopyInto(out *AutomatedRollbackPolicySpec) { + *out = *in + out.Trigger = in.Trigger + in.RollbackTemplate.DeepCopyInto(&out.RollbackTemplate) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutomatedRollbackPolicySpec. +func (in *AutomatedRollbackPolicySpec) DeepCopy() *AutomatedRollbackPolicySpec { + if in == nil { + return nil + } + out := new(AutomatedRollbackPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutomatedRollbackPolicyStatus) DeepCopyInto(out *AutomatedRollbackPolicyStatus) { + *out = *in + if in.LastAutomatedRollbackTime != nil { + in, out := &in.LastAutomatedRollbackTime, &out.LastAutomatedRollbackTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutomatedRollbackPolicyStatus. +func (in *AutomatedRollbackPolicyStatus) DeepCopy() *AutomatedRollbackPolicyStatus { + if in == nil { + return nil + } + out := new(AutomatedRollbackPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Release) DeepCopyInto(out *Release) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.ReleaseConfig.DeepCopyInto(&out.ReleaseConfig) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Release. +func (in *Release) DeepCopy() *Release { + if in == nil { + return nil + } + out := new(Release) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Release) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseConfig) DeepCopyInto(out *ReleaseConfig) { + *out = *in + if in.Revisions != nil { + in, out := &in.Revisions, &out.Revisions + *out = make([]Revision, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseConfig. +func (in *ReleaseConfig) DeepCopy() *ReleaseConfig { + if in == nil { + return nil + } + out := new(ReleaseConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseList) DeepCopyInto(out *ReleaseList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Release, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseList. +func (in *ReleaseList) DeepCopy() *ReleaseList { + if in == nil { + return nil + } + out := new(ReleaseList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ReleaseList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseReference) DeepCopyInto(out *ReleaseReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseReference. +func (in *ReleaseReference) DeepCopy() *ReleaseReference { + if in == nil { + return nil + } + out := new(ReleaseReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseStatus) DeepCopyInto(out *ReleaseStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.DeploymentStartTime.DeepCopyInto(&out.DeploymentStartTime) + in.DeploymentEndTime.DeepCopyInto(&out.DeploymentEndTime) + in.PreviousRelease.DeepCopyInto(&out.PreviousRelease) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseStatus. +func (in *ReleaseStatus) DeepCopy() *ReleaseStatus { + if in == nil { + return nil + } + out := new(ReleaseStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseTransition) DeepCopyInto(out *ReleaseTransition) { + *out = *in + in.TransitionTime.DeepCopyInto(&out.TransitionTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseTransition. +func (in *ReleaseTransition) DeepCopy() *ReleaseTransition { + if in == nil { + return nil + } + out := new(ReleaseTransition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Revision) DeepCopyInto(out *Revision) { + *out = *in + out.Metadata = in.Metadata +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Revision. +func (in *Revision) DeepCopy() *Revision { + if in == nil { + return nil + } + out := new(Revision) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RevisionMetadata) DeepCopyInto(out *RevisionMetadata) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RevisionMetadata. +func (in *RevisionMetadata) DeepCopy() *RevisionMetadata { + if in == nil { + return nil + } + out := new(RevisionMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Rollback) DeepCopyInto(out *Rollback) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Rollback. +func (in *Rollback) DeepCopy() *Rollback { + if in == nil { + return nil + } + out := new(Rollback) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Rollback) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackInitiator) DeepCopyInto(out *RollbackInitiator) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackInitiator. +func (in *RollbackInitiator) DeepCopy() *RollbackInitiator { + if in == nil { + return nil + } + out := new(RollbackInitiator) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackList) DeepCopyInto(out *RollbackList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Rollback, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackList. +func (in *RollbackList) DeepCopy() *RollbackList { + if in == nil { + return nil + } + out := new(RollbackList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RollbackList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackSpec) DeepCopyInto(out *RollbackSpec) { + *out = *in + out.ToReleaseRef = in.ToReleaseRef + out.InitiatedBy = in.InitiatedBy + if in.DeploymentOptions != nil { + in, out := &in.DeploymentOptions, &out.DeploymentOptions + *out = make(map[string]v1.JSON, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackSpec. +func (in *RollbackSpec) DeepCopy() *RollbackSpec { + if in == nil { + return nil + } + out := new(RollbackSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackStatus) DeepCopyInto(out *RollbackStatus) { + *out = *in + if in.FromReleaseRef != nil { + in, out := &in.FromReleaseRef, &out.FromReleaseRef + *out = new(ReleaseReference) + **out = **in + } + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackStatus. +func (in *RollbackStatus) DeepCopy() *RollbackStatus { + if in == nil { + return nil + } + out := new(RollbackStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackTemplate) DeepCopyInto(out *RollbackTemplate) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackTemplate. +func (in *RollbackTemplate) DeepCopy() *RollbackTemplate { + if in == nil { + return nil + } + out := new(RollbackTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackTemplateMetadata) DeepCopyInto(out *RollbackTemplateMetadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackTemplateMetadata. +func (in *RollbackTemplateMetadata) DeepCopy() *RollbackTemplateMetadata { + if in == nil { + return nil + } + out := new(RollbackTemplateMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackTemplateSpec) DeepCopyInto(out *RollbackTemplateSpec) { + *out = *in + if in.DeploymentOptions != nil { + in, out := &in.DeploymentOptions, &out.DeploymentOptions + *out = make(map[string]v1.JSON, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackTemplateSpec. +func (in *RollbackTemplateSpec) DeepCopy() *RollbackTemplateSpec { + if in == nil { + return nil + } + out := new(RollbackTemplateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackTrigger) DeepCopyInto(out *RollbackTrigger) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackTrigger. +func (in *RollbackTrigger) DeepCopy() *RollbackTrigger { + if in == nil { + return nil + } + out := new(RollbackTrigger) + in.DeepCopyInto(out) + return out +} diff --git a/api/workloads/v1alpha1/helpers_test.go b/api/workloads/v1alpha1/helpers_test.go index c21871855..184df0d87 100644 --- a/api/workloads/v1alpha1/helpers_test.go +++ b/api/workloads/v1alpha1/helpers_test.go @@ -1,7 +1,7 @@ package v1alpha1 import ( - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/api/workloads/v1alpha1/suite_test.go b/api/workloads/v1alpha1/suite_test.go index 102fb7129..dd24ff85a 100644 --- a/api/workloads/v1alpha1/suite_test.go +++ b/api/workloads/v1alpha1/suite_test.go @@ -3,7 +3,7 @@ package v1alpha1 import ( "testing" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/cmd/acceptance/main.go b/cmd/acceptance/main.go index 100337790..a80808bef 100644 --- a/cmd/acceptance/main.go +++ b/cmd/acceptance/main.go @@ -16,8 +16,7 @@ import ( "github.com/alecthomas/kingpin" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" - . "github.com/onsi/ginkgo" - "github.com/onsi/ginkgo/config" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -212,8 +211,10 @@ func main() { SetDefaultEventuallyTimeout(time.Minute) SetDefaultEventuallyPollingInterval(100 * time.Millisecond) + + _, reporterCfg := GinkgoConfiguration() if *runVerbose { - config.DefaultReporterConfig.Verbose = true + reporterCfg.Verbose = true } logger := kitlog.NewLogfmtLogger(GinkgoWriter) @@ -235,7 +236,7 @@ func main() { } }) - if RunSpecs(new(testing.T), "theatre/cmd/acceptance") { + if RunSpecs(new(testing.T), "theatre/cmd/acceptance", reporterCfg) { os.Exit(0) } else { os.Exit(1) diff --git a/cmd/automated-rollback-manager/main.go b/cmd/automated-rollback-manager/main.go new file mode 100644 index 000000000..00efee802 --- /dev/null +++ b/cmd/automated-rollback-manager/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + + "github.com/alecthomas/kingpin" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/cmd" + + "github.com/gocardless/theatre/v5/pkg/signals" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + automatedrollbackcontroller "github.com/gocardless/theatre/v5/internal/controller/deploy" + automatedrollbackwebhook "github.com/gocardless/theatre/v5/internal/webhook/deploy/v1alpha1/automated-rollback-policy" +) + +var ( + scheme = runtime.NewScheme() + app = kingpin.New("automated-rollback-manager", "Creates rollback resources based on release status and rollback policies").Version(cmd.VersionStanza()) + commonOptions = cmd.NewCommonOptions(app).WithMetrics(app) +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(deployv1alpha1.AddToScheme(scheme)) +} + +func main() { + kingpin.MustParse(app.Parse(os.Args[1:])) + logger := commonOptions.Logger() + + ctx, cancel := signals.SetupSignalHandler() + defer cancel() + + webhookOpts := webhook.Options{Port: 9443} + webhookServer := webhook.NewServer(webhookOpts) + + manager, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + LeaderElection: commonOptions.ManagerLeaderElection, + LeaderElectionID: "automated-rollback-policy.deploy.crd.gocardless.com", + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: fmt.Sprintf("%s:%d", commonOptions.MetricAddress, commonOptions.MetricPort), + }, + WebhookServer: webhookServer, + }) + + if err != nil { + app.Fatalf("failed to create manager: %v", err) + } + + if err = (&automatedrollbackcontroller.AutomatedRollbackReconciler{ + Client: manager.GetClient(), + Scheme: scheme, + Log: logger, + }).SetupWithManager(ctx, manager); err != nil { + app.Fatalf("failed to create controller: %v", err) + } + + manager.GetWebhookServer().Register("/validate-automated-rollback-policies", &admission.Webhook{ + Handler: automatedrollbackwebhook.NewAutomatedRollbackPolicyValidateWebhook(logger, manager.GetScheme(), manager.GetClient()), + }) + + if err := manager.Start(ctx); err != nil { + app.Fatalf("failed to start manager: %v", err) + } +} diff --git a/cmd/release-manager/main.go b/cmd/release-manager/main.go new file mode 100644 index 000000000..5d3fa2be8 --- /dev/null +++ b/cmd/release-manager/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + + analysisv1alpha1 "github.com/akuity/kargo/api/stubs/rollouts/v1alpha1" + "github.com/alecthomas/kingpin" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/cmd" + + releasecontroller "github.com/gocardless/theatre/v5/internal/controller/deploy" + releasewebhook "github.com/gocardless/theatre/v5/internal/webhook/deploy/v1alpha1/release" + "github.com/gocardless/theatre/v5/pkg/signals" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + scheme = runtime.NewScheme() + app = kingpin.New("release-manager", "Manages release.deploy.crd.gocardless.com resources").Version(cmd.VersionStanza()) + enableReleaseUniquenessWebhooks = app.Flag( + "enable-release-uniqueness-webhooks", + "Enable release uniqueness webhooks - when enabled, the release name will be set by the controller based on the"+ + " release.config object. Kubernetes will then handle the uniqueness of Release resources in the namespace.", + ).Default("false").Bool() + enableArgoRolloutsAnalysis = app.Flag("enable-argo-rollouts-analysis", "Enable health analysis for releases. Requires Argo Rollouts").Default("false").Bool() + commonOptions = cmd.NewCommonOptions(app).WithMetrics(app) +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(deployv1alpha1.AddToScheme(scheme)) +} + +func main() { + kingpin.MustParse(app.Parse(os.Args[1:])) + logger := commonOptions.Logger() + + if *enableArgoRolloutsAnalysis { + utilruntime.Must(analysisv1alpha1.AddToScheme(scheme)) + } + + ctx, cancel := signals.SetupSignalHandler() + defer cancel() + + webhookOpts := webhook.Options{Port: 9443} + webhookServer := webhook.NewServer(webhookOpts) + + manager, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + LeaderElection: commonOptions.ManagerLeaderElection, + LeaderElectionID: "release.deploy.crd.gocardless.com", + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: fmt.Sprintf("%s:%d", commonOptions.MetricAddress, commonOptions.MetricPort), + }, + WebhookServer: webhookServer, + }) + + if err != nil { + app.Fatalf("failed to create manager: %v", err) + } + + if err = (&releasecontroller.ReleaseReconciler{ + Client: manager.GetClient(), + Scheme: scheme, + Log: logger, + AnalysisEnabled: *enableArgoRolloutsAnalysis, + }).SetupWithManager(ctx, manager); err != nil { + app.Fatalf("failed to create controller: %v", err) + } + + manager.GetWebhookServer().Register("/validate-releases", &admission.Webhook{ + Handler: releasewebhook.NewReleaseValidateWebhook(logger, manager.GetScheme()), + }) + + if *enableReleaseUniquenessWebhooks { + // Webhook configuration + manager.GetWebhookServer().Register("/mutate-releases", &admission.Webhook{ + Handler: releasewebhook.NewReleaseNamerWebhook(logger, manager.GetScheme()), + }) + } + + if err := manager.Start(ctx); err != nil { + app.Fatalf("failed to start manager: %v", err) + } +} diff --git a/cmd/rollback-manager/main.go b/cmd/rollback-manager/main.go new file mode 100644 index 000000000..0768e2409 --- /dev/null +++ b/cmd/rollback-manager/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/alecthomas/kingpin" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/cmd" + "github.com/google/go-github/v34/github" + "golang.org/x/oauth2" + + rollbackcontroller "github.com/gocardless/theatre/v5/internal/controller/deploy" + rollbackwebhook "github.com/gocardless/theatre/v5/internal/webhook/deploy/v1alpha1/rollback" + "github.com/gocardless/theatre/v5/pkg/cicd" + ghdeployer "github.com/gocardless/theatre/v5/pkg/cicd/github" + "github.com/gocardless/theatre/v5/pkg/signals" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + scheme = runtime.NewScheme() + app = kingpin.New("rollback-manager", "Manages rollback.deploy.crd.gocardless.com resources").Version(cmd.VersionStanza()) + rollbackHistoryLimit = app.Flag("rollback-history-limit", "Maximum number of rollbacks to keep per target name. All rollbacks older than this will be deleted by the reconciler."). + Default("10"). + Envar("ROLLBACK_MANAGER_HISTORY_LIMIT"). + Int() + cicdBackend = app.Flag("cicd-backend", "CICD backend to use (noop, github)"). + Default("noop"). + Envar("ROLLBACK_MANAGER_CICD_BACKEND"). + Enum("noop", "github") + githubToken = app.Flag("github-token", "GitHub API token for the github cicd backend"). + Envar("ROLLBACK_MANAGER_GITHUB_TOKEN"). + String() + commonOptions = cmd.NewCommonOptions(app).WithMetrics(app) + + // deployer holds the configured CICD deployer implementation. + // This is set during initialization based on configuration. + deployer cicd.Deployer +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(deployv1alpha1.AddToScheme(scheme)) + + // Default to noop deployer - users should configure their own implementation + // by setting the deployer variable before main() or via a plugin system. + // Example implementations could be injected via build tags or configuration. + deployer = &cicd.NoopDeployer{} +} + +func main() { + kingpin.MustParse(app.Parse(os.Args[1:])) + logger := commonOptions.Logger() + + ctx, cancel := signals.SetupSignalHandler() + defer cancel() + + // Initialize the deployer based on the configured backend + deployer, err := createDeployer(ctx, *cicdBackend) + if err != nil { + app.Fatalf("failed to create deployer: %v", err) + } + + webhookOpts := webhook.Options{Port: 9443} + webhookServer := webhook.NewServer(webhookOpts) + + manager, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + LeaderElection: commonOptions.ManagerLeaderElection, + LeaderElectionID: "rollback.deploy.crd.gocardless.com", + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: fmt.Sprintf("%s:%d", commonOptions.MetricAddress, commonOptions.MetricPort), + }, + WebhookServer: webhookServer, + }) + + if err != nil { + app.Fatalf("failed to create manager: %v", err) + } + + err = (&rollbackcontroller.RollbackReconciler{ + Client: manager.GetClient(), + Scheme: scheme, + Log: logger, + RollbackHistoryLimit: *rollbackHistoryLimit, + Deployer: deployer, + }).SetupWithManager(ctx, manager) + + if err != nil { + app.Fatalf("failed to create controller: %v", err) + } + + // Register mutating webhook to auto-set rollback target + manager.GetWebhookServer().Register("/mutate-rollbacks", &admission.Webhook{ + Handler: rollbackwebhook.NewRollbackTargetWebhook(logger, manager.GetScheme(), manager.GetClient()), + }) + + // Register validating webhook to prevent multiple rollbacks for the same target at the same time + manager.GetWebhookServer().Register("/validate-rollbacks", &admission.Webhook{ + Handler: rollbackwebhook.NewRollbackValidateWebhook(logger, manager.GetScheme(), manager.GetClient()), + }) + + if err := manager.Start(ctx); err != nil { + app.Fatalf("failed to start manager: %v", err) + } +} + +func createDeployer(ctx context.Context, backend string) (cicd.Deployer, error) { + switch backend { + case "noop": + return &cicd.NoopDeployer{}, nil + case "github": + if *githubToken == "" { + return nil, fmt.Errorf("github-token is required when using the github deployer backend") + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: *githubToken}) + httpClient := oauth2.NewClient(ctx, ts) + ghClient := github.NewClient(httpClient) + logger := zap.New(zap.UseDevMode(true)) + return ghdeployer.NewDeployer(ghClient, logger), nil + default: + return nil, fmt.Errorf("unknown deployer backend: %s", backend) + } +} diff --git a/cmd/vault-manager/acceptance/acceptance.go b/cmd/vault-manager/acceptance/acceptance.go index b806d8221..88b9484c2 100644 --- a/cmd/vault-manager/acceptance/acceptance.go +++ b/cmd/vault-manager/acceptance/acceptance.go @@ -20,7 +20,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/cmd/workloads-manager/acceptance/acceptance.go b/cmd/workloads-manager/acceptance/acceptance.go index 922667f0d..c536a3cd8 100644 --- a/cmd/workloads-manager/acceptance/acceptance.go +++ b/cmd/workloads-manager/acceptance/acceptance.go @@ -22,7 +22,7 @@ import ( workloadsv1alpha1 "github.com/gocardless/theatre/v5/api/workloads/v1alpha1" "github.com/gocardless/theatre/v5/pkg/workloads/console/runner" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/config/acceptance/kustomization.yaml b/config/acceptance/kustomization.yaml index 992c62311..fe5fdd1d6 100644 --- a/config/acceptance/kustomization.yaml +++ b/config/acceptance/kustomization.yaml @@ -12,3 +12,6 @@ patchesStrategicMerge: - overlays/rbac-manager.yaml - overlays/vault-manager.yaml - overlays/workloads-manager.yaml + - overlays/release-manager.yaml + - overlays/rollback-manager.yaml + - overlays/automated-rollback-manager.yaml diff --git a/config/acceptance/overlays/automated-rollback-manager.yaml b/config/acceptance/overlays/automated-rollback-manager.yaml new file mode 100644 index 000000000..9439fcc85 --- /dev/null +++ b/config/acceptance/overlays/automated-rollback-manager.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: automated-rollback-manager +spec: + replicas: 1 + selector: + matchLabels: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com + template: + metadata: + labels: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com + spec: + containers: + - command: + - /usr/local/bin/automated-rollback-manager + args: + - --metrics-address=0.0.0.0 + image: theatre:latest + imagePullPolicy: Never + name: manager + resources: + limits: + cpu: 100m + memory: 256Mi diff --git a/config/acceptance/overlays/rbac-manager.yaml b/config/acceptance/overlays/rbac-manager.yaml index 4501bad38..97b7ef551 100644 --- a/config/acceptance/overlays/rbac-manager.yaml +++ b/config/acceptance/overlays/rbac-manager.yaml @@ -5,7 +5,15 @@ metadata: name: rbac-manager spec: replicas: 1 + selector: + matchLabels: + controller: rbac-manager + group: rbac.crd.gocardless.com template: + metadata: + labels: + controller: rbac-manager + group: rbac.crd.gocardless.com spec: containers: - name: manager diff --git a/config/acceptance/overlays/release-manager.yaml b/config/acceptance/overlays/release-manager.yaml new file mode 100644 index 000000000..81c36232d --- /dev/null +++ b/config/acceptance/overlays/release-manager.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-manager +spec: + replicas: 1 + selector: + matchLabels: + controller: release-manager + group: deploy.crd.gocardless.com + template: + metadata: + labels: + controller: release-manager + group: deploy.crd.gocardless.com + spec: + containers: + - command: + - /usr/local/bin/release-manager + args: + - --metrics-address=0.0.0.0 + image: theatre:latest + imagePullPolicy: Never + name: manager + resources: + limits: + cpu: 100m + memory: 256Mi diff --git a/config/acceptance/overlays/rollback-manager.yaml b/config/acceptance/overlays/rollback-manager.yaml new file mode 100644 index 000000000..6a87504ee --- /dev/null +++ b/config/acceptance/overlays/rollback-manager.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rollback-manager +spec: + replicas: 1 + selector: + matchLabels: + controller: rollback-manager + group: deploy.crd.gocardless.com + template: + metadata: + labels: + controller: rollback-manager + group: deploy.crd.gocardless.com + spec: + containers: + - command: + - /usr/local/bin/rollback-manager + args: + - --metrics-address=0.0.0.0 + image: theatre:latest + imagePullPolicy: Never + name: manager + resources: + limits: + cpu: 100m + memory: 256Mi diff --git a/config/acceptance/overlays/vault-manager.yaml b/config/acceptance/overlays/vault-manager.yaml index a3ae2ef00..fe5e21e8f 100644 --- a/config/acceptance/overlays/vault-manager.yaml +++ b/config/acceptance/overlays/vault-manager.yaml @@ -5,7 +5,15 @@ metadata: name: vault-manager spec: replicas: 1 + selector: + matchLabels: + controller: vault-manager + group: vault.crd.gocardless.com template: + metadata: + labels: + controller: vault-manager + group: vault.crd.gocardless.com spec: containers: - name: manager diff --git a/config/acceptance/overlays/workloads-manager.yaml b/config/acceptance/overlays/workloads-manager.yaml index 83fe5ad1b..d4c834a38 100644 --- a/config/acceptance/overlays/workloads-manager.yaml +++ b/config/acceptance/overlays/workloads-manager.yaml @@ -5,7 +5,15 @@ metadata: name: workloads-manager spec: replicas: 1 + selector: + matchLabels: + controller: workloads-manager + group: workloads.crd.gocardless.com template: + metadata: + labels: + controller: workloads-manager + group: workloads.crd.gocardless.com spec: containers: - name: manager diff --git a/config/base/kustomization.yaml b/config/base/kustomization.yaml index 02b499371..64b78ceee 100644 --- a/config/base/kustomization.yaml +++ b/config/base/kustomization.yaml @@ -13,8 +13,16 @@ resources: - managers/rbac.yaml - managers/vault.yaml - managers/workloads.yaml + - managers/release.yaml + - managers/rollback.yaml + - managers/automated-rollback.yaml - webhooks/vault.yaml - webhooks/workloads.yaml + - webhooks/release-mutating.yaml + - webhooks/release-validating.yaml + - webhooks/rollback-mutating.yaml + - webhooks/rollback-validating.yaml + - webhooks/automated-rollback-policy-validating.yaml - rbac/leader-election.yaml - cert-manager/certificate.yaml diff --git a/config/base/managers/automated-rollback.yaml b/config/base/managers/automated-rollback.yaml new file mode 100644 index 000000000..1598ff3d7 --- /dev/null +++ b/config/base/managers/automated-rollback.yaml @@ -0,0 +1,156 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: automated-rollback-manager +rules: + - apiGroups: + - deploy.crd.gocardless.com + resources: + - automatedrollbackpolicies + verbs: + - get + - list + - watch + - apiGroups: + - deploy.crd.gocardless.com + resources: + - automatedrollbackpolicies/status + verbs: + - get + - update + - patch + - apiGroups: + - deploy.crd.gocardless.com + resources: + - automatedrollbackpolicies/finalizers + verbs: + - update + # Need to get/list existing rollbacks and create new ones + - apiGroups: + - deploy.crd.gocardless.com + resources: + - rollbacks + verbs: + - get + - list + - watch + - create + # Need to get/list existing releases + - apiGroups: + - deploy.crd.gocardless.com + resources: + - releases + verbs: + - get + - list + - watch + # Need to create events for reconciliation logging + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: automated-rollback-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: automated-rollback-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: automated-rollback-manager +subjects: + - kind: ServiceAccount + name: automated-rollback-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: automated-rollback-manager-leader-election +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: theatre-leader-election +subjects: + - kind: ServiceAccount + name: automated-rollback-manager +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: automated-rollback-manager +spec: + dnsNames: + - theatre-automated-rollback-manager.theatre-system.svc + issuerRef: + kind: Issuer + name: theatre-webhooks + secretName: theatre-automated-rollback-manager-certificate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: automated-rollback-manager + labels: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com +spec: + replicas: 1 + selector: + matchLabels: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com + template: + metadata: + labels: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com + spec: + serviceAccountName: automated-rollback-manager + terminationGracePeriodSeconds: 10 + containers: + - command: + - /usr/local/bin/automated-rollback-manager + args: + - --metrics-address=0.0.0.0 + image: eu.gcr.io/gc-containers/gocardless/theatre:latest + imagePullPolicy: Always + name: manager + env: [] + ports: + - name: https + containerPort: 9443 + - name: http-metrics + containerPort: 9525 + resources: + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + secretName: theatre-automated-rollback-manager-certificate +--- +apiVersion: v1 +kind: Service +metadata: + name: automated-rollback-manager +spec: + selector: + controller: automated-rollback-manager + group: deploy.crd.gocardless.com + ports: + - port: 443 + targetPort: 9443 diff --git a/config/base/managers/rbac.yaml b/config/base/managers/rbac.yaml index ecea6c553..3b7882ccc 100644 --- a/config/base/managers/rbac.yaml +++ b/config/base/managers/rbac.yaml @@ -69,6 +69,7 @@ metadata: name: rbac-manager labels: group: rbac.crd.gocardless.com + controller: rbac-manager spec: serviceName: rbac-manager replicas: 1 @@ -76,10 +77,12 @@ spec: selector: matchLabels: group: rbac.crd.gocardless.com + controller: rbac-manager template: metadata: labels: group: rbac.crd.gocardless.com + controller: rbac-manager spec: serviceAccountName: rbac-manager terminationGracePeriodSeconds: 10 diff --git a/config/base/managers/release.yaml b/config/base/managers/release.yaml new file mode 100644 index 000000000..e9fef77fe --- /dev/null +++ b/config/base/managers/release.yaml @@ -0,0 +1,161 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: release-manager +rules: + - apiGroups: + - deploy.crd.gocardless.com + resources: + - releases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - deploy.crd.gocardless.com + resources: + - releases/status + verbs: + - get + - update + - patch + - apiGroups: + - deploy.crd.gocardless.com + resources: + - releases/finalizers + verbs: + - update + # Needed to create events for reconciliation logging + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + # Needed to create leases for culling + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + # Needed to read namespaces for culling config + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: release-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: release-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: release-manager +subjects: + - kind: ServiceAccount + name: release-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: release-manager-leader-election +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: theatre-leader-election +subjects: + - kind: ServiceAccount + name: release-manager +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: release-manager +spec: + dnsNames: + - theatre-release-manager.theatre-system.svc + issuerRef: + kind: Issuer + name: theatre-webhooks + secretName: theatre-release-manager-certificate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-manager + labels: + controller: release-manager + group: deploy.crd.gocardless.com +spec: + replicas: 1 + selector: + matchLabels: + controller: release-manager + group: deploy.crd.gocardless.com + template: + metadata: + labels: + controller: release-manager + group: deploy.crd.gocardless.com + spec: + serviceAccountName: release-manager + terminationGracePeriodSeconds: 10 + containers: + - command: + - /usr/local/bin/release-manager + args: + - --metrics-address=0.0.0.0 + image: eu.gcr.io/gc-containers/gocardless/theatre:latest + imagePullPolicy: Always + name: manager + env: [] + ports: + - name: https + containerPort: 9443 + - name: http-metrics + containerPort: 9525 + resources: + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + secretName: theatre-release-manager-certificate +--- +apiVersion: v1 +kind: Service +metadata: + name: release-manager +spec: + selector: + controller: release-manager + group: deploy.crd.gocardless.com + ports: + - port: 443 + targetPort: 9443 diff --git a/config/base/managers/rollback.yaml b/config/base/managers/rollback.yaml new file mode 100644 index 000000000..3c4e7d61b --- /dev/null +++ b/config/base/managers/rollback.yaml @@ -0,0 +1,150 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rollback-manager +rules: + - apiGroups: + - deploy.crd.gocardless.com + resources: + - rollbacks + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - deploy.crd.gocardless.com + resources: + - rollbacks/status + verbs: + - get + - update + - patch + - apiGroups: + - deploy.crd.gocardless.com + resources: + - rollbacks/finalizers + verbs: + - update + # Need to read releases to get revision info for rollbacks + - apiGroups: + - deploy.crd.gocardless.com + resources: + - releases + verbs: + - get + - list + - watch + # Need to create events for reconciliation logging + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rollback-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: rollback-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: rollback-manager +subjects: + - kind: ServiceAccount + name: rollback-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: rollback-manager-leader-election +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: theatre-leader-election +subjects: + - kind: ServiceAccount + name: rollback-manager +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: rollback-manager +spec: + dnsNames: + - theatre-rollback-manager.theatre-system.svc + issuerRef: + kind: Issuer + name: theatre-webhooks + secretName: theatre-rollback-manager-certificate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rollback-manager + labels: + group: deploy.crd.gocardless.com + controller: rollback-manager +spec: + replicas: 1 + selector: + matchLabels: + group: deploy.crd.gocardless.com + controller: rollback-manager + template: + metadata: + labels: + group: deploy.crd.gocardless.com + controller: rollback-manager + spec: + serviceAccountName: rollback-manager + terminationGracePeriodSeconds: 10 + containers: + - command: + - /usr/local/bin/rollback-manager + args: + - --metrics-address=0.0.0.0 + image: eu.gcr.io/gc-containers/gocardless/theatre:latest + imagePullPolicy: Always + name: manager + env: [] + ports: + - name: https + containerPort: 9443 + - name: http-metrics + containerPort: 9525 + resources: + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + secretName: theatre-rollback-manager-certificate +--- +apiVersion: v1 +kind: Service +metadata: + name: rollback-manager +spec: + selector: + controller: rollback-manager + group: deploy.crd.gocardless.com + ports: + - port: 443 + targetPort: 9443 diff --git a/config/base/managers/vault.yaml b/config/base/managers/vault.yaml index db042daff..5fe6b389b 100644 --- a/config/base/managers/vault.yaml +++ b/config/base/managers/vault.yaml @@ -95,6 +95,7 @@ metadata: name: vault-manager labels: group: vault.crd.gocardless.com + controller: vault-manager spec: serviceName: vault-manager replicas: 1 @@ -102,10 +103,12 @@ spec: selector: matchLabels: group: vault.crd.gocardless.com + controller: vault-manager template: metadata: labels: group: vault.crd.gocardless.com + controller: vault-manager spec: serviceAccountName: vault-manager terminationGracePeriodSeconds: 10 @@ -148,6 +151,7 @@ metadata: spec: selector: group: vault.crd.gocardless.com + controller: vault-manager ports: - port: 443 targetPort: 443 diff --git a/config/base/managers/workloads.yaml b/config/base/managers/workloads.yaml index 20775d555..1c5a982c9 100644 --- a/config/base/managers/workloads.yaml +++ b/config/base/managers/workloads.yaml @@ -115,6 +115,7 @@ metadata: name: workloads-manager labels: group: workloads.crd.gocardless.com + controller: workloads-manager spec: serviceName: workloads-manager volumeClaimTemplates: [] @@ -122,10 +123,12 @@ spec: selector: matchLabels: group: workloads.crd.gocardless.com + controller: workloads-manager template: metadata: labels: group: workloads.crd.gocardless.com + controller: workloads-manager spec: serviceAccountName: workloads-manager terminationGracePeriodSeconds: 10 @@ -166,8 +169,8 @@ metadata: name: workloads-manager spec: selector: - app: theatre group: workloads.crd.gocardless.com + controller: workloads-manager ports: - port: 443 targetPort: 443 diff --git a/config/base/webhooks/automated-rollback-policy-validating.yaml b/config/base/webhooks/automated-rollback-policy-validating.yaml new file mode 100644 index 000000000..71ed11a66 --- /dev/null +++ b/config/base/webhooks/automated-rollback-policy-validating.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: automated-rollback-policy-validate + annotations: + cert-manager.io/inject-ca-from: theatre-system/theatre-automated-rollback-manager +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + caBundle: Cg== + service: + name: theatre-automated-rollback-manager + namespace: theatre-system + path: /validate-automated-rollback-policies + port: 443 + failurePolicy: Fail + name: automated-rollback-policy.deploy.crd.gocardless.com + rules: + - apiGroups: + - deploy.crd.gocardless.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - automatedrollbackpolicies + scope: "*" + sideEffects: None + timeoutSeconds: 10 diff --git a/config/base/webhooks/release-mutating.yaml b/config/base/webhooks/release-mutating.yaml new file mode 100644 index 000000000..29e3760ba --- /dev/null +++ b/config/base/webhooks/release-mutating.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: release-mutate + annotations: + cert-manager.io/inject-ca-from: theatre-system/theatre-release-manager +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + caBundle: Cg== + service: + name: theatre-release-manager + namespace: theatre-system + path: /mutate-releases + port: 443 + failurePolicy: Fail + name: release.deploy.crd.gocardless.com + rules: + - apiGroups: + - deploy.crd.gocardless.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - releases + scope: "*" + sideEffects: None + timeoutSeconds: 10 diff --git a/config/base/webhooks/release-validating.yaml b/config/base/webhooks/release-validating.yaml new file mode 100644 index 000000000..e891c84d9 --- /dev/null +++ b/config/base/webhooks/release-validating.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: release-validate + annotations: + cert-manager.io/inject-ca-from: theatre-system/theatre-release-manager +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + caBundle: Cg== + service: + name: theatre-release-manager + namespace: theatre-system + path: /validate-releases + port: 443 + failurePolicy: Fail + name: release.deploy.crd.gocardless.com + rules: + - apiGroups: + - deploy.crd.gocardless.com + apiVersions: + - v1alpha1 + operations: + - UPDATE + resources: + - releases + scope: "*" + sideEffects: None + timeoutSeconds: 10 diff --git a/config/base/webhooks/rollback-mutating.yaml b/config/base/webhooks/rollback-mutating.yaml new file mode 100644 index 000000000..88716ebe7 --- /dev/null +++ b/config/base/webhooks/rollback-mutating.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: rollback-mutate + annotations: + cert-manager.io/inject-ca-from: theatre-system/theatre-rollback-manager +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + caBundle: Cg== + service: + name: theatre-rollback-manager + namespace: theatre-system + path: /mutate-rollbacks + port: 443 + failurePolicy: Fail + name: rollback.deploy.crd.gocardless.com + rules: + - apiGroups: + - deploy.crd.gocardless.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - rollbacks + scope: "*" + sideEffects: None + timeoutSeconds: 10 diff --git a/config/base/webhooks/rollback-validating.yaml b/config/base/webhooks/rollback-validating.yaml new file mode 100644 index 000000000..e0f979a9e --- /dev/null +++ b/config/base/webhooks/rollback-validating.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: rollback-validate + annotations: + cert-manager.io/inject-ca-from: theatre-system/theatre-rollback-manager +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + caBundle: Cg== + service: + name: theatre-rollback-manager + namespace: theatre-system + path: /validate-rollbacks + port: 443 + failurePolicy: Fail + name: rollback.deploy.crd.gocardless.com + rules: + - apiGroups: + - deploy.crd.gocardless.com + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - rollbacks + scope: "*" + sideEffects: None + timeoutSeconds: 10 diff --git a/config/crd/bases/deploy.crd.gocardless.com_automatedrollbackpolicies.yaml b/config/crd/bases/deploy.crd.gocardless.com_automatedrollbackpolicies.yaml new file mode 100644 index 000000000..514a01c14 --- /dev/null +++ b/config/crd/bases/deploy.crd.gocardless.com_automatedrollbackpolicies.yaml @@ -0,0 +1,207 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: automatedrollbackpolicies.deploy.crd.gocardless.com +spec: + group: deploy.crd.gocardless.com + names: + kind: AutomatedRollbackPolicy + listKind: AutomatedRollbackPolicyList + plural: automatedrollbackpolicies + shortNames: + - arbp + singular: automatedrollbackpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetName + name: Target + type: string + - jsonPath: .spec.trigger.conditionType + name: Trigger_Condition + type: string + - jsonPath: .spec.trigger.conditionStatus + name: Trigger_When + type: string + - jsonPath: .status.conditions[?(@.type=="Automated")].status + name: Automated + type: string + - jsonPath: .status.conditions[?(@.type=="Automated")].reason + name: Reason + type: string + - jsonPath: .status.conditions[?(@.type=="Automated")].message + name: Message + priority: 10 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AutomatedRollbackPolicy is the Schema for the automatedrollbackpolicies + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of AutomatedRollbackPolicy + properties: + enabled: + default: false + description: Enabled controls whether automated rollbacks are active. + type: boolean + rollbackTemplate: + description: |- + RollbackTemplate groups all configuration applied to the Rollback resource + created by the controller. + properties: + metadata: + description: Metadata fields applied to the created Rollback resource. + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add to the Rollback resource. + type: object + labels: + additionalProperties: + type: string + description: Labels to add to the Rollback resource. + type: object + type: object + spec: + description: Spec fields applied to the created Rollback resource's + spec. + properties: + deploymentOptions: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: DeploymentOptions contains additional rollback + provider-specific options. + type: object + type: object + type: object + targetName: + description: |- + TargetName identifies which releases this policy applies to, + matching Release.config.targetName. + minLength: 1 + type: string + x-kubernetes-validations: + - message: TargetName is immutable + rule: self == oldSelf + trigger: + description: Trigger defines the Release condition that triggers a + rollback. + properties: + conditionStatus: + default: "True" + description: ConditionStatus is the status value that triggers + a rollback. + enum: + - "True" + - "False" + type: string + conditionType: + default: RollbackRequired + description: ConditionType is the Release status condition type + to watch. + type: string + type: object + required: + - enabled + - targetName + type: object + status: + description: Status defines the observed state of AutomatedRollbackPolicy + properties: + conditions: + description: Conditions represent the latest observations of the policy's + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastAutomatedRollbackTime: + description: LastAutomatedRollbackTime is when the last automated + rollback was created. + format: date-time + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/deploy.crd.gocardless.com_releases.yaml b/config/crd/bases/deploy.crd.gocardless.com_releases.yaml new file mode 100644 index 000000000..cc45aad6a --- /dev/null +++ b/config/crd/bases/deploy.crd.gocardless.com_releases.yaml @@ -0,0 +1,220 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: releases.deploy.crd.gocardless.com +spec: + group: deploy.crd.gocardless.com + names: + kind: Release + listKind: ReleaseList + plural: releases + shortNames: + - rel + singular: release + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .config.targetName + name: Target_Name + type: string + - jsonPath: .status.conditions[?(@.type=="Active")].status + name: Active + type: string + - jsonPath: .status.conditions[?(@.type=="Healthy")].status + name: Healthy + type: string + - jsonPath: .status.signature + name: Signature + type: string + - jsonPath: .status.deploymentStartTime + name: Started_At + type: string + - jsonPath: .status.deploymentEndTime + name: Ended_At + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + config: + description: ReleaseConfig the release configuration + properties: + revisions: + description: |- + Revisions is a list of revisions to be released. Each revision.name must be + unique across all revisions + items: + properties: + id: + description: ID is the unique identifier of the revision (e.g., + commit SHA, image digest, chart version) + type: string + metadata: + description: Metadata contains additional optional information + about the revision + properties: + author: + description: Author is the author of the commit, if available. + The field is optional. + type: string + branch: + description: Branch is the branch of the commit, if available. + The field is optional. + type: string + message: + description: Message is the message of the commit, if available. + The field is optional. + type: string + type: object + name: + description: Name is unique identifier for this revision. E.g. + application-revision, chart-revision, etc. + type: string + source: + description: Source identifies where this revision comes from + (e.g., repository URL, registry URL) + type: string + type: + description: Type specifies the kind of revision source (git, + container_image, helm_chart) + type: string + required: + - id + - name + type: object + maxItems: 10 + minItems: 1 + type: array + targetName: + description: TargetName is a namespace-unique identifier for this + release target + type: string + required: + - revisions + - targetName + type: object + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + status: + description: Status defines the observed state of Release + properties: + conditions: + description: |- + Conditions represent the latest available observations of a release's state. + Known conditions are: + * Active + * Healthy + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + deploymentEndTime: + description: DeploymentEndTime is the time when the release was completed. + format: date-time + type: string + deploymentStartTime: + description: DeploymentStartTime is the time when the release was + started. + format: date-time + type: string + message: + description: Message is a human-readable message indicating the state + of the release. + type: string + previousRelease: + description: PreviousRelease is the name of the release that was superseded + by this release. + properties: + releaseRef: + description: Other Release associated with this transition + type: string + transitionTime: + description: When the release transitioned to this state + format: date-time + type: string + type: object + signature: + description: |- + Signature is deterministic hash constructed out of the release revisions. + The signature is constructed out of the sum of names and ids of each revision. + type: string + type: object + required: + - config + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/deploy.crd.gocardless.com_rollbacks.yaml b/config/crd/bases/deploy.crd.gocardless.com_rollbacks.yaml new file mode 100644 index 000000000..1568ae7d6 --- /dev/null +++ b/config/crd/bases/deploy.crd.gocardless.com_rollbacks.yaml @@ -0,0 +1,249 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: rollbacks.deploy.crd.gocardless.com +spec: + group: deploy.crd.gocardless.com + names: + kind: Rollback + listKind: RollbackList + plural: rollbacks + shortNames: + - rb + singular: rollback + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.fromReleaseRef.name + name: From + type: string + - jsonPath: .spec.toReleaseRef.name + name: To + type: string + - jsonPath: .spec.initiatedBy.principal + name: Initiator + type: string + - jsonPath: .status.conditions[?(@.type=="Succeeded")].status + name: Succeeded + type: string + - jsonPath: .spec.reason + name: Reason + priority: 10 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Rollback is the Schema for the rollbacks API. It represents a historical + record of a release rollback operation. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of Rollback + properties: + deploymentOptions: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: DeploymentOptions contains additional provider-specific + options. + type: object + initiatedBy: + description: InitiatedBy tracks who or what triggered the rollback + for audit purposes. + properties: + principal: + description: Principal is the identifier of the person or system + who initiated the rollback + type: string + type: + description: |- + Type indicates what type of principal initiated the rollback + (e.g. "user", "system") + type: string + type: object + reason: + description: Reason is a human-readable message explaining why the + rollback was initiated. + maxLength: 512 + minLength: 1 + type: string + toReleaseRef: + description: |- + ToReleaseRef is the target release to rollback to. This is a reference to + the Release resource. If the Name field is left empty, the operator will pick + the latest healthy release for the specified Target to roll back to. + properties: + name: + description: |- + Name is the name of the release resource. If left empty, the system will + automatically select the appropriate release (e.g., the latest healthy release). + type: string + x-kubernetes-validations: + - message: Name is immutable once set + rule: oldSelf == '' || self == oldSelf + target: + description: |- + Target is the target name of the release. This is required to identify + which release target to operate on. + minLength: 1 + type: string + x-kubernetes-validations: + - message: Target is immutable + rule: self == oldSelf + required: + - target + type: object + required: + - reason + - toReleaseRef + type: object + status: + description: Status defines the observed state of Rollback + properties: + attemptCount: + description: |- + AttemptCount tracks how many times the controller has attempted to + initiate the rollback via the CI system. + format: int32 + type: integer + automatic: + description: |- + Automatic indicates whether this rollback was triggered automatically + (e.g., by a health check) or manually by a user. + type: boolean + completionTime: + description: CompletionTime is when the rollback operation completed + (successfully or failed). + format: date-time + type: string + conditions: + description: |- + Conditions represent the latest observations of the rollback's state. + Known condition types are: "InProgress", "Succeeded". + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + deploymentID: + description: |- + DeploymentID is the unique identifier for the deployment in the CICD system. + This is used to poll for deployment status. + type: string + deploymentURL: + description: DeploymentURL is the URL to the CI job performing the + rollback. + type: string + fromReleaseRef: + description: |- + FromReleaseRef is the release being rolled back from. This is a reference + to the Release resource name. + properties: + name: + description: |- + Name is the name of the release resource. If left empty, the system will + automatically select the appropriate release (e.g., the latest healthy release). + type: string + x-kubernetes-validations: + - message: Name is immutable once set + rule: oldSelf == '' || self == oldSelf + target: + description: |- + Target is the target name of the release. This is required to identify + which release target to operate on. + minLength: 1 + type: string + x-kubernetes-validations: + - message: Target is immutable + rule: self == oldSelf + required: + - target + type: object + message: + description: Message is a human-readable message indicating the state + of the rollback. + type: string + startTime: + description: StartTime is when the rollback operation started. + format: date-time + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/external/analysis-run-crd.yaml b/config/crd/external/analysis-run-crd.yaml new file mode 100644 index 000000000..ddcae6061 --- /dev/null +++ b/config/crd/external/analysis-run-crd.yaml @@ -0,0 +1,3701 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: analysisruns.argoproj.io +spec: + group: argoproj.io + names: + kind: AnalysisRun + listKind: AnalysisRunList + plural: analysisruns + shortNames: + - ar + singular: analysisrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: AnalysisRun status + jsonPath: .status.phase + name: Status + type: string + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + managedBy: + type: string + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + successPolicy: + properties: + rules: + items: + properties: + succeededCount: + format: int32 + type: integer + succeededIndexes: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + x-kubernetes-list-type: atomic + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + searches: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + x-kubernetes-list-type: atomic + ip: + type: string + required: + - ip + type: object + type: array + x-kubernetes-list-map-keys: + - ip + x-kubernetes-list-type: map + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + hostnameOverride: + type: string + imagePullSecrets: + items: + properties: + name: + default: "" + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + initContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + x-kubernetes-list-type: atomic + resourceClaims: + items: + properties: + name: + type: string + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + type: object + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxChangePolicy: + type: string + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + x-kubernetes-list-type: atomic + supplementalGroupsPolicy: + type: string + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + lookback: + type: boolean + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + terminate: + type: boolean + ttlStrategy: + properties: + secondsAfterCompletion: + format: int32 + type: integer + secondsAfterFailure: + format: int32 + type: integer + secondsAfterSuccess: + format: int32 + type: integer + type: object + required: + - metrics + type: object + status: + properties: + completedAt: + format: date-time + type: string + dryRunSummary: + properties: + count: + format: int32 + type: integer + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + successful: + format: int32 + type: integer + type: object + message: + type: string + metricResults: + items: + properties: + consecutiveError: + format: int32 + type: integer + consecutiveSuccess: + format: int32 + type: integer + count: + format: int32 + type: integer + dryRun: + type: boolean + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + measurements: + items: + properties: + finishedAt: + format: date-time + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + phase: + type: string + resumeAt: + format: date-time + type: string + startedAt: + format: date-time + type: string + value: + type: string + required: + - phase + type: object + type: array + message: + type: string + metadata: + additionalProperties: + type: string + type: object + name: + type: string + phase: + type: string + successful: + format: int32 + type: integer + required: + - name + - phase + type: object + type: array + phase: + type: string + runSummary: + properties: + count: + format: int32 + type: integer + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + successful: + format: int32 + type: integer + type: object + startedAt: + format: date-time + type: string + required: + - phase + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/external/analysis-template-crd.yaml b/config/crd/external/analysis-template-crd.yaml new file mode 100644 index 000000000..e0c9f2e94 --- /dev/null +++ b/config/crd/external/analysis-template-crd.yaml @@ -0,0 +1,3572 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: analysistemplates.argoproj.io +spec: + group: argoproj.io + names: + kind: AnalysisTemplate + listKind: AnalysisTemplateList + plural: analysistemplates + shortNames: + - at + singular: analysistemplate + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + managedBy: + type: string + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + successPolicy: + properties: + rules: + items: + properties: + succeededCount: + format: int32 + type: integer + succeededIndexes: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + x-kubernetes-list-type: atomic + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + searches: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + x-kubernetes-list-type: atomic + ip: + type: string + required: + - ip + type: object + type: array + x-kubernetes-list-map-keys: + - ip + x-kubernetes-list-type: map + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + hostnameOverride: + type: string + imagePullSecrets: + items: + properties: + name: + default: "" + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + initContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + x-kubernetes-list-type: atomic + resourceClaims: + items: + properties: + name: + type: string + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + type: object + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxChangePolicy: + type: string + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + x-kubernetes-list-type: atomic + supplementalGroupsPolicy: + type: string + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + lookback: + type: boolean + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/external/cluster-analysis-template-crd.yaml b/config/crd/external/cluster-analysis-template-crd.yaml new file mode 100644 index 000000000..3e3058904 --- /dev/null +++ b/config/crd/external/cluster-analysis-template-crd.yaml @@ -0,0 +1,3572 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: clusteranalysistemplates.argoproj.io +spec: + group: argoproj.io + names: + kind: ClusterAnalysisTemplate + listKind: ClusterAnalysisTemplateList + plural: clusteranalysistemplates + shortNames: + - cat + singular: clusteranalysistemplate + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + managedBy: + type: string + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + successPolicy: + properties: + rules: + items: + properties: + succeededCount: + format: int32 + type: integer + succeededIndexes: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + x-kubernetes-list-type: atomic + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + searches: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + x-kubernetes-list-type: atomic + ip: + type: string + required: + - ip + type: object + type: array + x-kubernetes-list-map-keys: + - ip + x-kubernetes-list-type: map + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + hostnameOverride: + type: string + imagePullSecrets: + items: + properties: + name: + default: "" + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + initContainers: + items: + properties: + args: + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + properties: + key: + type: string + optional: + default: false + type: boolean + path: + type: string + volumeName: + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + default: "" + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + items: + properties: + configMapRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + default: "" + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + type: string + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + restartPolicyRules: + items: + properties: + action: + type: string + exitCodes: + properties: + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + capabilities: + properties: + add: + items: + type: string + type: array + x-kubernetes-list-type: atomic + drop: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + default: "" + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + recursiveReadOnly: + type: string + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + x-kubernetes-list-type: atomic + resourceClaims: + items: + properties: + name: + type: string + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + resources: + properties: + claims: + items: + properties: + name: + type: string + request: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + type: object + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + appArmorProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxChangePolicy: + type: string + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + x-kubernetes-list-type: atomic + supplementalGroupsPolicy: + type: string + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + x-kubernetes-list-type: atomic + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + lookback: + type: boolean + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - region + - scope + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 41914f141..e995e5171 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,6 +9,9 @@ resources: - bases/workloads.crd.gocardless.com_consoles.yaml - bases/workloads.crd.gocardless.com_consoleauthorisations.yaml - bases/workloads.crd.gocardless.com_consoletemplates.yaml + - bases/deploy.crd.gocardless.com_releases.yaml + - bases/deploy.crd.gocardless.com_rollbacks.yaml + - bases/deploy.crd.gocardless.com_automatedrollbackpolicies.yaml # +kubebuilder:scaffold:crdkustomizeresource # patches: diff --git a/config/with-analysis/kustomization.yaml b/config/with-analysis/kustomization.yaml new file mode 100644 index 000000000..23ffcf8a6 --- /dev/null +++ b/config/with-analysis/kustomization.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base + +patches: + - path: role-analysis.patch.yaml + target: + group: rbac.authorization.k8s.io + version: v1 + kind: ClusterRole + name: release-manager diff --git a/config/with-analysis/role-analysis.patch.yaml b/config/with-analysis/role-analysis.patch.yaml new file mode 100644 index 000000000..37b6d370f --- /dev/null +++ b/config/with-analysis/role-analysis.patch.yaml @@ -0,0 +1,24 @@ +- op: add + path: /rules/- + value: + apiGroups: + - argoproj.io + resources: + - analysisruns + verbs: + - get + - list + - watch + - create +- op: add + path: /rules/- + value: + apiGroups: + - argoproj.io + resources: + - analysistemplates + - clusteranalysistemplates + verbs: + - get + - list + - watch diff --git a/go.mod b/go.mod index 04136099d..ad0f40caf 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,23 @@ module github.com/gocardless/theatre/v5 -go 1.24.5 +go 1.25.5 require ( cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/pubsub v1.50.1 + github.com/akuity/kargo/api v0.0.0-20260121184142-b7b90061271f github.com/alecthomas/kingpin v2.2.6+incompatible github.com/go-kit/kit v0.13.0 github.com/go-logr/logr v1.4.3 + github.com/google/go-github/v34 v34.0.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/vault/api v1.0.4 github.com/mitchellh/mapstructure v1.5.0 - github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.36.1 + github.com/onsi/ginkgo/v2 v2.27.4 + github.com/onsi/gomega v1.38.2 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.22.0 + github.com/prometheus/client_golang v1.23.2 github.com/sykesm/zap-logfmt v0.0.4 go.uber.org/zap v1.27.1 golang.org/x/oauth2 v0.32.0 @@ -30,6 +32,7 @@ require ( k8s.io/client-go v0.34.3 k8s.io/klog v1.0.0 k8s.io/kubectl v0.34.1 + k8s.io/kubernetes v1.35.0 sigs.k8s.io/controller-runtime v0.22.4 ) @@ -41,6 +44,7 @@ require ( cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -63,11 +67,14 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -95,17 +102,16 @@ require ( github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/nxadm/tail v1.4.8 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pierrec/lz4 v2.0.5+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/cobra v1.10.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.opencensus.io v0.24.0 // indirect @@ -116,32 +122,33 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/orderedmap v0.1.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect google.golang.org/grpc v1.76.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/square/go-jose.v2 v2.3.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/component-base v0.34.1 // indirect + k8s.io/apiextensions-apiserver v0.34.3 // indirect + k8s.io/component-base v0.34.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index e275bd0b1..87001c722 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,10 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/akuity/kargo/api v0.0.0-20260121184142-b7b90061271f h1:Unn/P1xuKU2kmIaL5mwdCF+BV8pHtI0znpAPsnophGg= +github.com/akuity/kargo/api v0.0.0-20260121184142-b7b90061271f/go.mod h1:tDP6DiBbJw1De2yHFgKe1bvcP3ckFZ8Gpma5tCptpO8= github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI= github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= @@ -75,12 +79,16 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU= @@ -106,11 +114,11 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 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/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -127,7 +135,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -146,11 +153,15 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v34 v34.0.0 h1:/siYFImY8KwGc5QD1gaPf+f8QX6tLwxNIco2RkYxoFA= +github.com/google/go-github/v34 v34.0.0/go.mod h1:w/2qlrXUfty+lbyO6tatnzIw97v1CM+/jZcwXMDiPQQ= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 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-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= @@ -199,11 +210,12 @@ github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0 github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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= @@ -224,8 +236,12 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -257,20 +273,11 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= -github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= @@ -284,15 +291,15 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= @@ -303,10 +310,11 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= +github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= +github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/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= @@ -315,16 +323,23 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sykesm/zap-logfmt v0.0.4 h1:U2WzRvmIWG1wDLCFY3sz8UeEmsdHQjHFNlIdmroVFaI= github.com/sykesm/zap-logfmt v0.0.4/go.mod h1:AuBd9xQjAe3URrWT1BBDk2v2onAZHkZkWRMiYZXiZWA= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -361,8 +376,8 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.12.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -379,15 +394,15 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -406,17 +421,11 @@ golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -440,7 +449,6 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= @@ -488,28 +496,23 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/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= @@ -520,30 +523,32 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= +k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/cli-runtime v0.34.3 h1:YRyMhiwX0dT9lmG0AtZDaeG33Nkxgt9OlCTZhRXj9SI= k8s.io/cli-runtime v0.34.3/go.mod h1:GVwL1L5uaGEgM7eGeKjaTG2j3u134JgG4dAI6jQKhMc= k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= -k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= +k8s.io/component-base v0.34.3 h1:zsEgw6ELqK0XncCQomgO9DpUIzlrYuZYA0Cgo+JWpVk= +k8s.io/component-base v0.34.3/go.mod h1:5iIlD8wPfWE/xSHTRfbjuvUul2WZbI2nOUK65XL0E/c= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= 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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI= k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kubernetes v1.35.0 h1:PUOojD8c8E3csMP5NX+nLLne6SGqZjrYCscptyBfWMY= +k8s.io/kubernetes v1.35.0/go.mod h1:Tzk9Y9W/XUFFFgTUVg+BAowoFe+Pc7koGLuaiLHdcFg= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= diff --git a/internal/controller/deploy/README.md b/internal/controller/deploy/README.md new file mode 100644 index 000000000..04fe0f18d --- /dev/null +++ b/internal/controller/deploy/README.md @@ -0,0 +1,118 @@ +# Deploy Controllers Overview + +This directory contains the controllers for the Deploy API group, which manages release, rollback, and automated rollback policy resources. + +## Controllers + +- `release_controller.go` - Manages Release resources + - `release_analysis.go` - Health analysis reconciliation for Release resources + - `release_culling.go` - Release culling logic +- `rollback_controller.go` - Manages Rollback resources +- `automated_rollback_policy_controller.go` - Manages AutomatedRollbackPolicy resources + +## Release Controller + +Responsible for reconciling Release resources. In the context of GoCardless, Release CRs are created +only after a deployment has completed. Hence, the `Release` CR is using a `.config`, instead of a +`.spec`, as the controller doesn't have a desired state to get to. + +### Annotation-driven status + +Release status is driven by annotations set by external tooling (e.g. the CI/CD pipeline): + +| Annotation | Effect | +| ---------------------------------------------- | ------------------------------------------- | +| `theatre.gocardless.com/active: "true"` | Sets the `Active` condition to `True` | +| `theatre.gocardless.com/deployment-start-time` | Sets `status.deploymentStartTime` (RFC3339) | +| `theatre.gocardless.com/deployment-end-time` | Sets `status.deploymentEndTime` (RFC3339) | +| `theatre.gocardless.com/previous-release` | Sets `status.previousRelease.releaseRef` | + +### Health analysis + +Health analysis is opt-in and must be enabled via the `AnalysisEnabled` controller flag. When +disabled, analysis is skipped entirely and the `Healthy` and `RollbackRequired` conditions remain +`Unknown`. + +When enabled, the controller creates `AnalysisRun` resources (part of the Argo Rollouts project) +owned by the Release, and updates the Release status based on their results. Templates are matched +three ways: + +- **By release labels** — namespaced `AnalysisTemplate` resources whose labels match the Release's labels +- **By custom selector** — namespaced and cluster-scoped templates matching the label selector in the `theatre.gocardless.com/analysis-selector` annotation +- **Global templates** — `ClusterAnalysisTemplate` resources with `global: "true"` label (opt-out per-release with annotation `theatre.gocardless.com/no-global-analysis: "true"`) + +The label on an `AnalysisTemplate` controls which Release condition it feeds: + +- `health: "true"` → contributes to the `Healthy` condition +- `rollback: "true"` → contributes to the `RollbackRequired` condition +- A single `AnalysisRun` can carry both labels and feed both conditions. + +The `pre-release-timestamp` argument, if declared in a template, is automatically populated with a +Unix timestamp of `deploymentStartTime - 5s` to ensure that the analysis for `RollbackRequired` condition is ran against the state before the deployment. + +### Release culling + +The controller culls old releases to prevent unbounded growth. Culling behaviour: + +- Only **inactive** releases are candidates for deletion +- Culling is skipped if there are not enough inactive candidates to safely reach the target limit +- A Kubernetes `Lease` object (named `theatre-release-cull-`) is used to prevent concurrent culls across multiple reconcile loops +- Default limit is **30** releases per target; configurable via the `theatre.gocardless.com/release-limit` annotation on the namespace +- Oldest releases (by `deploymentEndTime`, falling back to creation time) are deleted first + +## Rollback Controller + +Responsible for reconciling Rollback resources. Rollback resources are either created manually by a +user or automatically by the automated rollback controller. The Rollback controller is responsible +for initiating the rollback process through a configured CI/CD backend. As of time of writing, the +supported backends are: + +- **GitHub Deployments** - implemented using the GitHub REST API (see `pkg/cicd/github`) +- **ArgoCD** - implemented using the ArgoCD REST API (see `pkg/cicd/argocd`) + +The configured Rollback `.spec.deploymentOptions` will be passed to the chosen backend. + +### Reconcile flow + +1. On first reconcile, the controller records the currently active release as `status.fromReleaseRef`. +2. The controller triggers a deployment via the configured backend and sets `InProgress: True`. +3. The controller polls the backend every **15 seconds** until the deployment succeeds or fails. +4. On failure, the controller retries up to **3 attempts** total (trigger + re-polls). Retries only + occur for errors the backend marks as retryable; non-retryable errors fail immediately. +5. On terminal success or failure, the `Succeeded` condition is set accordingly and reconciliation stops. + +### Metrics + +The controller registers the following Prometheus metrics: + +- `rollbackTerminalTotal` — counter of rollbacks that reached a terminal state, labelled by outcome +- `rollbackCompletionDurationSeconds` — histogram of rollback duration from creation to completion +- `rollbackRetryCount` — histogram of the number of retries per rollback + +## Automated Rollback Controller + +Responsible for reconciling `AutomatedRollbackPolicy` resources and creating `Rollback` resources +when the configured trigger condition is met on the active release. + +The controller watches `Release` objects and maps +them to their policy. A reconciliation is triggered only when: + +- An `AutomatedRollbackPolicy` is created, updated, or deleted, **or** +- The policy's trigger condition **transitions** on an active `Release` + +This predicate filters out all other Release update events, keeping reconcile load low. + +**Note:** the controller expects exactly one `AutomatedRollbackPolicy` per target. If zero or more +than one policies exist for a target, Release update events for that target are silently dropped. + +### Reconcile flow + +1. Find the active `Release` for the policy's `targetName`. +2. Evaluate policy constraints (e.g. `spec.enabled`) and update the `Automated` status condition. +3. If rollback is allowed, check the trigger condition on the active release and confirm no + `Rollback` resource already exists for it (deduplication via owner index). +4. If all checks pass, create a `Rollback` resource with `toReleaseRef.name` left empty — the + Rollback controller resolves it to the latest healthy release. +5. Disable the policy after creating the rollback (`Automated: False`, reason `DisabledByController`). + +The policy will be re-enabled when the next Release recovers from the trigger condition (i.e. the configured condition status changes to the opposite of the configured condition status). diff --git a/internal/controller/deploy/automated_rollback_controller.go b/internal/controller/deploy/automated_rollback_controller.go new file mode 100644 index 000000000..e23576665 --- /dev/null +++ b/internal/controller/deploy/automated_rollback_controller.go @@ -0,0 +1,358 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/logging" + "github.com/gocardless/theatre/v5/pkg/recutil" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var ( + ErrNoRollbackPolicyFound = errors.New("no rollback policies found for target") + ErrNoActiveReleaseFound = errors.New("no active release found for target") +) + +type AutomatedRollbackReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme +} + +func (r *AutomatedRollbackReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + logger := r.Log.WithValues("component", "AutomatedRollback") + + ctrlBuilder := ctrl.NewControllerManagedBy(mgr). + For(&deployv1alpha1.AutomatedRollbackPolicy{}). + Watches(&deployv1alpha1.Release{}, + handler.EnqueueRequestsFromMapFunc(r.mapReleaseToPolicy(mgr))). + WithEventFilter(r.onReleaseConditionsChangedPredicate(ctx, logger)) + + err := mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.AutomatedRollbackPolicy{}, + IndexFieldPolicyTargetName, + func(rawObj client.Object) []string { + policy := rawObj.(*deployv1alpha1.AutomatedRollbackPolicy) + return []string{policy.Spec.TargetName} + }, + ) + if err != nil { + return err + } + + err = mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Rollback{}, + IndexFieldOwner, + func(rawObj client.Object) []string { + rollback := rawObj.(*deployv1alpha1.Rollback) + owner := metav1.GetControllerOf(rollback) + if owner == nil { + return nil + } + if owner.APIVersion != apiGVStr || owner.Kind != "Release" { + return nil + } + return []string{owner.Name} + }, + ) + if err != nil { + return err + } + + err = mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Release{}, + IndexFieldReleaseTarget, + func(rawObj client.Object) []string { + release := rawObj.(*deployv1alpha1.Release) + return []string{release.TargetName} + }, + ) + if err != nil { + return err + } + + return ctrlBuilder.Complete( + recutil.ResolveAndReconcile( + ctx, logger, mgr, &deployv1alpha1.AutomatedRollbackPolicy{}, + func(logger logr.Logger, request ctrl.Request, obj runtime.Object) (ctrl.Result, error) { + return r.Reconcile(ctx, logger, request, obj.(*deployv1alpha1.AutomatedRollbackPolicy)) + }, + ), + ) +} + +func (r *AutomatedRollbackReconciler) onReleaseConditionsChangedPredicate(ctx context.Context, logger logr.Logger) predicate.Predicate { + return predicate.Funcs{ + // All logs are at V(1) level, so we need to enable debug logging to see them + UpdateFunc: func(e event.UpdateEvent) bool { + if _, isPolicy := e.ObjectNew.(*deployv1alpha1.AutomatedRollbackPolicy); isPolicy { + return true + } + + if release, isRelease := e.ObjectNew.(*deployv1alpha1.Release); isRelease { + policyList := &deployv1alpha1.AutomatedRollbackPolicyList{} + if err := r.List(ctx, policyList, + client.InNamespace(release.Namespace), + client.MatchingFields(map[string]string{IndexFieldPolicyTargetName: release.TargetName}), + ); err != nil { + logger.V(1).Info("Failed to list policies for target, skipping", "target", release.TargetName, "error", err) + return false + } + + if len(policyList.Items) != 1 { + logger.V(1).Info("Expected exactly one policy for target, skipping", "target", release.TargetName) + return false + } + policy := policyList.Items[0] + + oldRelease := e.ObjectOld.(*deployv1alpha1.Release) + triggerTransitioned := recutil.HasConditionTransitioned(oldRelease.Status.Conditions, release.Status.Conditions, policy.Spec.Trigger.ConditionType) + + logger.V(1).Info("Release conditions changed, checking if reconciliation should be triggered", + "release", release.Name, + "triggerTransitioned", triggerTransitioned, + "isActive", release.IsConditionActiveTrue()) + + return release.IsConditionActiveTrue() && triggerTransitioned + } + + return false + }, + CreateFunc: func(e event.CreateEvent) bool { + _, isPolicy := e.Object.(*deployv1alpha1.AutomatedRollbackPolicy) + return isPolicy + }, + DeleteFunc: func(e event.DeleteEvent) bool { + _, isPolicy := e.Object.(*deployv1alpha1.AutomatedRollbackPolicy) + return isPolicy + }, + } +} + +func (r *AutomatedRollbackReconciler) mapReleaseToPolicy(mgr ctrl.Manager) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + release, isRelease := obj.(*deployv1alpha1.Release) + if !isRelease { + return nil + } + + policyList := &deployv1alpha1.AutomatedRollbackPolicyList{} + err := mgr.GetClient().List( + ctx, + policyList, + client.InNamespace(release.Namespace), + client.MatchingFields(map[string]string{IndexFieldPolicyTargetName: release.TargetName})) + + if err != nil || len(policyList.Items) != 1 { + // Do nothing on error or when none or multiple policies for a targetName + return nil + } + + return []reconcile.Request{{ + NamespacedName: client.ObjectKeyFromObject(&policyList.Items[0]), + }} + } +} + +func (r *AutomatedRollbackReconciler) Reconcile(ctx context.Context, logger logr.Logger, request ctrl.Request, policy *deployv1alpha1.AutomatedRollbackPolicy) (ctrl.Result, error) { + logger = logger.WithValues("namespace", request.Namespace, "target", policy.Spec.TargetName, "policy", policy.Name) + logger.Info("Reconcile") + + // Find active release + release, err := r.getActiveReleaseForPolicy(ctx, policy) + // If no active release is found, we want to continue and + // evaluate the policy constraints and update its status + if err != nil && !errors.Is(err, ErrNoActiveReleaseFound) { + return ctrl.Result{}, err + } + + evaluation := evaluateAndUpdatePolicyStatus(policy, release) + if !evaluation.Allowed { + logger.Info("rollback is not allowed, nothing to do", "reason", evaluation.Reason) + err := r.createOrUpdate(ctx, logger, policy, "policy") + return ctrl.Result{}, err + } + + // Check if rollback should be triggered + shouldTrigger, err := r.shouldTriggerRollback(ctx, logger, policy, release) + if err != nil { + return ctrl.Result{}, err + } + + if shouldTrigger { + // Create rollback and update policy + if err := r.createRollback(ctx, logger, policy, release); err != nil { + return ctrl.Result{}, err + } + + disableAutomationAfterRollback(policy) + } + + err = r.createOrUpdate(ctx, logger, policy, "policy") + return ctrl.Result{}, err +} + +// createOrUpdate creates or updates (spec or status) the given object, logging the outcome. +func (r *AutomatedRollbackReconciler) createOrUpdate(ctx context.Context, logger logr.Logger, object client.Object, objectType string) error { + outcome, err := recutil.CreateOrUpdate(ctx, r.Client, object, recutil.StatusDiff) + if err != nil { + logger.Error(err, fmt.Sprintf("failed to update %s status", objectType)) + return err + } + + switch outcome { + case recutil.Create: + logger.Info("created", objectType, object.GetName(), "event", EventCreated) + case recutil.StatusUpdate: + logger.Info("status updated", objectType, object.GetName(), "event", EventSuccessfulStatusUpdate) + case recutil.Update: + logger.Info("updated", objectType, object.GetName(), "event", EventSuccessfulUpdate) + case recutil.None: + logging.WithNoRecord(logger).Info("No status update needed", "event", EventNoStatusUpdate) + default: + logger.Info("Unexpected outcome from CreateOrUpdate", "outcome", outcome) + } + + return nil +} + +// shouldTriggerRollback checks if a rollback should be triggered for the given policy. +// Rollback is triggered when the release is active, there isn't a prior rollback and +// the trigger condition is met. +func (r *AutomatedRollbackReconciler) shouldTriggerRollback(ctx context.Context, logger logr.Logger, policy *deployv1alpha1.AutomatedRollbackPolicy, release *deployv1alpha1.Release) (bool, error) { + if release == nil { + logger.Info("no active release found, nothing to do") + return false, nil + } + + // Check if release already has a rollback + hasRollback, err := r.hasRollback(ctx, release) + if err != nil { + return false, err + } + + if hasRollback { + logger.Info("release already has a rollback, nothing to do", "release", release.Name) + return false, nil + } + + // Check trigger condition + triggerConditionType := policy.Spec.Trigger.ConditionType + triggerConditionStatus := policy.Spec.Trigger.ConditionStatus + if !meta.IsStatusConditionPresentAndEqual(release.Status.Conditions, triggerConditionType, triggerConditionStatus) { + logger.Info("trigger condition not met, nothing to do", "release", release.Name) + return false, nil + } + + return true, nil +} + +func (r *AutomatedRollbackReconciler) createRollback(ctx context.Context, logger logr.Logger, policy *deployv1alpha1.AutomatedRollbackPolicy, release *deployv1alpha1.Release) error { + reason := fmt.Sprintf("Release %s status condition %s is %s", release.Name, policy.Spec.Trigger.ConditionType, policy.Spec.Trigger.ConditionStatus) + // Merge release labels with policy template labels; policy template labels take precedence. + labels := make(map[string]string) + maps.Copy(labels, release.Labels) + maps.Copy(labels, policy.Spec.RollbackTemplate.Metadata.Labels) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-", release.TargetName), + Namespace: release.Namespace, + Labels: labels, + Annotations: policy.Spec.RollbackTemplate.Metadata.Annotations, + }, + Spec: deployv1alpha1.RollbackSpec{ + Reason: reason, + InitiatedBy: deployv1alpha1.RollbackInitiator{ + Principal: "automated-rollback-controller", + Type: "system", + }, + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: policy.Spec.TargetName, + }, + DeploymentOptions: policy.Spec.RollbackTemplate.Spec.DeploymentOptions, + }, + } + + if err := r.createOrUpdate(ctx, logger, rollback, "rollback"); err != nil { + logger.Error(err, "failed to create rollback") + return err + } + + return nil +} + +// disableAutomationAfterRollback disables the automated rollback policy after a rollback has been performed +// by setting the LastAutomatedRollbackTime and updating the condition +func disableAutomationAfterRollback(policy *deployv1alpha1.AutomatedRollbackPolicy) { + now := metav1.NewTime(time.Now()) + policy.Status.LastAutomatedRollbackTime = &now + + meta.SetStatusCondition(&policy.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.AutomatedRollbackPolicyConditionActive, + Status: metav1.ConditionFalse, + Reason: deployv1alpha1.AutomatedRollbackPolicyReasonDisabledByController, + Message: "Automation disabled after performing a rollback. Will be enabled after the next healthy release.", + }) +} + +func (r *AutomatedRollbackReconciler) getActiveReleaseForPolicy(ctx context.Context, policy *deployv1alpha1.AutomatedRollbackPolicy) (*deployv1alpha1.Release, error) { + releaseList, err := GetReleasesForTarget(ctx, r.Client, policy.Namespace, policy.Spec.TargetName) + if err != nil { + return nil, err + } + + activeRelease := deployv1alpha1.FindActiveRelease(releaseList) + if activeRelease == nil { + return nil, ErrNoActiveReleaseFound + } + + return activeRelease, nil +} + +func (r *AutomatedRollbackReconciler) hasRollback(ctx context.Context, release *deployv1alpha1.Release) (bool, error) { + rollbackList := &deployv1alpha1.RollbackList{} + if err := r.List(ctx, rollbackList, + client.InNamespace(release.Namespace), + client.MatchingFields(map[string]string{IndexFieldOwner: release.Name})); err != nil { + return false, fmt.Errorf("failed to list rollbacks: %w", err) + } + + return len(rollbackList.Items) > 0, nil +} + +// evaluateAndUpdatePolicyStatus evaluates the policy constraints +// and updates the policy status conditions accordingly +func evaluateAndUpdatePolicyStatus(policy *deployv1alpha1.AutomatedRollbackPolicy, release *deployv1alpha1.Release) deployv1alpha1.PolicyEvaluation { + result := policy.EvaluatePolicyConstraints(release) + + status := metav1.ConditionFalse + if result.Allowed { + status = metav1.ConditionTrue + } + meta.SetStatusCondition(&policy.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.AutomatedRollbackPolicyConditionActive, + Status: status, + Reason: result.Reason, + Message: result.Message, + }) + + return result +} diff --git a/internal/controller/deploy/automated_rollback_controller_test.go b/internal/controller/deploy/automated_rollback_controller_test.go new file mode 100644 index 000000000..017378b4e --- /dev/null +++ b/internal/controller/deploy/automated_rollback_controller_test.go @@ -0,0 +1,79 @@ +package deploy + +import ( + "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("AutomatedRollback", func() { + var ( + policy v1alpha1.AutomatedRollbackPolicy + ) + + BeforeEach(func() { + policy = v1alpha1.AutomatedRollbackPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "default", + }, + Spec: v1alpha1.AutomatedRollbackPolicySpec{ + TargetName: "test-target", + }, + Status: v1alpha1.AutomatedRollbackPolicyStatus{}, + } + }) + + Context("disableAutomationAfterRollback", func() { + It("should set LastAutomatedRollbackTime and update condition", func() { + disableAutomationAfterRollback(&policy) + + Expect(policy.Status.LastAutomatedRollbackTime).NotTo(BeNil()) + Expect(policy.Status.Conditions).NotTo(BeEmpty()) + Expect(policy.Status.Conditions[0].Type).To(Equal(v1alpha1.AutomatedRollbackPolicyConditionActive)) + Expect(policy.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(policy.Status.Conditions[0].Reason).To(Equal(v1alpha1.AutomatedRollbackPolicyReasonDisabledByController)) + }) + }) + + Context("evaluateAndUpdatePolicyStatus", func() { + Context("when evaluatePolicyConstraints returns allowed=true", func() { + BeforeEach(func() { + policy.Spec.Enabled = true + }) + + It("should set Active condition to True", func() { + evaluateAndUpdatePolicyStatus(&policy, nil) + + condition := meta.FindStatusCondition(policy.Status.Conditions, v1alpha1.AutomatedRollbackPolicyConditionActive) + Expect(condition).ToNot(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + }) + }) + + Context("when evaluatePolicyConstraints returns allowed=false", func() { + BeforeEach(func() { + policy.Spec.Enabled = false + }) + + It("should set Active condition to False", func() { + evaluateAndUpdatePolicyStatus(&policy, nil) + + condition := meta.FindStatusCondition(policy.Status.Conditions, v1alpha1.AutomatedRollbackPolicyConditionActive) + Expect(condition).ToNot(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + Expect(condition.Reason).To(Equal(v1alpha1.AutomatedRollbackPolicyReasonSetByUser)) + }) + + It("should propagate the reason and message from evaluation", func() { + result := evaluateAndUpdatePolicyStatus(&policy, nil) + + Expect(result.Allowed).To(BeFalse()) + Expect(result.Reason).To(Equal(v1alpha1.AutomatedRollbackPolicyReasonSetByUser)) + Expect(result.Message).To(Equal("Automated rollback policy is disabled")) + }) + }) + }) +}) diff --git a/internal/controller/deploy/events.go b/internal/controller/deploy/events.go new file mode 100644 index 000000000..dcbae04c0 --- /dev/null +++ b/internal/controller/deploy/events.go @@ -0,0 +1,23 @@ +package deploy + +const ( + // Generic CRUD events + EventCreated = "Created" + EventSuccessfulStatusUpdate = "SuccessfulStatusUpdate" + EventSuccessfulUpdate = "SuccessfulUpdate" + EventNoStatusUpdate = "NoStatusUpdate" + + // Culling events + EventReleaseCulled = "ReleasedCulled" + + // Deployment events + EventDeploymentTriggered = "DeploymentTriggered" + EventDeploymentTriggerFailed = "DeploymentTriggerFailed" + EventDeploymentFailed = "DeploymentFailed" + EventRollbackSucceeded = "RollbackSucceeded" + EventRollbackFailed = "RollbackFailed" + + // Automated rollback events + EventErrorGettingRollbackPolicy = "ErrorGettingRollbackPolicy" + EventAutomatedRollbackTriggered = "AutomatedRollbackTriggered" +) diff --git a/internal/controller/deploy/indexes.go b/internal/controller/deploy/indexes.go new file mode 100644 index 000000000..b31a80883 --- /dev/null +++ b/internal/controller/deploy/indexes.go @@ -0,0 +1,18 @@ +package deploy + +const ( + // IndexFieldOwner indexes objects by their controller owner reference + IndexFieldOwner = ".metadata.controller" + + // IndexFieldReleaseTarget indexes releases by their target name + IndexFieldReleaseTarget = ".config.targetName" + + // IndexFieldReleaseActive indexes releases by their active condition status + IndexFieldReleaseActive = "status.conditions.active" + + // IndexFieldRollbackTarget indexes rollbacks by their target name + IndexFieldRollbackTarget = ".spec.toReleaseRef.target" + + // IndexFieldPolicyTargetName indexes policies by their target name + IndexFieldPolicyTargetName = ".spec.targetName" +) diff --git a/internal/controller/deploy/integration/automated_rollback_test.go b/internal/controller/deploy/integration/automated_rollback_test.go new file mode 100644 index 000000000..f69c15041 --- /dev/null +++ b/internal/controller/deploy/integration/automated_rollback_test.go @@ -0,0 +1,430 @@ +package integration + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" +) + +var _ = Describe("AutomatedRollbackReconciler", func() { + var ( + testNamespace string + policy *deployv1alpha1.AutomatedRollbackPolicy + targetName string + k8sClient client.Client + ) + + BeforeEach(func() { + testNamespace = setupTestNamespace(ctx) + k8sClient = releaseMgr.GetClient() + targetName = generateTargetName() + policy = generatePolicy(testNamespace, targetName, nil) + }) + + Describe("Policy evaluation", func() { + Context("when policy is disabled", func() { + BeforeEach(func() { + policy.Spec.Enabled = false + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + }) + + It("should not trigger rollback even if release meets trigger condition", func() { + createActiveReleaseWithRollbackRequired(k8sClient, testNamespace, targetName) + expectNoRollbackCreated(k8sClient, testNamespace) + }) + + It("should set Active condition to False with reason SetByUser", func() { + By("Verifying policy status has Active=False with reason SetByUser") + Eventually(func(g Gomega) { + p := &deployv1alpha1.AutomatedRollbackPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), p)).To(Succeed()) + cond := meta.FindStatusCondition(p.Status.Conditions, deployv1alpha1.AutomatedRollbackPolicyConditionActive) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(deployv1alpha1.AutomatedRollbackPolicyReasonSetByUser)) + }).Should(Succeed()) + }) + }) + + Context("when policy is enabled", func() { + BeforeEach(func() { + policy.Spec.Enabled = true + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + }) + + It("should set Active condition to True", func() { + By("Verifying policy status has Active=True") + Eventually(func(g Gomega) { + p := &deployv1alpha1.AutomatedRollbackPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), p)).To(Succeed()) + cond := meta.FindStatusCondition(p.Status.Conditions, deployv1alpha1.AutomatedRollbackPolicyConditionActive) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + }) + }) + }) + + Describe("Rollback triggering", func() { + Context("when release meets trigger condition", func() { + var release *deployv1alpha1.Release + + BeforeEach(func() { + By("Creating enabled policy") + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + + By("Waiting for policy to be reconciled") + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), &deployv1alpha1.AutomatedRollbackPolicy{}) + }).Should(Succeed()) + + By("Creating active release with trigger condition") + release = createActiveReleaseWithRollbackRequired(k8sClient, testNamespace, targetName) + }) + + It("should create a Rollback with correct spec and initiatedBy", func() { + By("Waiting for Rollback to be created") + rollback := expectRollbackCreated(k8sClient, testNamespace) + + By("Verifying Rollback spec") + Expect(rollback.Spec.ToReleaseRef.Target).To(Equal(targetName)) + Expect(rollback.Spec.Reason).To(ContainSubstring(release.Name)) + Expect(rollback.Spec.Reason).To(ContainSubstring(deployv1alpha1.ReleaseConditionRollbackRequired)) + Expect(rollback.Spec.InitiatedBy.Principal).To(Equal("automated-rollback-controller")) + Expect(rollback.Spec.InitiatedBy.Type).To(Equal("system")) + }) + + It("should update policy status with lastAutomatedRollbackTime", func() { + By("Waiting for Rollback to be created") + expectRollbackCreated(k8sClient, testNamespace) + + By("Verifying policy status is updated") + Eventually(func(g Gomega) { + p := &deployv1alpha1.AutomatedRollbackPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), p)).To(Succeed()) + g.Expect(p.Status.LastAutomatedRollbackTime).NotTo(BeNil()) + g.Expect(p.Status.Conditions).NotTo(BeEmpty()) + cond := meta.FindStatusCondition(p.Status.Conditions, deployv1alpha1.AutomatedRollbackPolicyConditionActive) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(deployv1alpha1.AutomatedRollbackPolicyReasonDisabledByController)) + }).Should(Succeed()) + }) + + It("should re-enable automation, if a new release recovers from rollback", func() { + By("Waiting for policy to be disabled") + Eventually(func() bool { + p := &deployv1alpha1.AutomatedRollbackPolicy{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), p); err != nil { + return false + } + cond := meta.FindStatusCondition(p.Status.Conditions, deployv1alpha1.AutomatedRollbackPolicyConditionActive) + return cond != nil && cond.Status == metav1.ConditionFalse + }).Should(BeTrue()) + + By("Deactivating the old release") + oldRelease := &deployv1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(release), oldRelease)).To(Succeed()) + oldRelease.Annotations[deployv1alpha1.AnnotationKeyReleaseActivate] = "false" + Expect(k8sClient.Update(ctx, oldRelease)).To(Succeed()) + + By("Waiting for old release to be deactivated") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(oldRelease), r); err != nil { + return false + } + return !r.IsConditionActiveTrue() + }).Should(BeTrue()) + + By("Creating a new active release") + newRelease := createRelease(ctx, testNamespace, targetName, map[string]string{ + deployv1alpha1.AnnotationKeyReleaseActivate: "true", + }) + By("Waiting for new release to be active") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(newRelease), r); err != nil { + return false + } + + return r.IsConditionActiveTrue() + }).Should(BeTrue()) + + By("Setting the RollbackRequired=false") + // refetch the release + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(newRelease), newRelease)).To(Succeed()) + meta.SetStatusCondition(&newRelease.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.ReleaseConditionRollbackRequired, + Status: metav1.ConditionFalse, + Reason: "AnalysisSucceeded", + Message: "Analysis completed successfully", + }) + Expect(k8sClient.Status().Update(ctx, newRelease)).To(Succeed()) + + By("Verifying policy is re-enabled") + Eventually(func() bool { + p := &deployv1alpha1.AutomatedRollbackPolicy{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), p); err != nil { + return false + } + cond := meta.FindStatusCondition(p.Status.Conditions, deployv1alpha1.AutomatedRollbackPolicyConditionActive) + return cond != nil && cond.Status == metav1.ConditionTrue + }, "2s", "100ms").Should(BeTrue()) + }) + }) + + Context("when policy has a rollback template", func() { + BeforeEach(func() { + By("Creating policy with deploymentOptions") + policy.Spec.RollbackTemplate = deployv1alpha1.RollbackTemplate{ + Metadata: deployv1alpha1.RollbackTemplateMetadata{ + Labels: map[string]string{ + "test": "label", + }, + Annotations: map[string]string{ + "test": "annotation", + }, + }, + Spec: deployv1alpha1.RollbackTemplateSpec{ + DeploymentOptions: map[string]apiextv1.JSON{ + "skip_canary": {Raw: []byte(`true`)}, + "timeout": {Raw: []byte(`300`)}, + }, + }, + } + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + + By("Waiting for policy to be reconciled") + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), &deployv1alpha1.AutomatedRollbackPolicy{}) + }).Should(Succeed()) + + By("Creating active release with trigger condition") + createActiveReleaseWithRollbackRequired(k8sClient, testNamespace, targetName) + }) + + It("should pass deploymentOptions from policy to rollback", func() { + By("Waiting for Rollback to be created") + rollback := expectRollbackCreated(k8sClient, testNamespace) + + By("Verifying deploymentOptions are passed to rollback") + Expect(rollback.Spec.DeploymentOptions).To(HaveKey("skip_canary")) + Expect(rollback.Spec.DeploymentOptions).To(HaveKey("timeout")) + + By("Verifying metadata is passed to rollback") + Expect(rollback.Labels).To(HaveKey("test")) + Expect(rollback.Annotations).To(HaveKey("test")) + }) + }) + + Context("when release already has a rollback", func() { + BeforeEach(func() { + By("Creating enabled policy") + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + + By("Waiting for policy to be reconciled") + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), &deployv1alpha1.AutomatedRollbackPolicy{}) + }).Should(Succeed()) + + By("Creating active release without trigger condition first") + release := createRelease(ctx, testNamespace, targetName, map[string]string{ + deployv1alpha1.AnnotationKeyReleaseActivate: deployv1alpha1.AnnotationValueReleaseActivateTrue, + }) + + By("Waiting for release to be active") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(release), r); err != nil { + return false + } + return r.IsConditionActiveTrue() + }).Should(BeTrue()) + + By("Creating existing rollback with owner reference to release") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(release), release)).To(Succeed()) + existingRollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-rollback", + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: deployv1alpha1.GroupVersion.String(), + Kind: "Release", + Name: release.Name, + UID: release.UID, + Controller: ptr.To(true), + }}, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: targetName, + }, + Reason: "Pre-existing rollback for testing", + }, + } + Expect(k8sClient.Create(ctx, existingRollback)).To(Succeed()) + + By("Setting RollbackRequired=True condition on the release to trigger reconciliation") + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.ReleaseConditionRollbackRequired, + Status: metav1.ConditionTrue, + Reason: deployv1alpha1.ReasonAnalysisFailed, + Message: "Health check failed", + }) + Expect(k8sClient.Status().Update(ctx, release)).To(Succeed()) + }) + + It("should not create another Rollback", func() { + By("Verifying no additional Rollback is created") + Consistently(func() int { + rollbackList := &deployv1alpha1.RollbackList{} + Expect(k8sClient.List(ctx, rollbackList, client.InNamespace(testNamespace))).To(Succeed()) + return len(rollbackList.Items) + }, "2s", "200ms").Should(Equal(1)) + }) + }) + + Context("when no active release exists", func() { + BeforeEach(func() { + By("Creating enabled policy without any release") + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + }) + + It("should not create a Rollback", func() { + expectNoRollbackCreated(k8sClient, testNamespace) + }) + }) + + Context("when release does not meet trigger condition", func() { + BeforeEach(func() { + By("Creating enabled policy") + Expect(k8sClient.Create(ctx, policy)).To(Succeed()) + + By("Waiting for policy to be reconciled") + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), &deployv1alpha1.AutomatedRollbackPolicy{}) + }).Should(Succeed()) + + By("Creating active release without trigger condition") + release := createRelease(ctx, testNamespace, targetName, map[string]string{ + deployv1alpha1.AnnotationKeyReleaseActivate: deployv1alpha1.AnnotationValueReleaseActivateTrue, + }) + + By("Waiting for release to be active") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(release), r); err != nil { + return false + } + return r.IsConditionActiveTrue() + }).Should(BeTrue()) + }) + + It("should not create a Rollback", func() { + expectNoRollbackCreated(k8sClient, testNamespace) + }) + }) + }) +}) + +// Helper functions + +// createActiveReleaseWithRollbackRequired creates an active release and sets the RollbackRequired=True condition. +// It waits for the release to be active and for the condition to be set before returning. +func createActiveReleaseWithRollbackRequired(k8sClient client.Client, namespace, targetName string) *deployv1alpha1.Release { + By("Creating an active release") + release := createRelease(ctx, namespace, targetName, map[string]string{ + deployv1alpha1.AnnotationKeyReleaseActivate: deployv1alpha1.AnnotationValueReleaseActivateTrue, + }) + + By("Waiting for release to be active") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(release), r); err != nil { + return false + } + return r.IsConditionActiveTrue() + }).Should(BeTrue()) + + By("Setting RollbackRequired=True condition on the release") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(release), release)).To(Succeed()) + meta.SetStatusCondition(&release.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.ReleaseConditionRollbackRequired, + Status: metav1.ConditionTrue, + Reason: deployv1alpha1.ReasonAnalysisFailed, + Message: "Health check failed", + }) + Expect(k8sClient.Status().Update(ctx, release)).To(Succeed()) + + By("Verifying the RollbackRequired condition was set") + Eventually(func() bool { + r := &deployv1alpha1.Release{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(release), r); err != nil { + return false + } + return meta.IsStatusConditionTrue(r.Status.Conditions, deployv1alpha1.ReleaseConditionRollbackRequired) + }).Should(BeTrue()) + + return release +} + +// expectRollbackCreated waits for a Rollback to be created in the namespace and returns it. +func expectRollbackCreated(k8sClient client.Client, namespace string) *deployv1alpha1.Rollback { + var rollback *deployv1alpha1.Rollback + Eventually(func() bool { + rollbackList := &deployv1alpha1.RollbackList{} + if err := k8sClient.List(ctx, rollbackList, client.InNamespace(namespace)); err != nil { + return false + } + if len(rollbackList.Items) > 0 { + rollback = &rollbackList.Items[0] + return true + } + return false + }).Should(BeTrue()) + return rollback +} + +// expectNoRollbackCreated verifies that no Rollback resources are created in the namespace. +func expectNoRollbackCreated(k8sClient client.Client, namespace string) { + By("Verifying no Rollback is created") + Consistently(func() int { + rollbackList := &deployv1alpha1.RollbackList{} + Expect(k8sClient.List(ctx, rollbackList, client.InNamespace(namespace))).To(Succeed()) + return len(rollbackList.Items) + }, "2s", "200ms").Should(Equal(0)) +} + +func generatePolicy(namespace, targetName string, opts map[string]apiextv1.JSON) *deployv1alpha1.AutomatedRollbackPolicy { + policy := &deployv1alpha1.AutomatedRollbackPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetName, + Namespace: namespace, + }, + Spec: deployv1alpha1.AutomatedRollbackPolicySpec{ + TargetName: targetName, + Enabled: true, + Trigger: deployv1alpha1.RollbackTrigger{ + ConditionType: deployv1alpha1.ReleaseConditionRollbackRequired, + ConditionStatus: metav1.ConditionTrue, + }, + }, + } + + if opts != nil { + policy.Spec.RollbackTemplate = deployv1alpha1.RollbackTemplate{ + Spec: deployv1alpha1.RollbackTemplateSpec{ + DeploymentOptions: opts, + }, + } + } + + return policy +} diff --git a/internal/controller/deploy/integration/release_test.go b/internal/controller/deploy/integration/release_test.go new file mode 100644 index 000000000..445be06e7 --- /dev/null +++ b/internal/controller/deploy/integration/release_test.go @@ -0,0 +1,408 @@ +package integration + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "maps" + "time" + + "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("ReleaseController", func() { + var ( + testNamespace string + defaultRelease *v1alpha1.Release + k8sClient client.Client + ) + + BeforeEach(func() { + testNamespace = setupTestNamespace(ctx) + defaultRelease = createRelease(ctx, testNamespace, "default-target", nil) + k8sClient = releaseMgr.GetClient() + }) + + Context("handleAnnotations", func() { + var fetchedRelease *v1alpha1.Release + + JustBeforeEach(func() { + fetchedRelease = &v1alpha1.Release{} + Eventually(func() error { + err := k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, fetchedRelease) + if err != nil { + return err + } + + if !fetchedRelease.IsStatusInitialised() { + return fmt.Errorf("release hasn't been initialised by the reconciler") + } + return nil + }, "5s", "100ms").Should(Succeed()) + }) + + Context("AnnotationKeyReleaseDeploymentStartTime", func() { + It("should set status.deploymentStartTime when annotation is added", func() { + stringTimestamp := "2025-12-08T14:42:00Z" + metav1Timestamp := getMetaV1Timestamp(stringTimestamp) + + By("Setting the deployment start time annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentStartTime: stringTimestamp, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentStartTime.Unix() == metav1Timestamp.Unix() + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should clear status.deploymentStartTime when annotation is removed", func() { + stringTimestamp := "2025-12-08T14:42:00Z" + + By("Setting the deployment start time annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentStartTime: stringTimestamp, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return !updatedObj.Status.DeploymentStartTime.IsZero() + }, "5s", "100ms").Should(BeTrue()) + + By("Removing the annotation") + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, fetchedRelease)).To(Succeed()) + delete(fetchedRelease.Annotations, v1alpha1.AnnotationKeyReleaseDeploymentStartTime) + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentStartTime.IsZero() + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should not update status.deploymentStartTime when annotation has invalid timestamp", func() { + By("Setting the deployment start time annotation with an invalid timestamp") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentStartTime: "not-a-valid-timestamp", + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Consistently(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentStartTime.IsZero() + }, "2s", "100ms").Should(BeTrue()) + }) + }) + + Context("AnnotationKeyReleaseDeploymentEndTime", func() { + It("should set status.deploymentEndTime when annotation is added", func() { + stringTimestamp := "2025-12-08T15:30:00Z" + metav1Timestamp := getMetaV1Timestamp(stringTimestamp) + + By("Setting the deployment end time annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentEndTime: stringTimestamp, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentEndTime.Unix() == metav1Timestamp.Unix() + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should clear status.deploymentEndTime when annotation is removed", func() { + stringTimestamp := "2025-12-08T15:30:00Z" + + By("Setting the deployment end time annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentEndTime: stringTimestamp, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return !updatedObj.Status.DeploymentEndTime.IsZero() + }, "5s", "100ms").Should(BeTrue()) + + By("Removing the annotation") + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, fetchedRelease)).To(Succeed()) + delete(fetchedRelease.Annotations, v1alpha1.AnnotationKeyReleaseDeploymentEndTime) + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentEndTime.IsZero() + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should not update status.deploymentEndTime when annotation has invalid timestamp", func() { + By("Setting the deployment end time annotation with an invalid timestamp") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentEndTime: "invalid-timestamp-format", + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Consistently(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.DeploymentEndTime.IsZero() + }, "2s", "100ms").Should(BeTrue()) + }) + }) + + Context("AnnotationKeyReleaseActivate", func() { + It("should set status.conditions.active to true when annotation is added with value 'true'", func() { + By("Setting the activate annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseActivate: v1alpha1.AnnotationValueReleaseActivateTrue, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.IsConditionActiveTrue() + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should set status.conditions.active to false when annotation is removed", func() { + By("Setting the annotation to activate") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseActivate: v1alpha1.AnnotationValueReleaseActivateTrue, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.IsConditionActiveTrue() + }, "5s", "100ms").Should(BeTrue()) + + By("Removing the annotation") + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, fetchedRelease)).To(Succeed()) + delete(fetchedRelease.Annotations, v1alpha1.AnnotationKeyReleaseActivate) + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return meta.IsStatusConditionFalse(updatedObj.Status.Conditions, v1alpha1.ReleaseConditionActive) + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should not activate when annotation value is not 'true'", func() { + By("Setting the activate annotation to false") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleaseActivate: "false", + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Consistently(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return meta.IsStatusConditionPresentAndEqual(updatedObj.Status.Conditions, v1alpha1.ReleaseConditionActive, metav1.ConditionUnknown) + }, "2s", "100ms").Should(BeTrue()) + }) + }) + + Context("AnnotationKeyReleasePreviousRelease", func() { + It("should set status.previousRelease.releaseRef when annotation is added", func() { + previousReleaseName := "previous-release-abc123" + + By("Setting the previous release annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleasePreviousRelease: previousReleaseName, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.PreviousRelease.ReleaseRef == previousReleaseName + }, "5s", "100ms").Should(BeTrue()) + }) + + It("should clear status.previousRelease.releaseRef when annotation is removed", func() { + previousReleaseName := "previous-release-xyz789" + + By("Setting the previous release annotation") + fetchedRelease.Annotations = map[string]string{ + v1alpha1.AnnotationKeyReleasePreviousRelease: previousReleaseName, + } + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.PreviousRelease.ReleaseRef == previousReleaseName + }, "5s", "100ms").Should(BeTrue()) + + By("Removing the previous release annotation") + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, fetchedRelease)).To(Succeed()) + delete(fetchedRelease.Annotations, v1alpha1.AnnotationKeyReleasePreviousRelease) + Expect(k8sClient.Update(ctx, fetchedRelease)).To(Succeed()) + + Eventually(func() bool { + updatedObj := &v1alpha1.Release{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: defaultRelease.Name, Namespace: testNamespace}, updatedObj)).To(Succeed()) + return updatedObj.Status.PreviousRelease.ReleaseRef == "" + }, "5s", "100ms").Should(BeTrue()) + }) + }) + }) + + Context("Reconcile", func() { + It("Should initialise status of new releases", func() { + release := createRelease(ctx, testNamespace, "test-target", nil) + + Eventually(func() bool { + fetchedRelease := &v1alpha1.Release{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: release.Name, Namespace: testNamespace}, fetchedRelease) + if err != nil { + return false + } + + return fetchedRelease.IsStatusInitialised() && fetchedRelease.Status.Signature != "" + }).Should(BeTrue()) + }) + }) + + Context("cullReleases", func() { + BeforeEach(func() { + // annotate namespace with max releases per target + namespace := &v1.Namespace{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: testNamespace}, namespace)).To(Succeed()) + metav1.SetMetaDataAnnotation(&namespace.ObjectMeta, v1alpha1.AnnotationKeyReleaseLimit, "5") + Expect(k8sClient.Update(ctx, namespace)).To(Succeed()) + }) + + It("should never delete active releases", func() { + targetName := generateTargetName() + annotations := map[string]string{ + v1alpha1.AnnotationKeyReleaseActivate: v1alpha1.AnnotationValueReleaseActivateTrue, + } + createReleases(ctx, testNamespace, targetName, annotations, 6) + + // The number of releases should be 6 + Eventually(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: targetName}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(6)) + + Consistently(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: targetName}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(6)) + }) + + It("should not delete releases that are not in the target", func() { + otherTarget := generateTargetName() + createReleases(ctx, testNamespace, otherTarget, nil, 3) + + targetName := generateTargetName() + createReleases(ctx, testNamespace, targetName, nil, 6) + + // The number of releases of the first target should be 5 + Eventually(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: targetName}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(5)) + + // The number of releases in the other target should still be 3 + Eventually(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: otherTarget}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(3)) + }) + + It("should not delete releases if there are less than or equal to the max", func() { + targetName := generateTargetName() + createReleases(ctx, testNamespace, targetName, nil, 5) + + Eventually(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: targetName}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(5)) + }) + + It("should delete the oldest release (oldest end time) if there are more than the max", func() { + // Create 6 releases with different end times + targetName := generateTargetName() + releases := createReleases(ctx, testNamespace, targetName, nil, 6) + oldestRelease := releases[0] + + // The number of releases should be 5 + Eventually(func() int { + releases := &v1alpha1.ReleaseList{} + Expect(k8sClient.List(ctx, releases, client.InNamespace(testNamespace), client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: targetName}))).To(Succeed()) + return len(releases.Items) + }).Should(Equal(5)) + + // The oldest release (index 0) should be deleted + Eventually(func() bool { + err := k8sClient.Get(ctx, client.ObjectKey{Name: oldestRelease.Name, Namespace: testNamespace}, &v1alpha1.Release{}) + return apierrors.IsNotFound(err) + }).Should(BeTrue()) + }) + }) +}) + +// Creates and returns a slice of Release objects with the specified count +// Each release has an end time annotation that is 1 hour apart +func createReleases(ctx context.Context, namespace, targetName string, extraAnnotations map[string]string, count int) []*v1alpha1.Release { + ret := make([]*v1alpha1.Release, 0, count) + for i := range count { + annotations := map[string]string{ + v1alpha1.AnnotationKeyReleaseDeploymentEndTime: time.Now().Add(time.Duration(i) * time.Hour).Format(time.RFC3339), + } + maps.Copy(annotations, extraAnnotations) + + release := createRelease(ctx, namespace, targetName, annotations) + ret = append(ret, release) + } + return ret +} + +func generateTargetName() string { + return fmt.Sprintf("test-target-%d-%d", GinkgoParallelProcess(), testCounter.Add(1)) +} + +func generateCommitSHA() string { + bytes := make([]byte, 20) + _, err := rand.Read(bytes) + if err != nil { + panic(err) + } + return hex.EncodeToString(bytes) +} + +func getMetaV1Timestamp(ts string) metav1.Time { + timestamp, err := time.Parse(time.RFC3339, ts) + Expect(err).NotTo(HaveOccurred()) + return metav1.NewTime(timestamp) +} diff --git a/internal/controller/deploy/integration/rollback_test.go b/internal/controller/deploy/integration/rollback_test.go new file mode 100644 index 000000000..7c3c4a665 --- /dev/null +++ b/internal/controller/deploy/integration/rollback_test.go @@ -0,0 +1,244 @@ +package integration + +import ( + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/cicd" +) + +var _ = Describe("RollbackReconciler", func() { + var ( + testNamespace string + release *deployv1alpha1.Release + rollback *deployv1alpha1.Rollback + k8sClient client.Client + ) + + BeforeEach(func() { + testNamespace = setupTestNamespace(ctx) + release = createRelease(ctx, testNamespace, "default-target", nil) + k8sClient = rollbackMgr.GetClient() + }) + + Describe("Basic rollback flow", func() { + It("triggers deployment and sets InProgress condition", func() { + rollback = newRollback(testNamespace, "test-rollback", release.Name, "Testing rollback") + + By("Creating rollback") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying InProgress condition is set") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) + // Check for either InProgress=True or InProgress=False with Completed reason (fast completion) + return cond != nil && (cond.Status == metav1.ConditionTrue || + (cond.Status == metav1.ConditionFalse && cond.Reason == "Completed")) + }).Should(BeTrue()) + }) + + It("completes successfully when deployment succeeds", func() { + // Configure deployer for this rollback + deployer.SetTriggerResult(testNamespace, "success-rollback", TriggerResult{ + Result: &cicd.DeploymentResult{ + ID: "deployment-123", + URL: "https://example.com/deployments/123", + Status: cicd.DeploymentStatusPending, + Message: "Deployment created", + }, + }) + deployer.SetStatusResult("deployment-123", StatusResult{ + Result: &cicd.DeploymentResult{ + ID: "deployment-123", + Status: cicd.DeploymentStatusSucceeded, + Message: "Deployment completed successfully", + }, + }) + + rollback = newRollback(testNamespace, "success-rollback", release.Name, "Testing success") + + By("Creating rollback") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying rollback succeeds") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionSucceded) + return cond != nil && cond.Status == metav1.ConditionTrue + }).Should(BeTrue()) + + rb := &deployv1alpha1.Rollback{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb)).NotTo(HaveOccurred()) + + // Verify deployment ID is set + Expect(rb.Status.DeploymentID).To(Equal("deployment-123")) + + // Verify completion time is set + Expect(rb.Status.CompletionTime).NotTo(BeNil()) + + // Verify InProgress condition is cleared + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal("Completed")) + }) + + It("fails after max retries when deployment keeps failing", func() { + // Use a unique deployment ID for this test to avoid collisions + deploymentID := "deployment-" + testNamespace[:8] + + // Configure deployer for this rollback + deployer.SetTriggerResult(testNamespace, "failing-rollback", TriggerResult{ + Result: &cicd.DeploymentResult{ + ID: deploymentID, + URL: "https://example.com/deployments/" + deploymentID, + Status: cicd.DeploymentStatusPending, + Message: "Deployment created", + }, + }) + deployer.SetStatusResult(deploymentID, StatusResult{ + Result: &cicd.DeploymentResult{ + ID: deploymentID, + Status: cicd.DeploymentStatusFailed, + Message: "Deployment failed: container crashed", + }, + }) + + rollback = newRollback(testNamespace, "failing-rollback", release.Name, "Testing failure") + + By("Creating rollback") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying rollback is terminally failed after max retries") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionSucceded) + inProgressCond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) + // Wait for terminal failure: Succeeded=False, InProgress=False, AttemptCount >= 3 + return cond != nil && cond.Status == metav1.ConditionFalse && + inProgressCond != nil && inProgressCond.Status == metav1.ConditionFalse && + rb.Status.AttemptCount >= 3 && rb.Status.CompletionTime != nil + }, "5s", "100ms").Should(BeTrue()) + }) + + It("fails immediately on non-retryable error", func() { + // Configure deployer to return non-retryable error + deployer.SetTriggerResult(testNamespace, "nonretryable-rollback", TriggerResult{ + Err: &cicd.DeployerError{ + Deployer: "fake", + Operation: "TriggerDeployment", + Retryable: false, + Err: fmt.Errorf("authentication failed"), + }, + }) + + rollback = newRollback(testNamespace, "nonretryable-rollback", release.Name, "Testing non-retryable") + + By("Creating rollback") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying rollback fails immediately") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionSucceded) + inProgressCond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) + // Wait for terminal failure: Succeeded=False, AttemptCount=1, InProgress=False, Message contains error + return cond != nil && cond.Status == metav1.ConditionFalse && rb.Status.AttemptCount == 1 && + inProgressCond != nil && inProgressCond.Status == metav1.ConditionFalse && + rb.Status.CompletionTime != nil && strings.Contains(rb.Status.Message, "authentication failed") + }).Should(BeTrue()) + }) + + It("passes deployment options to deployer", func() { + rollback = &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "options-rollback", + Namespace: testNamespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: release.ReleaseConfig.TargetName, + Name: release.Name, + }, + Reason: "Testing options", + DeploymentOptions: map[string]apiextv1.JSON{ + "skip_canary": {Raw: []byte("true")}, + "timeout": {Raw: []byte("300")}, + }, + }, + } + + By("Creating rollback with options") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying deployment URL contains options as parameters") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + // Options should be encoded in the deployment URL + url := rb.Status.DeploymentURL + return strings.Contains(url, "skip_canary=true") && strings.Contains(url, "timeout=300") + }).Should(BeTrue()) + }) + + It("fails when target release does not exist", func() { + rollback = newRollback(testNamespace, "missing-target", "nonexistent-release", "Testing missing release") + + By("Creating rollback") + Expect(k8sClient.Create(ctx, rollback)).NotTo(HaveOccurred()) + + By("Verifying rollback does not succeed (release not found)") + Eventually(func() bool { + rb := &deployv1alpha1.Rollback{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(rollback), rb); err != nil { + return false + } + cond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionSucceded) + inProgressCond := meta.FindStatusCondition(rb.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) + // Should have failed with release not found + return cond != nil && cond.Status == metav1.ConditionFalse && + inProgressCond != nil && inProgressCond.Status == metav1.ConditionFalse && + rb.Status.CompletionTime != nil && strings.Contains(rb.Status.Message, "not found") + }, "1s", "100ms").Should(BeTrue()) + }) + }) +}) + +func newRollback(namespace, name, toRelease, reason string) *deployv1alpha1.Rollback { + return &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "default-target", + Name: toRelease, + }, + Reason: reason, + }, + } +} diff --git a/internal/controller/deploy/integration/suite_test.go b/internal/controller/deploy/integration/suite_test.go new file mode 100644 index 000000000..9c8468051 --- /dev/null +++ b/internal/controller/deploy/integration/suite_test.go @@ -0,0 +1,256 @@ +package integration + +import ( + "context" + "fmt" + "net/url" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/internal/controller/deploy" + "github.com/gocardless/theatre/v5/pkg/cicd" +) + +var ( + testEnv *envtest.Environment + deployer *FakeDeployer + rollbackMgr ctrl.Manager + releaseMgr ctrl.Manager + ctx context.Context + cancel context.CancelFunc + testCounter atomic.Int32 +) + +func TestSuite(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "controllers/deploy/integration") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.Background()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + cfg, err := testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + scheme := runtime.NewScheme() + err = clientgoscheme.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + err = deployv1alpha1.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + // Create separate managers for rollback and release controllers + // to simulate production where they run in separate processes + rollbackMgr, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", // Disable metrics to avoid port conflicts + }, + }) + Expect(err).NotTo(HaveOccurred()) + + releaseMgr, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", // Disable metrics to avoid port conflicts + }, + }) + Expect(err).NotTo(HaveOccurred()) + + automatedRollbackMgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", // Disable metrics to avoid port conflicts + }, + }) + Expect(err).NotTo(HaveOccurred()) + + deployer = NewFakeDeployer() + + err = (&deploy.RollbackReconciler{ + Client: rollbackMgr.GetClient(), + Scheme: rollbackMgr.GetScheme(), + Log: ctrl.Log.WithName("controllers").WithName("Rollback"), + Deployer: deployer, + }).SetupWithManager(ctx, rollbackMgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&deploy.ReleaseReconciler{ + Client: releaseMgr.GetClient(), + Scheme: releaseMgr.GetScheme(), + Log: ctrl.Log.WithName("controllers").WithName("Release"), + }).SetupWithManager(ctx, releaseMgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&deploy.AutomatedRollbackReconciler{ + Client: automatedRollbackMgr.GetClient(), + Scheme: automatedRollbackMgr.GetScheme(), + Log: ctrl.Log.WithName("controllers").WithName("AutomatedRollback"), + }).SetupWithManager(ctx, automatedRollbackMgr) + Expect(err).NotTo(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err := rollbackMgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + go func() { + defer GinkgoRecover() + err := releaseMgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + go func() { + defer GinkgoRecover() + err := automatedRollbackMgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + gexec.CleanupBuildArtifacts() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// TriggerResult holds the result for a TriggerDeployment call +type TriggerResult struct { + Result *cicd.DeploymentResult + Err error +} + +// StatusResult holds the result for a GetDeploymentStatus call +type StatusResult struct { + Result *cicd.DeploymentResult + Err error +} + +// FakeDeployer is a thread-safe fake implementation of the cicd.Deployer interface +type FakeDeployer struct { + TriggerResults sync.Map // map[string]TriggerResult keyed by "namespace/name" + StatusResults sync.Map // map[string]StatusResult keyed by deploymentID +} + +func NewFakeDeployer() *FakeDeployer { + return &FakeDeployer{} +} + +func (f *FakeDeployer) TriggerDeployment(ctx context.Context, req cicd.DeploymentRequest) (*cicd.DeploymentResult, error) { + key := req.Rollback.Namespace + "/" + req.Rollback.Name + if val, ok := f.TriggerResults.Load(key); ok { + result := val.(TriggerResult) + return result.Result, result.Err + } + + // Default: return a pending deployment with options encoded in URL + deploymentURL := "https://example.com/deployments/" + req.Rollback.Name + if len(req.Options) > 0 { + params := url.Values{} + for k, v := range req.Options { + params.Set(k, fmt.Sprint(v)) + } + deploymentURL += "?" + params.Encode() + } + return &cicd.DeploymentResult{ + ID: "default-deployment-" + req.Rollback.Name, + URL: deploymentURL, + Status: cicd.DeploymentStatusPending, + Message: "Deployment created", + }, nil +} + +func (f *FakeDeployer) GetDeploymentStatus(ctx context.Context, deploymentID string) (*cicd.DeploymentResult, error) { + if val, ok := f.StatusResults.Load(deploymentID); ok { + result := val.(StatusResult) + return result.Result, result.Err + } + + // Default: return success + return &cicd.DeploymentResult{ + ID: deploymentID, + Status: cicd.DeploymentStatusSucceeded, + Message: "Deployment succeeded", + }, nil +} + +func (f *FakeDeployer) Name() string { + return "fake" +} + +func (f *FakeDeployer) SetTriggerResult(namespace, name string, result TriggerResult) { + f.TriggerResults.Store(namespace+"/"+name, result) +} + +func (f *FakeDeployer) SetStatusResult(deploymentID string, result StatusResult) { + f.StatusResults.Store(deploymentID, result) +} + +func generateNamespaceName() string { + return fmt.Sprintf("test-ns-%d-%d", GinkgoParallelProcess(), testCounter.Add(1)) +} + +func setupTestNamespace(ctx context.Context) string { + ns := generateNamespaceName() + err := rollbackMgr.GetClient().Create(ctx, &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: ns, + }, + }) + Expect(err).NotTo(HaveOccurred()) + return ns +} + +func generateRelease(namespace string, target string) *v1alpha1.Release { + appSHA := generateCommitSHA() + infraSHA := generateCommitSHA() + return &v1alpha1.Release{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: target + "-", + Namespace: namespace, + }, + ReleaseConfig: v1alpha1.ReleaseConfig{ + TargetName: target, + Revisions: []v1alpha1.Revision{ + {Name: "application-revision", ID: appSHA}, + {Name: "infrastructure-revision", ID: infraSHA}, + }, + }, + } +} + +func createRelease(ctx context.Context, namespace string, target string, annotations map[string]string) *v1alpha1.Release { + release := generateRelease(namespace, target) + release.Annotations = annotations + err := rollbackMgr.GetClient().Create(ctx, release) + Expect(err).NotTo(HaveOccurred()) + return release +} diff --git a/internal/controller/deploy/messages.go b/internal/controller/deploy/messages.go new file mode 100644 index 000000000..16ff91dd9 --- /dev/null +++ b/internal/controller/deploy/messages.go @@ -0,0 +1,7 @@ +package deploy + +const ( + MessageReleaseCreated = "Release has been just created, waiting for status update" + MessageReleaseActive = "Release is active" + MessageReleaseInactive = "Release is inactive" +) diff --git a/internal/controller/deploy/metrics.go b/internal/controller/deploy/metrics.go new file mode 100644 index 000000000..4e50eeb6e --- /dev/null +++ b/internal/controller/deploy/metrics.go @@ -0,0 +1,76 @@ +package deploy + +import ( + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + // Common label keys that may appear on rollbacks (copied from releases) + // These are defined upfront as Prometheus requires label keys at registration time + rollbackLabelKeys = []string{ + "status", + "cluster", + "service", + "environment", + "namespace", + "release", + "team", + "initiatedBy", + "severity", + "escalation_path", + } + + rollbackTerminalTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "theatre_rollback_terminal_total", + Help: "Count of rollbacks that reached a terminal state (succeeded or failed)", + }, + rollbackLabelKeys, + ) + + rollbackCompletionDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "theatre_rollback_completion_duration_seconds", + Help: "Time from rollback creation to rollback completion in seconds", + Buckets: prometheus.DefBuckets, + }, + rollbackLabelKeys, + ) + + rollbackRetryCount = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "theatre_rollback_retry_count", + Help: "Number of retries performed by rollbacks before reaching terminal state", + Buckets: []float64{0, 1, 2, 3, 4, 5}, + }, + rollbackLabelKeys, + ) +) + +// buildRollbackLabels creates a prometheus.Labels map from the rollback's labels +// plus the required namespace and status labels. All label keys from rollbackLabelKeys +// must be present to satisfy Prometheus cardinality requirements. +func buildRollbackLabels(rollback *deployv1alpha1.Rollback, status string) prometheus.Labels { + labels := prometheus.Labels{ + "status": status, + "namespace": rollback.Namespace, + "initiatedBy": rollback.Spec.InitiatedBy.Principal, + } + + // Initialize all label keys with empty strings to ensure consistent cardinality + for _, key := range rollbackLabelKeys { + if _, exists := labels[key]; !exists { + labels[key] = "" + } + } + + // Override with actual rollback labels if present + for k, v := range rollback.Labels { + if _, exists := labels[k]; exists { + labels[k] = v + } + } + + return labels +} diff --git a/internal/controller/deploy/release_analysis.go b/internal/controller/deploy/release_analysis.go new file mode 100644 index 000000000..f4ec1791b --- /dev/null +++ b/internal/controller/deploy/release_analysis.go @@ -0,0 +1,553 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + analysisv1alpha1 "github.com/akuity/kargo/api/stubs/rollouts/v1alpha1" + "github.com/go-logr/logr" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/deploy" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + // AnalysisPreDeploymentOffset is subtracted from DeploymentStartTime to ensure + // analysis queries capture metrics from slightly before the deployment began, + // accounting for clock drift between systems. + AnalysisPreDeploymentOffset = 5 * time.Second + + AnalysisArgNameBeforeDeploymentTimestamp = "pre-release-timestamp" + AnalysisArgLabelPrefix = "label_" +) + +// AnalysisReconcileJoinedError is an error that wraps multiple errors and can +// be fatal or non-fatal +type AnalysisReconcileJoinedError struct { + message string + fatal bool + inner []error +} + +func (e AnalysisReconcileJoinedError) Error() string { + return fmt.Sprintf("%s: %v", e.message, e.inner) +} + +func (e AnalysisReconcileJoinedError) Unwrap() []error { + return e.inner +} + +// newAnalysisReconcileJoinedError returns a new AnalysisReconcileJoinedError +// with the specified message and inner errors. If any of the inner errors +// are of the same type and fatal, the returned error is also fatal. Otherwise, +// the value provided in `fatal` is used. +func newAnalysisReconcileJoinedError(message string, fatal bool, innerErr ...error) *AnalysisReconcileJoinedError { + ret := AnalysisReconcileJoinedError{ + message: message, + fatal: fatal, + inner: innerErr, + } + if fatal { + return &ret + } + + // if any of contained errors are fatal, consider the whole error fatal + for _, e := range innerErr { + var errToTest *AnalysisReconcileJoinedError + if errors.As(e, &errToTest) && errToTest.fatal { + ret.fatal = errToTest.fatal + return &ret + } + } + return &ret +} + +func (r *ReleaseReconciler) ReconcileAnalysis(ctx context.Context, logger logr.Logger, req ctrl.Request, release *deployv1alpha1.Release) error { + if !r.AnalysisEnabled { + logger.Info("analysis is disabled, skipping") + return nil + } + + releaseActive := meta.IsStatusConditionTrue(release.Status.Conditions, deployv1alpha1.ReleaseConditionActive) + + analysisResultKnown := release.IsAnalysisStatusKnown() + + // collect non-fatal errors to schedule next reconciliation + var collectedErr []error + + if !releaseActive && analysisResultKnown { + // if release is inactive and health/rollback status is already known, there + // is nothing left to do and we can return immediately + logger.Info("release is inactive with known analysis status, skipping") + return nil + } + + var childAnalysisRuns analysisv1alpha1.AnalysisRunList + + // List owned AnalysisRuns already existing + err := r.List(ctx, &childAnalysisRuns, client.InNamespace(req.Namespace), client.MatchingFields{IndexFieldOwner: release.Name}) + if err != nil { + logger.Error(err, "failed to list owned AnalysisRuns") + return err + } + + healthAnalysisRuns, rollbackAnalysisRuns := splitHealthRollback(childAnalysisRuns) + + healthResults := parseAnalysisResults(healthAnalysisRuns) + rollbackResults := parseAnalysisResults(rollbackAnalysisRuns) + + analysisToCreate := []*analysisv1alpha1.AnalysisRun{} + + // We will create missing AnalysisRuns if the release is active. If it is + // inactive, we will only finish parsing the result of existing AnalysisRuns. + if !releaseActive { + logger.Info("inactive release, skipping creation of new AnalysisRuns") + } else { + analysisToCreate, err = r.generateAnalysisRuns(ctx, logger, req, release, childAnalysisRuns.Items) + if err != nil { + var analysisErr AnalysisReconcileJoinedError + if errors.As(err, &analysisErr) && analysisErr.fatal { + logger.Error(err, "fatal error while attempting to determine AnalysisRuns to create, stopping") + return analysisErr + } + logger.Error(err, "error while attempting to determine AnalysisRuns to create, continuing") + collectedErr = append(collectedErr, err) + } + } + + if len(analysisToCreate) > 0 { + logger.Info("found missing AnalysisRuns, creating") + + for _, v := range analysisToCreate { + logger.Info("creating new AnalysisRun", "name", v.Name) + err := r.Create(ctx, v) + if err != nil { + logger.Error(err, "failed to create AnalysisRun", "name", v.Name) + collectedErr = append(collectedErr, err) + continue + } + + // We just created this, so it is counted as pending. + if metav1.HasLabel(v.ObjectMeta, "health") && v.Labels["health"] == "true" { + healthResults[analysisv1alpha1.AnalysisPhasePending] = append(healthResults[analysisv1alpha1.AnalysisPhasePending], v.Name) + } + if metav1.HasLabel(v.ObjectMeta, "rollback") && v.Labels["rollback"] == "true" { + rollbackResults[analysisv1alpha1.AnalysisPhasePending] = append(rollbackResults[analysisv1alpha1.AnalysisPhasePending], v.Name) + } + } + } + + healthCondition := healthConditionGen.conditionFromResults(healthResults) + rollbackCondition := rollbackConditionGen.conditionFromResults(rollbackResults) + + meta.SetStatusCondition(&release.Status.Conditions, healthCondition) + meta.SetStatusCondition(&release.Status.Conditions, rollbackCondition) + + // return non-fatal errors to schedule another reconciliation + if len(collectedErr) > 0 { + return newAnalysisReconcileJoinedError("errors encountered during analysis reconciliation", false, collectedErr...) + } + return nil +} + +// generateAnalysisRuns returns the AnalysisRuns that should be created for the +// given release. This is determined by collecting all AnalysisTemplates and +// ClusterAnalysisTemplates that match the release's selectors. +// Any generated AnalysisRuns that already exist in `existingRuns` are skipped. +func (r *ReleaseReconciler) generateAnalysisRuns( + ctx context.Context, + logger logr.Logger, + req ctrl.Request, + release *deployv1alpha1.Release, + existingRuns []analysisv1alpha1.AnalysisRun, +) ([]*analysisv1alpha1.AnalysisRun, error) { + var ret []*analysisv1alpha1.AnalysisRun + + namespacedSelectors, clusterSelectors := generateSelectors(release, logger) + allAnalysisTemplateLists := []runtime.Object{} + + // collect non-fatal errors, returned at the end of function + var collectedErr []error + + for _, v := range namespacedSelectors { + var templateList analysisv1alpha1.AnalysisTemplateList + err := r.List(ctx, &templateList, client.InNamespace(req.Namespace), client.MatchingLabelsSelector{Selector: v}) + if err != nil { + logger.Error(err, "failed to list AnalysisTemplates", "selector", v.String()) + collectedErr = append( + collectedErr, + fmt.Errorf("failed to list AnalysisTemplates with selector '%s': %w", v.String(), err), + ) + continue + } + + allAnalysisTemplateLists = append(allAnalysisTemplateLists, &templateList) + } + + for _, v := range clusterSelectors { + var templateList analysisv1alpha1.ClusterAnalysisTemplateList + err := r.List(ctx, &templateList, client.MatchingLabelsSelector{Selector: v}) + if err != nil { + logger.Error(err, "failed to list ClusterAnalysisTemplates", "selector", v.String()) + collectedErr = append( + collectedErr, + fmt.Errorf("failed to list ClusterAnalysisTemplates with selector '%s': %w", v.String(), err), + ) + continue + } + + allAnalysisTemplateLists = append(allAnalysisTemplateLists, &templateList) + } + + // collect all input templates in a generic list, so we can pass it all to a single function + // NOTE: we use runtime.Object here to encompass both AnalysisTemplate and + // ClusterAnalysisTemplate, but will convert to the correct type in analysisCreate + allTemplates, err := concatTemplateLists(allAnalysisTemplateLists) + if err != nil { + logger.Error(err, "failed to collect all templates for analysis generation") + return nil, newAnalysisReconcileJoinedError( + "failed to collect all templates for analysis generation", + true, + append(collectedErr, err)..., + ) + } + + for _, v := range allTemplates { + analysis, err := createAnalysisRun(release, v) + if err != nil { + logger.Error(err, "failed to create AnalysisRun") + collectedErr = append( + collectedErr, + fmt.Errorf("failed to create AnalysisRun: %w", err), + ) + continue + } + controllerutil.SetControllerReference(release, analysis, r.Scheme) + + if !slices.ContainsFunc(existingRuns, func(run analysisv1alpha1.AnalysisRun) bool { return run.Name == analysis.Name }) { + ret = append(ret, analysis) + } + } + + if len(collectedErr) > 0 { + return ret, newAnalysisReconcileJoinedError("errors while generating AnalysisRuns", false, collectedErr...) + } + return ret, nil +} + +// splitHealthRollback splits AnalysisRuns into separate lists of AnalysisRuns +// contributing to health or rollback status, based on their labels. The same +// resource can be included in both lists. +func splitHealthRollback(analysisList analysisv1alpha1.AnalysisRunList) ([]analysisv1alpha1.AnalysisRun, []analysisv1alpha1.AnalysisRun) { + + var health, rollback []analysisv1alpha1.AnalysisRun + + for _, v := range analysisList.Items { + if metav1.HasLabel(v.ObjectMeta, "health") && v.Labels["health"] == "true" { + health = append(health, v) + } + if metav1.HasLabel(v.ObjectMeta, "rollback") && v.Labels["rollback"] == "true" { + rollback = append(rollback, v) + } + } + return health, rollback +} + +// parseAnalysisResults takes an AnalysisRunList and returns a map of +// AnalysisPhase to a list of AnalysisRun names in each phase. +// This makes it easier to count how many AnalysisRuns are in each phase. +func parseAnalysisResults(analysisList []analysisv1alpha1.AnalysisRun) map[analysisv1alpha1.AnalysisPhase][]string { + out := map[analysisv1alpha1.AnalysisPhase][]string{ + analysisv1alpha1.AnalysisPhasePending: {}, + analysisv1alpha1.AnalysisPhaseRunning: {}, + analysisv1alpha1.AnalysisPhaseSuccessful: {}, + analysisv1alpha1.AnalysisPhaseFailed: {}, + analysisv1alpha1.AnalysisPhaseError: {}, + analysisv1alpha1.AnalysisPhaseInconclusive: {}, + } + + for _, v := range analysisList { + out[v.Status.Phase] = append(out[v.Status.Phase], v.Name) + } + + return out +} + +// concatTemplateLists takes a list of objects of type AnalysisTemplateList or +// ClusterAnalysisTemplateList and returns a list of runtime.Object containing +// all items from the lists +func concatTemplateLists(list []runtime.Object) ([]runtime.Object, error) { + + // count elements in each list and then assign by index - measured ~10% + // faster than just append + total := 0 + counter := 0 + for _, v := range list { + switch typedList := v.(type) { + case *analysisv1alpha1.AnalysisTemplateList: + total += len(typedList.Items) + case *analysisv1alpha1.ClusterAnalysisTemplateList: + total += len(typedList.Items) + default: + return nil, errors.New("object is not an AnalysisTemplateList or ClusterAnalysisTemplateList") + } + } + + ret := make([]runtime.Object, total) + + for _, v := range list { + switch typedList := v.(type) { + case *analysisv1alpha1.AnalysisTemplateList: + for _, v := range typedList.Items { + ret[counter] = &v + counter++ + } + case *analysisv1alpha1.ClusterAnalysisTemplateList: + for _, v := range typedList.Items { + ret[counter] = &v + counter++ + } + } + // default condition not needed - type assertion already done during counting + } + return ret, nil +} + +// createAnalysisRun generates an AnalysisRun for the given release, from the +// provided template. Template must be an AnalysisTemplate or ClusterAnalysisTemplate +func createAnalysisRun(release *deployv1alpha1.Release, template runtime.Object) (*analysisv1alpha1.AnalysisRun, error) { + var ( + templateName string + templateSpec analysisv1alpha1.AnalysisTemplateSpec + templateLabels map[string]string + ) + + switch t := template.(type) { + case *analysisv1alpha1.AnalysisTemplate: + templateName = t.Name + templateSpec = *t.Spec.DeepCopy() + templateLabels = t.GetLabels() + case *analysisv1alpha1.ClusterAnalysisTemplate: + templateName = t.Name + templateSpec = *t.Spec.DeepCopy() + templateLabels = t.GetLabels() + default: + return nil, errors.New("object is not an AnalysisTemplate or ClusterAnalysisTemplate") + } + + var args []analysisv1alpha1.Argument + + for _, v := range templateSpec.Args { + ret := v + + // special value to insert timestamp evaluation + if ret.Name == AnalysisArgNameBeforeDeploymentTimestamp { + unix := release.Status.DeploymentStartTime.Time.Add(-AnalysisPreDeploymentOffset).Unix() + unixStr := strconv.FormatInt(unix, 10) + ret.Value = &unixStr + ret.ValueFrom = nil + } else if labelName, found := strings.CutPrefix(ret.Name, AnalysisArgLabelPrefix); found { + // we replace prefixed args with release labels + if metav1.HasLabel(release.ObjectMeta, labelName) { + labelStr := release.Labels[labelName] + ret.Value = &labelStr + } + } + + if ret.Value == nil && ret.ValueFrom == nil { + return nil, fmt.Errorf("could not determine value for arg %s and no default value set", ret.Name) + } + + args = append(args, ret) + } + + finalLabels := maps.Clone(release.GetLabels()) + if healthLabel, found := templateLabels["health"]; found { + finalLabels["health"] = healthLabel + } + if rollbackLabel, found := templateLabels["rollback"]; found { + finalLabels["rollback"] = rollbackLabel + } + + run := &analysisv1alpha1.AnalysisRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploy.GenerateAnalysisRunName(release.Name, templateName), + Namespace: release.Namespace, + Labels: finalLabels, + }, + Spec: analysisv1alpha1.AnalysisRunSpec{ + Args: args, + Metrics: templateSpec.Metrics, + }, + } + + return run, nil +} + +// generateSelectors generates analysis template selectors for the given release. +// Returns two slices of selectors, based on their target resource type: +// - for (namespaced) AnalysisTemplate resources +// - for ClusterAnalysisTemplate resources +// +// Returns selectors: +// - release labels (namespaced) +// - "global: true" label (cluster) - can be disabled with Release annotation +// - custom selector (namespaced and cluster) - parsed from Release annotation +func generateSelectors(release *deployv1alpha1.Release, logger logr.Logger) ([]labels.Selector, []labels.Selector) { + useGlobal := true + + var customTemplateSelector labels.Selector + if metav1.HasAnnotation(release.ObjectMeta, deployv1alpha1.AnnotationKeyReleaseAnalysisTemplateSelector) { + templateSelectorStr := release.GetAnnotations()[deployv1alpha1.AnnotationKeyReleaseAnalysisTemplateSelector] + parsedTemplateSelector, err := labels.Parse(templateSelectorStr) + if err != nil { + logger.Error(err, "failed to parse custom template selector, proceeding without", "selector", templateSelectorStr) + customTemplateSelector = nil + } else { + customTemplateSelector = parsedTemplateSelector + } + } + + if metav1.HasAnnotation(release.ObjectMeta, deployv1alpha1.AnnotationKeyReleaseNoGlobalAnalysis) && release.GetAnnotations()[deployv1alpha1.AnnotationKeyReleaseNoGlobalAnalysis] == "true" { + useGlobal = false + } + + releaseLabelsSelector := labels.SelectorFromSet(release.GetLabels()) + globalSelector := labels.SelectorFromValidatedSet(labels.Set{"global": "true"}) + + namespacedSelectors := []labels.Selector{releaseLabelsSelector} + var clusterSelectors []labels.Selector + + if customTemplateSelector != nil { + namespacedSelectors = append(namespacedSelectors, customTemplateSelector) + clusterSelectors = append(clusterSelectors, customTemplateSelector) + } + + if useGlobal { + clusterSelectors = append(clusterSelectors, globalSelector) + } + + return namespacedSelectors, clusterSelectors +} + +// conditionGen is a helper struct to generate metav1.Condition +// we use custom struct to determine health/rollback status, because they have +// opposite status based on analysis result +type conditionGen struct { + conditionType string + conditionStatusBad metav1.ConditionStatus + conditionStatusGood metav1.ConditionStatus +} + +var healthConditionGen = conditionGen{ + conditionType: deployv1alpha1.ReleaseConditionHealthy, + conditionStatusGood: metav1.ConditionTrue, + conditionStatusBad: metav1.ConditionFalse, +} + +var rollbackConditionGen = conditionGen{ + conditionType: deployv1alpha1.ReleaseConditionRollbackRequired, + conditionStatusGood: metav1.ConditionFalse, + conditionStatusBad: metav1.ConditionTrue, +} + +// conditionFromResults takes a map of analysis results returned by +// parseAnalysisResults, and returns a metav1.Condition for the Release object. +// Condition reason is determined by priority list: +// 1. Any result failed: Healthy==False, otherwise +// 2. Any result [error|inconclusive|pending|running]: Healthy==Unknown, otherwise +// 3. Healthy==True (all results finished and successful) +func (c conditionGen) conditionFromResults(results map[analysisv1alpha1.AnalysisPhase][]string) metav1.Condition { + ret := metav1.Condition{ + Type: c.conditionType, + } + + numTotal := 0 + + for _, v := range results { + numTotal += len(v) + } + + if numTotal == 0 { + ret.Status = metav1.ConditionUnknown + ret.Reason = deployv1alpha1.ReasonAnalysisMissing + ret.Message = "No AnalysisRun(s) found" + return ret + } + + if len(results[analysisv1alpha1.AnalysisPhaseFailed]) > 0 { + ret.Status = c.conditionStatusBad + ret.Reason = deployv1alpha1.ReasonAnalysisFailed + if len(results[analysisv1alpha1.AnalysisPhaseFailed]) == 1 { + ret.Message = fmt.Sprintf("AnalysisRun \"%s\" failed", results[analysisv1alpha1.AnalysisPhaseFailed][0]) + } else { + ret.Message = fmt.Sprintf("%d out of %d AnalysisRun(s) failed", len(results[analysisv1alpha1.AnalysisPhaseFailed]), numTotal) + } + return ret + } + + numPendingOrRunning := len(results[analysisv1alpha1.AnalysisPhasePending]) + len(results[analysisv1alpha1.AnalysisPhaseRunning]) + numErrored := len(results[analysisv1alpha1.AnalysisPhaseError]) + numInconclusive := len(results[analysisv1alpha1.AnalysisPhaseInconclusive]) + numUnknowns := numPendingOrRunning + numErrored + numInconclusive + + if numUnknowns > 0 { + ret.Status = metav1.ConditionUnknown + + if numErrored > 0 { + ret.Reason = deployv1alpha1.ReasonAnalysisError + if numErrored == 1 { + ret.Message = fmt.Sprintf("AnalysisRun \"%s\" errored", results[analysisv1alpha1.AnalysisPhaseError][0]) + } else { + ret.Message = fmt.Sprintf("%d out of %d AnalysisRuns errored", numErrored, numTotal) + } + return ret + } + + if numInconclusive > 0 { + ret.Reason = deployv1alpha1.ReasonAnalysisError + if numInconclusive == 1 { + ret.Message = fmt.Sprintf("AnalysisRun \"%s\" result is inconclusive", results[analysisv1alpha1.AnalysisPhaseInconclusive][0]) + } else { + ret.Message = fmt.Sprintf("%d out of %d AnalysisRuns have inconclusive results", numInconclusive, numTotal) + } + return ret + } + + if numPendingOrRunning > 0 { + ret.Reason = deployv1alpha1.ReasonAnalysisInProgress + if numPendingOrRunning == 1 { + var pendingName string + if len(results[analysisv1alpha1.AnalysisPhasePending]) > 0 { + pendingName = results[analysisv1alpha1.AnalysisPhasePending][0] + } else { + pendingName = results[analysisv1alpha1.AnalysisPhaseRunning][0] + } + ret.Message = fmt.Sprintf("Awaiting results from AnalysisRun \"%s\"", pendingName) + } else { + ret.Message = fmt.Sprintf("Awaiting results from %d out of %d AnalysisRuns", numPendingOrRunning, numTotal) + } + return ret + } + } + + // all other options skipped, we can infer that release is healthy + ret.Status = c.conditionStatusGood + ret.Reason = deployv1alpha1.ReasonAnalysisSucceeded + ret.Message = fmt.Sprintf("All %d AnalysisRuns succeeded", len(results[analysisv1alpha1.AnalysisPhaseSuccessful])) + return ret +} diff --git a/internal/controller/deploy/release_analysis_test.go b/internal/controller/deploy/release_analysis_test.go new file mode 100644 index 000000000..194b0adb5 --- /dev/null +++ b/internal/controller/deploy/release_analysis_test.go @@ -0,0 +1,708 @@ +package deploy + +import ( + "errors" + "fmt" + "slices" + "strconv" + + analysisv1alpha1 "github.com/akuity/kargo/api/stubs/rollouts/v1alpha1" + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" +) + +const ( + DefaultNamespace = "default" +) + +var _ = Describe("ReleaseAnalysis", func() { + var ( + obj v1alpha1.Release + ) + + BeforeEach(func() { + obj = v1alpha1.Release{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-release", + Namespace: DefaultNamespace, + Annotations: map[string]string{}, + Labels: map[string]string{ + "service": "test-service", + }, + }, + ReleaseConfig: v1alpha1.ReleaseConfig{ + TargetName: "test-target", + Revisions: []v1alpha1.Revision{ + {Name: "application-revision", ID: "test-app-revision"}, + {Name: "infrastructure-revision", ID: "test-infra-revision"}, + }, + }, + } + }) + + Describe("generateSelectors", func() { + var ( + expectedSelectors map[string]labels.Selector + + returnedClusterSelectors []labels.Selector + returnedNamespacedSelectors []labels.Selector + ) + + selectorKeyGlobal := "global" + selectorKeyRelease := "releaseLabels" + selectorKeyCustom := "custom" + + BeforeEach(func() { + expectedSelectors = map[string]labels.Selector{ + selectorKeyRelease: labels.SelectorFromSet(obj.GetLabels()), + selectorKeyGlobal: labels.SelectorFromValidatedSet(labels.Set{"global": "true"}), + } + }) + + JustBeforeEach(func() { + returnedNamespacedSelectors, returnedClusterSelectors = generateSelectors(&obj, logr.Discard()) + }) + + AssertGlobalSelectorPresent := func() { + It("generates a global selector for cluster resource, but not namespaced resource", func() { + Expect(returnedClusterSelectors).To(ContainElement(expectedSelectors[selectorKeyGlobal])) + Expect(returnedNamespacedSelectors).ToNot(ContainElement(expectedSelectors[selectorKeyGlobal])) + }) + } + + AssertGlobalSelectorAbsent := func() { + It("does not generate a global selector for any resource", func() { + Expect(returnedClusterSelectors).ToNot(ContainElement(expectedSelectors[selectorKeyGlobal])) + Expect(returnedNamespacedSelectors).ToNot(ContainElement(expectedSelectors[selectorKeyGlobal])) + }) + } + + AssertSelector := func(key string) { + It(fmt.Sprintf("generates %s selector for namespaced and cluster resources", key), func() { + Expect(returnedClusterSelectors).To(ContainElement(expectedSelectors[key])) + Expect(returnedNamespacedSelectors).To(ContainElement(expectedSelectors[key])) + }) + } + + AssertSelectorNamespacedOnly := func(key string) { + It(fmt.Sprintf("generates %s selector for namespaced resource only", key), func() { + Expect(returnedClusterSelectors).ToNot(ContainElement(expectedSelectors[key])) + Expect(returnedNamespacedSelectors).To(ContainElement(expectedSelectors[key])) + }) + } + + AssertGlobalSelectorPresent() + AssertSelectorNamespacedOnly(selectorKeyRelease) + + When("global templates are disabled", func() { + BeforeEach(func() { + obj.Annotations[v1alpha1.AnnotationKeyReleaseNoGlobalAnalysis] = "true" + }) + + AssertGlobalSelectorAbsent() + AssertSelectorNamespacedOnly(selectorKeyRelease) + }) + + When("valid custom selectors are set", func() { + selectorStr := "testlabel in (foo, bar), testequiv == baz" + var ( + selectorParsed labels.Selector + err error + ) + + BeforeEach(func() { + obj.Annotations[v1alpha1.AnnotationKeyReleaseAnalysisTemplateSelector] = selectorStr + selectorParsed, err = labels.Parse(selectorStr) + Expect(err).NotTo(HaveOccurred()) + expectedSelectors[selectorKeyCustom] = selectorParsed + }) + + AssertGlobalSelectorPresent() + AssertSelector(selectorKeyCustom) + AssertSelectorNamespacedOnly(selectorKeyRelease) + + When("global templates are disabled", func() { + BeforeEach(func() { + obj.Annotations[v1alpha1.AnnotationKeyReleaseNoGlobalAnalysis] = "true" + }) + + AssertGlobalSelectorAbsent() + AssertSelector(selectorKeyCustom) + AssertSelectorNamespacedOnly(selectorKeyRelease) + }) + }) + + When("invalid custom selector is defined in annotation", func() { + selectorStr := "in in in in in in" + + BeforeEach(func() { + obj.Annotations[v1alpha1.AnnotationKeyReleaseAnalysisTemplateSelector] = selectorStr + }) + + AssertGlobalSelectorPresent() + AssertSelectorNamespacedOnly(selectorKeyRelease) + + It("does not returns custom selectors", func() { + namespacedSelectors, clusterSelectors := generateSelectors(&obj, logr.Discard()) + // global only + Expect(clusterSelectors).To(HaveLen(1)) + // release labels only + Expect(namespacedSelectors).To(HaveLen(1)) + }) + }) + }) + + Describe("splitHealthRollback", func() { + var ( + analysisList analysisv1alpha1.AnalysisRunList + + healthResult, rollbackResult []analysisv1alpha1.AnalysisRun + ) + + BeforeEach(func() { + analysisList = analysisv1alpha1.AnalysisRunList{ + Items: []analysisv1alpha1.AnalysisRun{}, + } + }) + + JustBeforeEach(func() { + healthResult, rollbackResult = splitHealthRollback(analysisList) + }) + + AssertHealthEmpty := func() { + It("returns empty health list", func() { + Expect(healthResult).To(BeEmpty()) + }) + } + + AssertRollbackEmpty := func() { + It("returns empty rollback list", func() { + Expect(rollbackResult).To(BeEmpty()) + }) + } + + AssertRollbackEmpty() + AssertHealthEmpty() + + When("health analysis is in the list", func() { + healthAnalysis := genAnalysisRun("health-1", analysisv1alpha1.AnalysisPhaseSuccessful, true, false) + + AssertHealthReturned := func() { + It("contains health analysis only in health list", func() { + Expect(healthResult).To(ContainElement(healthAnalysis)) + Expect(rollbackResult).ToNot(ContainElement(healthAnalysis)) + }) + } + + BeforeEach(func() { + analysisList.Items = append(analysisList.Items, healthAnalysis) + }) + + AssertHealthReturned() + AssertRollbackEmpty() + + When("rollback analysis is in the list", func() { + rollbackAnalysis := genAnalysisRun("rollback-1", analysisv1alpha1.AnalysisPhaseSuccessful, false, true) + + AssertRollbackReturned := func() { + It("contains rollback analysis only in rollback list", func() { + Expect(healthResult).ToNot(ContainElement(rollbackAnalysis)) + Expect(rollbackResult).To(ContainElement(rollbackAnalysis)) + }) + } + + BeforeEach(func() { + analysisList.Items = append(analysisList.Items, rollbackAnalysis) + }) + + AssertRollbackReturned() + AssertHealthReturned() + + When("shared (health/rollback) analysis is in the list", func() { + sharedAnalysis := genAnalysisRun("shared-1", analysisv1alpha1.AnalysisPhaseSuccessful, true, true) + + BeforeEach(func() { + analysisList.Items = append(analysisList.Items, sharedAnalysis) + }) + + AssertRollbackReturned() + AssertHealthReturned() + + It("contains shared analysis in both lists", func() { + health, rollback := splitHealthRollback(analysisList) + Expect(health).To(ContainElement(sharedAnalysis)) + Expect(rollback).To(ContainElement(sharedAnalysis)) + }) + }) + }) + }) + }) + + Describe("concatTemplateLists", func() { + templateList := analysisv1alpha1.AnalysisTemplateList{} + clusterTemplateList := analysisv1alpha1.ClusterAnalysisTemplateList{} + templateListSecond := analysisv1alpha1.AnalysisTemplateList{} + for i := range 10 { + templateList.Items = append(templateList.Items, genAnalysisTemplate(fmt.Sprintf("template-%d", i))) + templateListSecond.Items = append(templateListSecond.Items, genAnalysisTemplate(fmt.Sprintf("second-template-%d", i))) + clusterTemplateList.Items = append(clusterTemplateList.Items, genClusterAnalysisTemplate(fmt.Sprintf("cluster-template-%d", i))) + } + + var listOfLists []runtime.Object + + BeforeEach(func() { + listOfLists = []runtime.Object{&templateList, &clusterTemplateList, &templateListSecond} + }) + + convertTemplateList := func(t []analysisv1alpha1.AnalysisTemplate) []runtime.Object { + ret := make([]runtime.Object, len(t)) + for i, v := range t { + ret[i] = &v + } + return ret + } + + convertClusterTemplateList := func(t []analysisv1alpha1.ClusterAnalysisTemplate) []runtime.Object { + ret := make([]runtime.Object, len(t)) + for i, v := range t { + ret[i] = &v + } + return ret + } + + It("returns all elements from primary template list", func() { + ret, err := concatTemplateLists(listOfLists) + Expect(err).ToNot(HaveOccurred()) + Expect(ret).To(ContainElements(convertTemplateList(templateList.Items))) + }) + + It("returns all elements from secondary template list", func() { + ret, err := concatTemplateLists(listOfLists) + Expect(err).ToNot(HaveOccurred()) + Expect(ret).To(ContainElements(convertTemplateList(templateListSecond.Items))) + }) + + It("returns all elements from cluster template list", func() { + ret, err := concatTemplateLists(listOfLists) + Expect(err).ToNot(HaveOccurred()) + Expect(ret).To(ContainElements(convertClusterTemplateList(clusterTemplateList.Items))) + }) + + When("list contains invalid object", func() { + BeforeEach(func() { + listOfLists = append(listOfLists, &obj) + }) + It("returns error", func() { + _, err := concatTemplateLists(listOfLists) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("createAnalysisRun", func() { + var ( + analysisTemplate analysisv1alpha1.AnalysisTemplate + clusterAnalysisTemplate analysisv1alpha1.ClusterAnalysisTemplate + template runtime.Object + + selectCluster bool // if true, use clusterAnalysisTemplate, else use analysisTemplate + + returnedAnalysisRun *analysisv1alpha1.AnalysisRun + err error + ) + + BeforeEach(func() { + analysisTemplate = genAnalysisTemplate("namespaced") + clusterAnalysisTemplate = genClusterAnalysisTemplate("clustered") + selectCluster = false + }) + + JustBeforeEach(func() { + if selectCluster { + template = &clusterAnalysisTemplate + } else { + template = &analysisTemplate + } + returnedAnalysisRun, err = createAnalysisRun(&obj, template) + }) + + AssertAnalysisRunReturned := func() { + It("returns an analysis run", func() { + Expect(err).ToNot(HaveOccurred()) + Expect(returnedAnalysisRun).ToNot(BeNil()) + }) + } + + AssertAnalysisReleaseLabelsEqual := func() { + It("returns an analysis run with release labels", func() { + Expect(returnedAnalysisRun.GetLabels()).To(Equal(obj.GetLabels())) + }) + } + + AssertAnalysisRunError := func() { + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + }) + } + + AssertAnalysisRunReturned() + AssertAnalysisReleaseLabelsEqual() + + When("clusterAnalysisTemplate is provided", func() { + BeforeEach(func() { + selectCluster = true + }) + AssertAnalysisRunReturned() + AssertAnalysisReleaseLabelsEqual() + }) + + When("unsupported object is used as template", func() { + It("returns an error", func() { + // can not reuse AssertAnalysisRunError because JustBeforeEach in parent + // only uses template objects + _, invalidTypeErr := createAnalysisRun(&obj, &obj) + Expect(invalidTypeErr).To(HaveOccurred()) + }) + }) + + When("pre-deploy timestamp arg is requested", func() { + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, analysisv1alpha1.Argument{ + Name: AnalysisArgNameBeforeDeploymentTimestamp, + }) + }) + + AssertTimestampArg := func() { + It("sets the pre-deploy timestamp", func() { + Expect(returnedAnalysisRun.Spec.Args).To(ContainElement(And( + HaveField("Name", Equal(AnalysisArgNameBeforeDeploymentTimestamp)), + HaveField("Value", HaveValue(Not(BeEmpty()))), + ))) + }) + } + + AssertAnalysisReleaseLabelsEqual() + AssertAnalysisRunReturned() + AssertTimestampArg() + + When("present release label arg is requested", func() { + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, analysisv1alpha1.Argument{ + Name: AnalysisArgLabelPrefix + "service", + }) + }) + + AssertAnalysisReleaseLabelsEqual() + AssertAnalysisRunReturned() + AssertTimestampArg() + + It("sets the argument to the corret value", func() { + Expect(returnedAnalysisRun.Spec.Args).To(ContainElement(And( + HaveField("Name", Equal(AnalysisArgLabelPrefix+"service")), + HaveField("Value", HaveValue(Equal(obj.GetLabels()["service"]))), + ))) + }) + }) + + When("missing release label arg is requested", func() { + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, analysisv1alpha1.Argument{ + Name: AnalysisArgLabelPrefix + "missing", + }) + }) + AssertAnalysisRunError() + }) + + When("unknown arg is requested", func() { + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, analysisv1alpha1.Argument{ + Name: "unknown", + }) + }) + AssertAnalysisRunError() + }) + + When("unknown arg is requested with default value", func() { + argValue := "default-value" + arg := analysisv1alpha1.Argument{ + Name: "unknown", + Value: &argValue, + } + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, arg) + }) + AssertAnalysisRunReturned() + AssertAnalysisReleaseLabelsEqual() + AssertTimestampArg() + + It("returns the argument with the pre-set value unchanged", func() { + Expect(returnedAnalysisRun.Spec.Args).To(ContainElement(arg)) + }) + }) + + When("unknown arg is requested with valueFrom", func() { + arg := analysisv1alpha1.Argument{ + Name: "unknown", + ValueFrom: &analysisv1alpha1.ValueFrom{ + SecretKeyRef: &analysisv1alpha1.SecretKeyRef{ + Name: "foo", + Key: "bar", + }, + FieldRef: &analysisv1alpha1.FieldRef{ + FieldPath: "baz", + }, + }, + } + + BeforeEach(func() { + analysisTemplate.Spec.Args = append(analysisTemplate.Spec.Args, arg) + }) + + AssertAnalysisRunReturned() + AssertAnalysisReleaseLabelsEqual() + AssertTimestampArg() + + It("returns the argument with valueFrom unchanged", func() { + Expect(returnedAnalysisRun.Spec.Args).To(ContainElement(arg)) + }) + }) + }) + }) + + Describe("AnalysisReconcileJoinedError", func() { + var ( + innerError1 = errors.New("inner error 1") + innerError2 = errors.New("inner error 2") + analysisErrorNonFatal = newAnalysisReconcileJoinedError("non-fatal analysis error", false, nil) + analysisErrorFatal = newAnalysisReconcileJoinedError("fatal analysis error", true, nil) + analysisErrorNestedFatal = newAnalysisReconcileJoinedError("analysis error nested fatal", false, analysisErrorFatal) + + innerErrors []error + testAnalysisError *AnalysisReconcileJoinedError + ) + + BeforeEach(func() { + innerErrors = []error{} + }) + + JustBeforeEach(func() { + // we test non-fatal outer error, for behavior based on inner errors + testAnalysisError = newAnalysisReconcileJoinedError("test analysis error", false, innerErrors...) + }) + + AssertFatal := func() { + It("is fatal", func() { + Expect(testAnalysisError.fatal).To(BeTrue()) + }) + } + + AssertNonFatal := func() { + It("is non-fatal", func() { + Expect(testAnalysisError.fatal).To(BeFalse()) + }) + } + + AssertNonFatal() + + When("contains standard inner errors", func() { + BeforeEach(func() { + innerErrors = append(innerErrors, innerError1, innerError2) + }) + + AssertInnerStdErrPresent := func() { + It("contains standard inner errors", func() { + Expect(errors.Is(testAnalysisError, innerError1)).To(BeTrue()) + Expect(errors.Is(testAnalysisError, innerError2)).To(BeTrue()) + }) + } + + AssertInnerStdErrPresent() + AssertNonFatal() + + When("inner errors contain a fatal analysis error", func() { + BeforeEach(func() { + innerErrors = append(innerErrors, analysisErrorFatal) + }) + + AssertInnerStdErrPresent() + AssertFatal() + }) + + When("inner errors contain a non-fatal analysis error", func() { + BeforeEach(func() { + innerErrors = append(innerErrors, analysisErrorNonFatal) + }) + + AssertInnerStdErrPresent() + AssertNonFatal() + + When("inner errors contain a nested fatal analysis error", func() { + BeforeEach(func() { + innerErrors = append(innerErrors, analysisErrorNestedFatal) + }) + + AssertInnerStdErrPresent() + AssertFatal() + }) + }) + }) + }) + + Describe("result parsing", func() { + const runsPerPhase = 10 + + var ( + // collect input for functions to test + runList []analysisv1alpha1.AnalysisRun + + // which phases will be generated for each run - others will be empty + phasesToGenerate []analysisv1alpha1.AnalysisPhase + + parsedResult map[analysisv1alpha1.AnalysisPhase][]string + ) + + phases := []analysisv1alpha1.AnalysisPhase{ + analysisv1alpha1.AnalysisPhasePending, + analysisv1alpha1.AnalysisPhaseRunning, + analysisv1alpha1.AnalysisPhaseSuccessful, + analysisv1alpha1.AnalysisPhaseFailed, + analysisv1alpha1.AnalysisPhaseError, + analysisv1alpha1.AnalysisPhaseInconclusive, + } + + BeforeEach(func() { + runList = []analysisv1alpha1.AnalysisRun{} + phasesToGenerate = []analysisv1alpha1.AnalysisPhase{} + }) + + JustBeforeEach(func() { + for i := range runsPerPhase { + for _, p := range phasesToGenerate { + runList = append(runList, genAnalysisRun(fmt.Sprintf("%s-%d", p, i), p, false, false)) + } + } + parsedResult = parseAnalysisResults(runList) + }) + + AppendPhaseBeforeEach := func(phase analysisv1alpha1.AnalysisPhase) { + BeforeEach(func() { + phasesToGenerate = append(phasesToGenerate, phase) + }) + } + + AssertParseCountCorrect := func() { + It("parseAnalysisResults returns appropriate count for each phase", func() { + for _, v := range phases { + if slices.Contains(phasesToGenerate, v) { + Expect(parsedResult[v]).To(HaveLen(runsPerPhase)) + } else { + Expect(parsedResult[v]).To(BeEmpty()) + } + } + }) + } + + AssertConditionsUnknown := func() { + It("conditionFromResults returns both conditions as unknown", func() { + Expect(healthConditionGen.conditionFromResults(parsedResult).Status).To(Equal(metav1.ConditionUnknown)) + Expect(rollbackConditionGen.conditionFromResults(parsedResult).Status).To(Equal(metav1.ConditionUnknown)) + }) + } + + AssertCondition := func(generator conditionGen, expectedStatus metav1.ConditionStatus) { + It(fmt.Sprintf("conditionFromResults returns %s condition as %s", generator.conditionType, expectedStatus), func() { + Expect(generator.conditionFromResults(parsedResult).Status).To(Equal(expectedStatus)) + }) + } + + AssertParseCountCorrect() + AssertConditionsUnknown() + + When("successful phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhaseSuccessful) + + AssertParseCountCorrect() + AssertCondition(healthConditionGen, metav1.ConditionTrue) + AssertCondition(rollbackConditionGen, metav1.ConditionFalse) + + When("errored phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhaseError) + + AssertParseCountCorrect() + AssertConditionsUnknown() + }) + + When("inconclusive phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhaseInconclusive) + + AssertParseCountCorrect() + AssertConditionsUnknown() + }) + + When("pending phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhasePending) + + AssertParseCountCorrect() + AssertConditionsUnknown() + }) + + When("running phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhaseRunning) + + AssertParseCountCorrect() + AssertConditionsUnknown() + + When("failed phases are present", func() { + AppendPhaseBeforeEach(analysisv1alpha1.AnalysisPhaseFailed) + + AssertParseCountCorrect() + AssertCondition(healthConditionGen, metav1.ConditionFalse) + AssertCondition(rollbackConditionGen, metav1.ConditionTrue) + + It("correct message is set", func() { + Expect(rollbackConditionGen.conditionFromResults(parsedResult).Message). + To(Equal(fmt.Sprintf("%d out of %d AnalysisRun(s) failed", runsPerPhase, len(runList)))) + }) + }) + }) + + }) + }) +}) + +func genAnalysisRun(name string, phase analysisv1alpha1.AnalysisPhase, health bool, rollback bool) analysisv1alpha1.AnalysisRun { + return analysisv1alpha1.AnalysisRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "health": strconv.FormatBool(health), + "rollback": strconv.FormatBool(rollback), + }, + }, + Status: analysisv1alpha1.AnalysisRunStatus{ + Phase: phase, + }, + } +} + +func genAnalysisTemplate(name string) analysisv1alpha1.AnalysisTemplate { + return analysisv1alpha1.AnalysisTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} + +func genClusterAnalysisTemplate(name string) analysisv1alpha1.ClusterAnalysisTemplate { + return analysisv1alpha1.ClusterAnalysisTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} diff --git a/internal/controller/deploy/release_controller.go b/internal/controller/deploy/release_controller.go new file mode 100644 index 000000000..77317afc2 --- /dev/null +++ b/internal/controller/deploy/release_controller.go @@ -0,0 +1,183 @@ +package deploy + +import ( + "context" + "errors" + "time" + + analysisv1alpha1 "github.com/akuity/kargo/api/stubs/rollouts/v1alpha1" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/logging" + "github.com/gocardless/theatre/v5/pkg/recutil" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // DefaultReleaseLimit is the default number of releases to keep in a namespace + DefaultReleaseLimit = 30 +) + +var apiGVStr = deployv1alpha1.GroupVersion.String() + +type ReleaseReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + AnalysisEnabled bool +} + +func (r *ReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + logger := r.Log.WithValues("component", "Release") + + ctrlBuilder := ctrl.NewControllerManagedBy(mgr).For(&deployv1alpha1.Release{}) + + // Only set AnalysisRun ownership, and owner indexed field, if analysis is enabled + if r.AnalysisEnabled { + ctrlBuilder = ctrlBuilder.Owns(&analysisv1alpha1.AnalysisRun{}) + + err := mgr.GetFieldIndexer().IndexField( + ctx, + &analysisv1alpha1.AnalysisRun{}, + IndexFieldOwner, + func(rawObj client.Object) []string { + run := rawObj.(*analysisv1alpha1.AnalysisRun) + owner := metav1.GetControllerOf(run) + if owner == nil { + return nil + } + if owner.APIVersion != apiGVStr || owner.Kind != "Release" { + return nil + } + return []string{owner.Name} + }, + ) + if err != nil { + return err + } + } + + err := mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Release{}, + IndexFieldReleaseTarget, + func(rawObj client.Object) []string { + release := rawObj.(*deployv1alpha1.Release) + return []string{release.TargetName} + }, + ) + if err != nil { + return err + } + + return ctrlBuilder.Complete( + recutil.ResolveAndReconcile( + ctx, logger, mgr, &deployv1alpha1.Release{}, + func(logger logr.Logger, request ctrl.Request, obj runtime.Object) (ctrl.Result, error) { + return r.Reconcile(ctx, logger, request, obj.(*deployv1alpha1.Release)) + }, + ), + ) +} + +func (r *ReleaseReconciler) Reconcile(ctx context.Context, logger logr.Logger, req ctrl.Request, release *deployv1alpha1.Release) (ctrl.Result, error) { + baseLogger := logger.WithValues("namespace", req.Namespace, "target", release.TargetName) + logger = baseLogger.WithValues("release", release.Name) + + if !release.IsStatusInitialised() { + logger.Info("release is new, will initialise") + release.InitialiseStatus(MessageReleaseCreated) + } + + r.handleAnnotations(logger, release) + analysisErr := r.ReconcileAnalysis(ctx, logger, req, release) + + outcome, err := recutil.CreateOrUpdate(ctx, r.Client, release, recutil.StatusDiff) + if err != nil { + logger.Error(err, "failed to update release status") + return ctrl.Result{}, errors.Join(err, analysisErr) + } + + switch outcome { + case recutil.StatusUpdate: + logger.Info("Updated release status", "event", EventSuccessfulStatusUpdate) + case recutil.None: + logging.WithNoRecord(logger).Info("No status update needed", "event", EventNoStatusUpdate) + default: + logger.Info("Unexpected outcome from CreateOrUpdate", "outcome", outcome) + } + + // When culling, we don't want to log the release that triggered the culling + // as it introduces a confusion. Hence its not included in the logging. + err = r.cullReleases(ctx, baseLogger, req.Namespace, release.TargetName) + if err != nil { + logger.Error(err, "failed to cull releases") + return ctrl.Result{}, errors.Join(err, analysisErr) + } + + return ctrl.Result{}, analysisErr +} + +// The current way to active releases is by setting the deployment end time. The +// release controller will activate the release with the latest deployment end +// time. +func (r *ReleaseReconciler) handleAnnotations(logger logr.Logger, release *deployv1alpha1.Release) { + logger.Info("handling annotations for release", "release", release.Name) + + // Handle theatre.gocardless.com/deployment-start-time annotation + startTimeString, found := release.Annotations[deployv1alpha1.AnnotationKeyReleaseDeploymentStartTime] + if !found || startTimeString == "" { + if !release.Status.DeploymentStartTime.IsZero() { + release.SetDeploymentStartTime(metav1.Time{}) + } + } else { + startTime, err := time.Parse(time.RFC3339, startTimeString) + if err != nil { + logger.Error(err, "failed to parse deployment start time annotation", "annotation", release.Annotations[deployv1alpha1.AnnotationKeyReleaseDeploymentStartTime]) + } else if !release.Status.DeploymentStartTime.Time.UTC().Equal(startTime.UTC()) { + release.SetDeploymentStartTime(metav1.NewTime(startTime)) + } + } + + // Handle theatre.gocardless.com/deployment-end-time + endTimeString, found := release.Annotations[deployv1alpha1.AnnotationKeyReleaseDeploymentEndTime] + if !found || endTimeString == "" { + if !release.Status.DeploymentEndTime.IsZero() { + release.SetDeploymentEndTime(metav1.Time{}) + } + } else { + endTime, err := time.Parse(time.RFC3339, endTimeString) + if err != nil { + logger.Error(err, "failed to parse deployment end time annotation", "annotation", release.Annotations[deployv1alpha1.AnnotationKeyReleaseDeploymentEndTime]) + } else if !release.Status.DeploymentEndTime.Time.UTC().Equal(endTime.UTC()) { + release.SetDeploymentEndTime(metav1.NewTime(endTime)) + } + } + + // Handle theatre.gocardless.com/active annotation + activate, found := release.Annotations[deployv1alpha1.AnnotationKeyReleaseActivate] + desiredActive := found && activate == deployv1alpha1.AnnotationValueReleaseActivateTrue + if desiredActive != release.IsConditionActiveTrue() { + if desiredActive { + release.Activate(MessageReleaseActive) + } else { + release.Deactivate(MessageReleaseInactive) + } + } + + // Handle theatre.gocardless.com/previous-release + previousRelease, found := release.Annotations[deployv1alpha1.AnnotationKeyReleasePreviousRelease] + if found { + if previousRelease != release.Status.PreviousRelease.ReleaseRef { + release.SetPreviousRelease(previousRelease) + } + } else if release.Status.PreviousRelease.ReleaseRef != "" { + release.SetPreviousRelease("") + } +} diff --git a/internal/controller/deploy/release_culling.go b/internal/controller/deploy/release_culling.go new file mode 100644 index 000000000..9c73daabb --- /dev/null +++ b/internal/controller/deploy/release_culling.go @@ -0,0 +1,178 @@ +package deploy + +import ( + "context" + "fmt" + "slices" + "strconv" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + coordinationv1 "k8s.io/api/coordination/v1" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/deploy" +) + +// Parses namespace annotations to determine culling configuration +// Returns the release limit, or defaults if the annotation is invalid +func cullConfig(logger logr.Logger, namespace corev1.Namespace) (limit int, err error) { + limit = DefaultReleaseLimit + + if limitString, ok := namespace.Annotations[deployv1alpha1.AnnotationKeyReleaseLimit]; ok { + newLimit, err := strconv.Atoi(limitString) + if err != nil { + logger.Error(err, fmt.Sprintf("invalid release limit annotation value, defaulting to %d", DefaultReleaseLimit), + "annotation", deployv1alpha1.AnnotationKeyReleaseLimit, "value", limitString) + } else { + limit = newLimit + } + } + + return limit, nil +} + +// cullReleases ensures that the number of inactive releases does not exceed +// the configured maximum. It will delete based on effective time (deployment +// end time if set, otherwise creation time). +func (r *ReleaseReconciler) cullReleases(ctx context.Context, logger logr.Logger, namespace string, target string) error { + var namespaceObj corev1.Namespace + if err := r.Client.Get(ctx, client.ObjectKey{Name: namespace}, &namespaceObj); err != nil { + return fmt.Errorf("failed to get namespace: %w", err) + } + + limit, err := cullConfig(logger, namespaceObj) + if err != nil { + return err + } + + logger = logger.WithValues("release_count_limit", limit) + + releaseList := &deployv1alpha1.ReleaseList{} + matchFields := client.MatchingFields(map[string]string{IndexFieldReleaseTarget: target}) + if err := r.List(ctx, releaseList, client.InNamespace(namespace), matchFields); err != nil { + return fmt.Errorf("failed to list releases: %w", err) + } + + if len(releaseList.Items) <= limit { + logger.Info("number of releases is within limit, skipping", "release_count", len(releaseList.Items)) + return nil + } + + var cullingCandidates []deployv1alpha1.Release + for _, release := range releaseList.Items { + // We want to cull releases that are initialised but not active + if release.IsStatusInitialised() && !release.IsConditionActiveTrue() { + cullingCandidates = append(cullingCandidates, release) + } + } + + excess := len(releaseList.Items) - limit + if excess > len(cullingCandidates) { + logger.Info("not enough culling candidates to safely cull, skipping", "candidates", len(cullingCandidates)) + return nil + } + + acquired, err := r.acquireCullingLease(ctx, logger, namespace, target) + if err != nil { + return fmt.Errorf("failed to acquire culling lease: %w", err) + } + + if !acquired { + logger.Info("culling lease not acquired, skipping culling", "target", target) + return nil + } + + slices.SortFunc(cullingCandidates, func(a, b deployv1alpha1.Release) int { + // Oldest first (oldest at index 0, newest at the end) + return a.GetEffectiveTime().Compare(b.GetEffectiveTime()) + }) + + excessReleases := cullingCandidates[:excess] + for _, releaseToDelete := range excessReleases { + logger.Info("attempt to delete release", "name", releaseToDelete.Name) + err := r.Delete(ctx, &releaseToDelete) + if err != nil { + return fmt.Errorf("failed to delete release: %w", err) + } + logger.Info("Deleted oldest release due to culling limit", "name", releaseToDelete.Name, "event", EventReleaseCulled) + } + + logger.Info("deleted excess releases", "count", len(excessReleases)) + return nil +} + +// acquireCullingLease attempts to acquire a Lease for the given namespace/target. +// Returns true if the lease was acquired (caller should proceed with culling), +// false if another reconcile holds the lease (caller should skip culling). +func (r *ReleaseReconciler) acquireCullingLease(ctx context.Context, logger logr.Logger, namespace, target string) (bool, error) { + leaseName := cullingLeaseName(target) + now := metav1.NowMicro() + leaseDuration := int32(5) // seconds + holderID := fmt.Sprintf("%d", time.Now().UnixNano()) + + var lease coordinationv1.Lease + err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: leaseName}, &lease) + if err != nil { + if !apierrors.IsNotFound(err) { + return false, err + } + // Lease doesn't exist, create it + lease = coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: leaseName, + Namespace: namespace, + }, + Spec: coordinationv1.LeaseSpec{ + HolderIdentity: &holderID, + AcquireTime: &now, + RenewTime: &now, + LeaseDurationSeconds: &leaseDuration, + }, + } + if err := r.Create(ctx, &lease); err != nil { + if apierrors.IsAlreadyExists(err) { + // Another reconcile just created it, skip + return false, nil + } + return false, err + } + logger.Info("acquired culling lease", "lease", leaseName) + return true, nil + } + + // Lease exists, check if it's expired + if lease.Spec.RenewTime != nil && lease.Spec.LeaseDurationSeconds != nil { + expiry := lease.Spec.RenewTime.Time.Add(time.Duration(*lease.Spec.LeaseDurationSeconds) * time.Second) + if time.Now().Before(expiry) { + // Lease is still valid, skip culling + return false, nil + } + } + + // Lease is expired, try to take it over + lease.Spec.HolderIdentity = &holderID + lease.Spec.AcquireTime = &now + lease.Spec.RenewTime = &now + lease.Spec.LeaseDurationSeconds = &leaseDuration + if err := r.Update(ctx, &lease); err != nil { + if apierrors.IsConflict(err) { + // Another reconcile updated it first, skip + return false, nil + } + return false, err + } + + logger.Info("acquired expired culling lease", "lease", leaseName) + return true, nil +} + +func cullingLeaseName(target string) string { + hash := deploy.HashString([]byte(target)) + return fmt.Sprintf("theatre-release-cull-%s", hash) +} diff --git a/internal/controller/deploy/release_culling_test.go b/internal/controller/deploy/release_culling_test.go new file mode 100644 index 000000000..cb15bf706 --- /dev/null +++ b/internal/controller/deploy/release_culling_test.go @@ -0,0 +1,57 @@ +package deploy + +import ( + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type cullConfigTestCase struct { + namespaceName string + namespace *corev1.Namespace + expectedLimit int + expectErr bool +} + +func createNewNamespace(name string, annotations map[string]string) *corev1.Namespace { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: annotations}} +} + +var _ = Describe("ReleaseCulling", func() { + logger := logr.Discard() + + DescribeTable("cullConfig", + func(tc cullConfigTestCase) { + limit, err := cullConfig(logger, *tc.namespace) + + if tc.expectErr { + Expect(err).To(HaveOccurred()) + return + } + Expect(err).NotTo(HaveOccurred()) + Expect(limit).To(Equal(tc.expectedLimit)) + }, + Entry("defaults", cullConfigTestCase{ + namespaceName: "test-ns", + namespace: createNewNamespace("test-ns", nil), + expectedLimit: DefaultReleaseLimit, + }), + Entry("max releases valid", cullConfigTestCase{ + namespaceName: "test-ns", + namespace: createNewNamespace("test-ns", map[string]string{ + deployv1alpha1.AnnotationKeyReleaseLimit: "5", + }), + expectedLimit: 5, + }), + Entry("max releases invalid", cullConfigTestCase{ + namespaceName: "test-ns", + namespace: createNewNamespace("test-ns", map[string]string{ + deployv1alpha1.AnnotationKeyReleaseLimit: "not-an-int", + }), + expectedLimit: DefaultReleaseLimit, + }), + ) +}) diff --git a/internal/controller/deploy/rollback_controller.go b/internal/controller/deploy/rollback_controller.go new file mode 100644 index 000000000..353b0c20b --- /dev/null +++ b/internal/controller/deploy/rollback_controller.go @@ -0,0 +1,365 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/cicd" + "github.com/gocardless/theatre/v5/pkg/recutil" + pkgerrors "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +const ( + // Duration to wait before rechecking a deployment + RequeueAfter = 15 * time.Second + + // Maximum number of times to retry triggering a deployment + MaxRollbackAttempts = 3 +) + +func init() { + metrics.Registry.MustRegister( + rollbackTerminalTotal, + rollbackCompletionDurationSeconds, + rollbackRetryCount, + ) +} + +type RollbackReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + RollbackHistoryLimit int + Deployer cicd.Deployer +} + +func (r *RollbackReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + logger := r.Log.WithValues("component", "Rollback") + + // Index releases by target name for efficient filtering in webhook + err := mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Release{}, + IndexFieldReleaseTarget, + func(rawObj client.Object) []string { + release := rawObj.(*deployv1alpha1.Release) + return []string{release.ReleaseConfig.TargetName} + }, + ) + if err != nil { + return err + } + + // Index rollbacks by target name for efficient filtering in webhook + err = mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Rollback{}, + IndexFieldRollbackTarget, + func(rawObj client.Object) []string { + rollback := rawObj.(*deployv1alpha1.Rollback) + return []string{rollback.Spec.ToReleaseRef.Target} + }, + ) + if err != nil { + return err + } + + // Index releases by their active condition status for efficient lookups + err = mgr.GetFieldIndexer().IndexField( + ctx, + &deployv1alpha1.Release{}, + IndexFieldReleaseActive, + func(rawObj client.Object) []string { + release := rawObj.(*deployv1alpha1.Release) + condition := meta.FindStatusCondition(release.Status.Conditions, deployv1alpha1.ReleaseConditionActive) + if condition == nil { + return []string{} + } + return []string{string(condition.Status)} + }, + ) + if err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&deployv1alpha1.Rollback{}). + Complete( + recutil.ResolveAndReconcile( + ctx, logger, mgr, &deployv1alpha1.Rollback{}, + func(logger logr.Logger, request ctrl.Request, obj runtime.Object) (ctrl.Result, error) { + return r.Reconcile(ctx, logger, request, obj.(*deployv1alpha1.Rollback)) + }, + ), + ) +} + +func (r *RollbackReconciler) Reconcile(ctx context.Context, logger logr.Logger, req ctrl.Request, rollback *deployv1alpha1.Rollback) (ctrl.Result, error) { + logger = logger.WithValues("namespace", req.Namespace, "rollback", rollback.Name) + logger.Info("reconciling rollback") + + // Check if rollback has already completed (succeeded or failed terminally) + if rollback.IsCompleted() { + logger.Info("rollback already complete, skipping") + return ctrl.Result{}, nil + } + + // Fetch the target release to get revision information + toRelease := &deployv1alpha1.Release{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: rollback.Namespace, + Name: rollback.Spec.ToReleaseRef.Name, + }, toRelease); err != nil { + logger.Error(err, "failed to fetch target release", "toRelease", rollback.Spec.ToReleaseRef.Name) + if apierrors.IsNotFound(err) { + return r.markRollbackFailed(ctx, logger, rollback, fmt.Sprintf("target release %q not found", rollback.Spec.ToReleaseRef.Name)) + } + return ctrl.Result{}, err + } + + // Determine the current release (fromRelease) if not already set + if rollback.Status.FromReleaseRef == nil { + fromRelease, err := r.findActiveRelease(ctx, toRelease.ReleaseConfig.TargetName, rollback.Namespace) + if err != nil { + logger.Info("failed to find active release, continuing without fromRelease", "error", err) + } else if fromRelease != nil { + rollback.Status.FromReleaseRef = &deployv1alpha1.ReleaseReference{ + Target: fromRelease.ReleaseConfig.TargetName, + Name: fromRelease.Name, + } + } + } + + if !meta.IsStatusConditionTrue(rollback.Status.Conditions, deployv1alpha1.RollbackConditionInProgress) { + return r.triggerDeployment(ctx, logger, rollback, toRelease) + } + + return r.pollDeploymentStatus(ctx, logger, rollback, toRelease) +} + +func (r *RollbackReconciler) findActiveRelease(ctx context.Context, targetName, namespace string) (*deployv1alpha1.Release, error) { + releaseList := &deployv1alpha1.ReleaseList{} + if err := r.List(ctx, releaseList, + client.InNamespace(namespace), + client.MatchingFields{IndexFieldReleaseActive: string(metav1.ConditionTrue)}, + ); err != nil { + return nil, err + } + + for _, release := range releaseList.Items { + if release.ReleaseConfig.TargetName == targetName { + return &release, nil + } + } + return nil, nil +} + +func (r *RollbackReconciler) statusUpdate(ctx context.Context, logger logr.Logger, rollback *deployv1alpha1.Rollback) error { + outcome, err := recutil.CreateOrUpdate(ctx, r.Client, rollback, recutil.StatusDiff) + if err != nil { + return pkgerrors.Wrap(err, "failed to update rollback status") + } + if outcome == recutil.StatusUpdate { + logger.Info("rollback status updated", "event", EventSuccessfulStatusUpdate) + } + return nil +} + +func (r *RollbackReconciler) triggerDeployment(ctx context.Context, logger logr.Logger, rollback *deployv1alpha1.Rollback, toRelease *deployv1alpha1.Release) (ctrl.Result, error) { + logger.Info("triggering deployment", "deployer", r.Deployer.Name(), "toRelease", toRelease.Name) + + if rollback.Status.AttemptCount >= MaxRollbackAttempts { + logger.Info("max rollback attempts exceeded", "attempts", rollback.Status.AttemptCount) + return r.markRollbackFailed(ctx, logger, rollback, "max rollback attempts exceeded") + } + + // Prepare deployment options using the shared CICD helper, which handles + // jsonpath expressions and JSON decoding into native types. + parsedOptions, _ := cicd.ParseDeploymentOptions(rollback.Spec.DeploymentOptions, toRelease) + + deployReq := cicd.DeploymentRequest{ + Rollback: rollback, + ToRelease: toRelease, + Options: parsedOptions, + } + + // Update attempt tracking + now := metav1.Now() + rollback.Status.AttemptCount++ + + if rollback.Status.StartTime == nil { + rollback.Status.StartTime = &now + } + + resp, err := r.Deployer.TriggerDeployment(ctx, deployReq) + if err != nil { + logger.Error(err, "failed to trigger deployment") + + // Check if error is retryable + var deployerErr *cicd.DeployerError + if errors.As(err, &deployerErr) && deployerErr.Retryable { + message := fmt.Sprintf("deployment trigger failed (attempt %d/%d): %v", rollback.Status.AttemptCount, MaxRollbackAttempts, err) + rollback.Status.Message = message + logger.Info(message, "event", EventDeploymentTriggerFailed, "attempt", rollback.Status.AttemptCount) + if updateErr := r.statusUpdate(ctx, logger, rollback); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{RequeueAfter: RequeueAfter}, nil + } + + // Non-retryable error + return r.markRollbackFailed(ctx, logger, rollback, fmt.Sprintf("deployment trigger failed: %v", err)) + } + + // Update status with deployment info + rollback.Status.DeploymentID = resp.ID + rollback.Status.DeploymentURL = resp.URL + rollback.Status.Message = fmt.Sprintf("deployment triggered via %s", r.Deployer.Name()) + + // Update InProgress condition to reflect successful trigger + meta.SetStatusCondition(&rollback.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.RollbackConditionInProgress, + Status: metav1.ConditionTrue, + Reason: "DeploymentTriggered", + Message: fmt.Sprintf("Deployment %s triggered via %s", resp.ID, r.Deployer.Name()), + }) + + if err := r.statusUpdate(ctx, logger, rollback); err != nil { + return ctrl.Result{}, err + } + logger.Info(fmt.Sprintf("deployment triggered successfully: %s", resp.URL), "deploymentID", resp.ID, "url", resp.URL, "event", EventDeploymentTriggered) + return ctrl.Result{RequeueAfter: RequeueAfter}, nil +} + +func (r *RollbackReconciler) pollDeploymentStatus(ctx context.Context, logger logr.Logger, rollback *deployv1alpha1.Rollback, toRelease *deployv1alpha1.Release) (ctrl.Result, error) { + statusResp, err := r.Deployer.GetDeploymentStatus(ctx, rollback.Status.DeploymentID) + if err != nil { + logger.Error(err, "failed to get deployment status") + // Continue polling on transient errors + if deployerErr, ok := err.(*cicd.DeployerError); ok && deployerErr.Retryable { + return ctrl.Result{RequeueAfter: RequeueAfter}, nil + } + return ctrl.Result{}, err + } + + logger.Info("deployment status", "status", statusResp.Status, "message", statusResp.Message) + + switch statusResp.Status { + case cicd.DeploymentStatusSucceeded: + return r.markRollbackSucceeded(ctx, logger, rollback, statusResp.Message) + + case cicd.DeploymentStatusFailed: + // Check if we should retry + if rollback.Status.AttemptCount < MaxRollbackAttempts { + logger.Info("deployment failed, retrying", "attempt", rollback.Status.AttemptCount, "maxAttempts", MaxRollbackAttempts, "event", EventDeploymentFailed) + rollback.Status.Message = fmt.Sprintf("deployment failed (attempt %d/%d): %s", rollback.Status.AttemptCount, MaxRollbackAttempts, statusResp.Message) + return r.triggerDeployment(ctx, logger, rollback, toRelease) + } + // Max retries exceeded - terminal failure + return r.markRollbackFailed(ctx, logger, rollback, statusResp.Message) + + case cicd.DeploymentStatusPending, cicd.DeploymentStatusInProgress: + // Update message and continue polling + if statusResp.Message != "" { + rollback.Status.Message = statusResp.Message + } + if err := r.statusUpdate(ctx, logger, rollback); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: RequeueAfter}, nil + + default: + logger.Info("unknown deployment status, continuing to poll", "status", statusResp.Status) + return ctrl.Result{RequeueAfter: RequeueAfter}, nil + } +} + +func (r *RollbackReconciler) markRollbackSucceeded(ctx context.Context, logger logr.Logger, rollback *deployv1alpha1.Rollback, message string) (ctrl.Result, error) { + now := metav1.Now() + rollback.Status.CompletionTime = &now + rollback.Status.Message = message + + meta.SetStatusCondition(&rollback.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.RollbackConditionInProgress, + Status: metav1.ConditionFalse, + Reason: "Completed", + Message: "Rollback deployment completed", + LastTransitionTime: now, + }) + + meta.SetStatusCondition(&rollback.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.RollbackConditionSucceded, + Status: metav1.ConditionTrue, + Reason: "DeploymentSucceeded", + Message: message, + LastTransitionTime: now, + }) + + if err := r.statusUpdate(ctx, logger, rollback); err != nil { + return ctrl.Result{}, err + } + + recordRollbackTerminalMetrics(rollback, "succeeded", now) + + logger.Info("rollback succeeded", "event", EventRollbackSucceeded) + return ctrl.Result{}, nil +} + +// markRollbackFailed marks the rollback as terminally failed +func (r *RollbackReconciler) markRollbackFailed(ctx context.Context, logger logr.Logger, rollback *deployv1alpha1.Rollback, message string) (ctrl.Result, error) { + now := metav1.Now() + rollback.Status.CompletionTime = &now + rollback.Status.Message = message + + meta.SetStatusCondition(&rollback.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.RollbackConditionInProgress, + Status: metav1.ConditionFalse, + Reason: "Failed", + Message: "Rollback deployment failed", + LastTransitionTime: now, + }) + + meta.SetStatusCondition(&rollback.Status.Conditions, metav1.Condition{ + Type: deployv1alpha1.RollbackConditionSucceded, + Status: metav1.ConditionFalse, + Reason: "DeploymentFailed", + Message: message, + LastTransitionTime: now, + }) + + if err := r.statusUpdate(ctx, logger, rollback); err != nil { + return ctrl.Result{}, err + } + + recordRollbackTerminalMetrics(rollback, "failed", now) + + logger.Info(fmt.Sprintf("rollback failed: %s", message), "event", EventRollbackFailed) + return ctrl.Result{}, nil +} + +func recordRollbackTerminalMetrics(rollback *deployv1alpha1.Rollback, status string, completionTime metav1.Time) { + labels := buildRollbackLabels(rollback, status) + rollbackTerminalTotal.With(labels).Inc() + + if !rollback.CreationTimestamp.IsZero() { + durationSeconds := completionTime.Time.Sub(rollback.CreationTimestamp.Time).Seconds() + if durationSeconds >= 0 { + rollbackCompletionDurationSeconds.With(labels).Observe(durationSeconds) + } + } + + retryCount := max(0, rollback.Status.AttemptCount-1) + rollbackRetryCount.With(labels).Observe(float64(retryCount)) +} diff --git a/internal/controller/deploy/suite_test.go b/internal/controller/deploy/suite_test.go new file mode 100644 index 000000000..fcfbfb2bc --- /dev/null +++ b/internal/controller/deploy/suite_test.go @@ -0,0 +1,13 @@ +package deploy + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "controllers/deploy") +} diff --git a/internal/controller/deploy/util.go b/internal/controller/deploy/util.go new file mode 100644 index 000000000..ce3b2ca7d --- /dev/null +++ b/internal/controller/deploy/util.go @@ -0,0 +1,22 @@ +package deploy + +import ( + "context" + "fmt" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GetReleasesForTarget retrieves all releases for a given target +func GetReleasesForTarget(ctx context.Context, c client.Client, namespace, targetName string) (*deployv1alpha1.ReleaseList, error) { + releaseList := &deployv1alpha1.ReleaseList{} + if err := c.List(ctx, releaseList, + client.InNamespace(namespace), + client.MatchingFields{IndexFieldReleaseTarget: targetName}, + ); err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + + return releaseList, nil +} diff --git a/internal/controller/rbac/cached_directory_test.go b/internal/controller/rbac/cached_directory_test.go index 9b2da6c6b..c5701b6fc 100644 --- a/internal/controller/rbac/cached_directory_test.go +++ b/internal/controller/rbac/cached_directory_test.go @@ -10,7 +10,7 @@ import ( gock "gopkg.in/h2non/gock.v1" "sigs.k8s.io/controller-runtime/pkg/log/zap" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/internal/controller/rbac/google_directory_test.go b/internal/controller/rbac/google_directory_test.go index 115e42eed..a44387298 100644 --- a/internal/controller/rbac/google_directory_test.go +++ b/internal/controller/rbac/google_directory_test.go @@ -8,7 +8,7 @@ import ( "google.golang.org/api/option" gock "gopkg.in/h2non/gock.v1" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/internal/controller/rbac/integration/integration_test.go b/internal/controller/rbac/integration/integration_test.go index 0fa4ca6b2..0277eb9c2 100644 --- a/internal/controller/rbac/integration/integration_test.go +++ b/internal/controller/rbac/integration/integration_test.go @@ -12,7 +12,7 @@ import ( rbacv1alpha1 "github.com/gocardless/theatre/v5/api/rbac/v1alpha1" "github.com/google/uuid" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/internal/controller/rbac/integration/suite_test.go b/internal/controller/rbac/integration/suite_test.go index a8646aa4b..3ca344f43 100644 --- a/internal/controller/rbac/integration/suite_test.go +++ b/internal/controller/rbac/integration/suite_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/onsi/gomega/gexec" "k8s.io/apimachinery/pkg/runtime" @@ -91,4 +91,4 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) }() -}, 60) +}) diff --git a/internal/controller/rbac/suite_test.go b/internal/controller/rbac/suite_test.go index 488a0e842..266990855 100644 --- a/internal/controller/rbac/suite_test.go +++ b/internal/controller/rbac/suite_test.go @@ -3,7 +3,7 @@ package directoryrolebinding import ( "testing" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/internal/controller/workloads/integration/integration_test.go b/internal/controller/workloads/integration/integration_test.go index bb7a78143..9caae3c70 100644 --- a/internal/controller/workloads/integration/integration_test.go +++ b/internal/controller/workloads/integration/integration_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/google/uuid" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" diff --git a/internal/controller/workloads/integration/suite_test.go b/internal/controller/workloads/integration/suite_test.go index d5293e89f..b5f3eeb56 100644 --- a/internal/controller/workloads/integration/suite_test.go +++ b/internal/controller/workloads/integration/suite_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/onsi/gomega/gexec" "k8s.io/apimachinery/pkg/runtime" @@ -125,4 +125,4 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) }() -}, 60) +}) diff --git a/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_suite_test.go b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_suite_test.go new file mode 100644 index 000000000..e190f6617 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_suite_test.go @@ -0,0 +1,13 @@ +package automatedrollbackpolicy + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAutomatedRollbackPolicy(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "webhook/deploy/v1alpha1/automated-rollback-policy") +} diff --git a/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook.go b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook.go new file mode 100644 index 000000000..f7c337151 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook.go @@ -0,0 +1,53 @@ +package automatedrollbackpolicy + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type AutomatedRollbackPolicyValidateWebhook struct { + logger logr.Logger + decoder admission.Decoder + client client.Client +} + +func NewAutomatedRollbackPolicyValidateWebhook(logger logr.Logger, scheme *runtime.Scheme, client client.Client) *AutomatedRollbackPolicyValidateWebhook { + decoder := admission.NewDecoder(scheme) + return &AutomatedRollbackPolicyValidateWebhook{ + logger: logger, + decoder: decoder, + client: client, + } +} + +func (w *AutomatedRollbackPolicyValidateWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + policy := &deployv1alpha1.AutomatedRollbackPolicy{} + if err := w.decoder.Decode(req, policy); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + targetName := policy.Spec.TargetName + w.logger.Info("Validating automated rollback policy", "name", policy.Name, "namespace", policy.Namespace, "targetName", targetName) + + policies := &deployv1alpha1.AutomatedRollbackPolicyList{} + if err := w.client.List(ctx, policies, + client.InNamespace(req.Namespace), + client.MatchingFields(map[string]string{deploy.IndexFieldPolicyTargetName: targetName}), + ); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if len(policies.Items) > 0 { + return admission.Denied(fmt.Sprintf("automated rollback policy already exists for target %s", targetName)) + } + + return admission.Allowed("automated rollback policy is valid") +} diff --git a/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook_test.go b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook_test.go new file mode 100644 index 000000000..1f0c67403 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/automated-rollback-policy/policy_validate_webhook_test.go @@ -0,0 +1,101 @@ +package automatedrollbackpolicy + +import ( + "context" + "encoding/json" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("AutomatedRollbackPolicyValidateWebhook", func() { + var ( + ctx context.Context + cancel context.CancelFunc + scheme *runtime.Scheme + fakeClient client.Client + webhook *AutomatedRollbackPolicyValidateWebhook + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + + scheme = runtime.NewScheme() + Expect(deployv1alpha1.AddToScheme(scheme)).To(Succeed()) + }) + + setupFakeClientWithIndex := func(objects ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&deployv1alpha1.AutomatedRollbackPolicy{}, deploy.IndexFieldPolicyTargetName, func(obj client.Object) []string { + policy := obj.(*deployv1alpha1.AutomatedRollbackPolicy) + return []string{policy.Spec.TargetName} + }). + Build() + } + + AfterEach(func() { + cancel() + }) + + It("should allow new policies to be created, if no policies with the same target exist", func() { + differentTargetPolicy := newAutomatedRollbackPolicy("different-policy", "different-target") + fakeClient = setupFakeClientWithIndex(differentTargetPolicy) + + webhook = NewAutomatedRollbackPolicyValidateWebhook(logr.New(logr.Discard().GetSink()), scheme, fakeClient) + req := reqWithObj(newAutomatedRollbackPolicy("test", "test-target")) + + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + }) + + It("should deny new policies if a policy with the same target already exists", func() { + existingPolicy := newAutomatedRollbackPolicy("existing-policy", "existing-target") + fakeClient = setupFakeClientWithIndex(existingPolicy) + + webhook = NewAutomatedRollbackPolicyValidateWebhook(logr.New(logr.Discard().GetSink()), scheme, fakeClient) + req := reqWithObj(newAutomatedRollbackPolicy("test", "existing-target")) + + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeFalse()) + }) +}) + +func newAutomatedRollbackPolicy( + name string, + target string, +) *deployv1alpha1.AutomatedRollbackPolicy { + return &deployv1alpha1.AutomatedRollbackPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: deployv1alpha1.AutomatedRollbackPolicySpec{ + TargetName: target, + }, + } +} + +func reqWithObj(obj runtime.Object) admission.Request { + return admission.Request{AdmissionRequest: v1.AdmissionRequest{ + Object: objectToRaw(obj), + }} +} + +func objectToRaw(obj runtime.Object) runtime.RawExtension { + objRaw, err := json.Marshal(obj) + Expect(err).ToNot(HaveOccurred()) + return runtime.RawExtension{ + Raw: objRaw, + } +} diff --git a/internal/webhook/deploy/v1alpha1/release/release_namer_webhook.go b/internal/webhook/deploy/v1alpha1/release/release_namer_webhook.go new file mode 100644 index 000000000..d100ff354 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/release/release_namer_webhook.go @@ -0,0 +1,53 @@ +package release + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/deploy" + "k8s.io/apimachinery/pkg/runtime" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type ReleaseNamerWebhook struct { + logger logr.Logger + decoder admission.Decoder +} + +func NewReleaseNamerWebhook(logger logr.Logger, scheme *runtime.Scheme) *ReleaseNamerWebhook { + decoder := admission.NewDecoder(scheme) + return &ReleaseNamerWebhook{ + logger: logger, + decoder: decoder, + } +} + +func (i *ReleaseNamerWebhook) Handle(ctx context.Context, req admission.Request) (resp admission.Response) { + release := &deployv1alpha1.Release{} + if err := i.decoder.Decode(req, release); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + releaseName, err := deploy.GenerateReleaseName(*release) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if release.Name == releaseName { + return admission.ValidationResponse(true, "Release name already set") + } + + copy := release.DeepCopy() + copy.Name = releaseName + + // Marshal the updated release object + copyBytes, err := json.Marshal(copy) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + return admission.PatchResponseFromRaw(req.Object.Raw, copyBytes) +} diff --git a/internal/webhook/deploy/v1alpha1/release/release_namer_webhook_test.go b/internal/webhook/deploy/v1alpha1/release/release_namer_webhook_test.go new file mode 100644 index 000000000..faf9b8532 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/release/release_namer_webhook_test.go @@ -0,0 +1,138 @@ +package release + +import ( + "context" + "encoding/json" + "net/http" + + logr "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/deploy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("ReleaseNamerWebhook", func() { + var ( + ctx context.Context + cancel context.CancelFunc + obj *deployv1alpha1.Release + releaseNamerWebhook *ReleaseNamerWebhook + scheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + + scheme = runtime.NewScheme() + Expect(deployv1alpha1.AddToScheme(scheme)).To(Succeed()) + + obj = &deployv1alpha1.Release{ + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + TargetName: "test-target", + Revisions: []deployv1alpha1.Revision{ + { + Name: "infrastructure-revision", + ID: "sha123", + Source: "https://github.com/example/repo", + Type: "git", + }, + { + Name: "application-revision", + ID: "sha256:abc123", + Source: "registry.example.com/image:v1.0.0", + Type: "container_image", + }, + }, + }, + } + + releaseNamerWebhook = NewReleaseNamerWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + ) + }) + + AfterEach(func() { + cancel() + }) + + Context("Handle Admission Request", func() { + It("Should process a valid release request", func() { + req := reqWithObj(obj) + resp := releaseNamerWebhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(len(resp.Patches)).To(BeNumerically(">=", 1)) + // Check that the patch includes the metadata field + Expect(resp.Patches[0].Path).To(Equal("/metadata")) + // Check that the patch modifies the name field + Expect(resp.Patches[0].Value).To(HaveKey("name")) + // Check that the name is set + nameValue, ok := resp.Patches[0].Value.(map[string]interface{})["name"] + Expect(ok).To(BeTrue()) + Expect(nameValue).NotTo(BeEmpty()) + }) + + It("Should exit early if release name is already set", func() { + name, err := deploy.GenerateReleaseName(*obj) + Expect(err).ToNot(HaveOccurred()) + obj.Name = name + + req := reqWithObj(obj) + resp := releaseNamerWebhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(resp.Result.Message).To(Equal("Release name already set")) + // Should not add any patches since name is already set + Expect(len(resp.Patches)).To(Equal(0)) + Expect(resp.Result.Code).To(Equal(int32(http.StatusOK))) + }) + + It("Should rename release when name is already set but invalid", func() { + obj.Name = "invalid-name-with-special-chars" + + generatedName, err := deploy.GenerateReleaseName(*obj) + Expect(err).ToNot(HaveOccurred()) + Expect(len(generatedName)).To(BeNumerically(">", 0)) + + req := reqWithObj(obj) + resp := releaseNamerWebhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(len(resp.Patches)).To(BeNumerically(">=", 1)) + Expect(resp.Patches[0].Path).To(Equal("/metadata/name")) + Expect(resp.Patches[0].Value).To(Equal(generatedName)) + }) + + It("Should error out if revisions or targetName are invalid", func() { + invalidRevisionObj := obj.DeepCopy() + invalidRevisionObj.Revisions = []deployv1alpha1.Revision{} + req := reqWithObj(invalidRevisionObj) + resp := releaseNamerWebhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + + invalidTargetName := obj.DeepCopy() + invalidTargetName.TargetName = "" + req2 := reqWithObj(invalidTargetName) + resp2 := releaseNamerWebhook.Handle(ctx, req2) + Expect(resp2.Allowed).To(BeFalse()) + Expect(resp2.Result.Code).To(Equal(int32(http.StatusBadRequest))) + }) + }) +}) + +func reqWithObj(obj runtime.Object) admission.Request { + return admission.Request{AdmissionRequest: v1.AdmissionRequest{ + Object: objectToRaw(obj), + }} +} + +func objectToRaw(obj runtime.Object) runtime.RawExtension { + objRaw, err := json.Marshal(obj) + Expect(err).ToNot(HaveOccurred()) + return runtime.RawExtension{ + Raw: objRaw, + } +} diff --git a/internal/webhook/deploy/v1alpha1/release/release_suite_test.go b/internal/webhook/deploy/v1alpha1/release/release_suite_test.go new file mode 100644 index 000000000..ba4bd48a1 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/release/release_suite_test.go @@ -0,0 +1,13 @@ +package release_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRelease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "webhook/deploy/v1alpha1/release") +} diff --git a/internal/webhook/deploy/v1alpha1/release/release_validate_webhook.go b/internal/webhook/deploy/v1alpha1/release/release_validate_webhook.go new file mode 100644 index 000000000..eb19d27fd --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/release/release_validate_webhook.go @@ -0,0 +1,47 @@ +package release + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type ReleaseValidateWebhook struct { + logger logr.Logger + decoder admission.Decoder +} + +func NewReleaseValidateWebhook(logger logr.Logger, scheme *runtime.Scheme) *ReleaseValidateWebhook { + decoder := admission.NewDecoder(scheme) + return &ReleaseValidateWebhook{ + logger: logger, + decoder: decoder, + } +} + +func (i *ReleaseValidateWebhook) Handle(ctx context.Context, req admission.Request) (resp admission.Response) { + release := &deployv1alpha1.Release{} + if err := i.decoder.DecodeRaw(req.Object, release); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if req.Operation == admissionv1.Update { + oldRelease := &deployv1alpha1.Release{} + if err := i.decoder.DecodeRaw(req.OldObject, oldRelease); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if !oldRelease.ReleaseConfig.Equals(&release.ReleaseConfig) { + return admission.Errored(http.StatusBadRequest, fmt.Errorf("release .config.targetName, config.revision[].name and"+ + " config.revision[].id are immutable")) + } + } + + return admission.Allowed("") +} diff --git a/internal/webhook/deploy/v1alpha1/release/release_validate_webhook_test.go b/internal/webhook/deploy/v1alpha1/release/release_validate_webhook_test.go new file mode 100644 index 000000000..1b5d95b09 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/release/release_validate_webhook_test.go @@ -0,0 +1,106 @@ +package release + +import ( + "context" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("ReleaseValidateWebhook", func() { + + var ( + ctx context.Context + cancel context.CancelFunc + obj *deployv1alpha1.Release + oldObj *deployv1alpha1.Release + releaseValidateWebhook *ReleaseValidateWebhook + scheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + scheme = runtime.NewScheme() + Expect(deployv1alpha1.AddToScheme(scheme)).To(Succeed()) + obj = &deployv1alpha1.Release{ + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + TargetName: "default", + Revisions: []deployv1alpha1.Revision{ + {Name: "application", ID: "test-id"}, + {Name: "infrastructure", ID: "test-id-2"}, + }, + }, + } + oldObj = obj.DeepCopy() + releaseValidateWebhook = NewReleaseValidateWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + ) + }) + + AfterEach(func() { + cancel() + }) + + Context("When updating Release under ReleaseValidate Webhook", func() { + It("Should fail when modifying targetName", func() { + obj.ReleaseConfig.TargetName = "other" + resp := releaseValidateWebhook.Handle(ctx, admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + Operation: v1.Update, + Object: objectToRaw(obj), + OldObject: objectToRaw(oldObj), + }, + }) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Message).To(ContainSubstring("release .config.targetName, config.revision[].name and config.revision[].id are immutable")) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + }) + + It("Should fail when modifying the revisions array", func() { + obj.ReleaseConfig.Revisions[0].ID = "750601ce98f7fc1309dc6cc060b822d8fcf32523" + + resp := releaseValidateWebhook.Handle(ctx, admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + Operation: v1.Update, + Object: objectToRaw(obj), + OldObject: objectToRaw(oldObj), + }, + }) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Message).To(ContainSubstring("release .config.targetName, config.revision[].name and config.revision[].id are immutable")) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + }) + + It("Should allow labels and annotations to be updated", func() { + obj.Labels = map[string]string{"foo": "bar"} + obj.Annotations = map[string]string{"foo": "bar"} + resp := releaseValidateWebhook.Handle(ctx, admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + Operation: v1.Update, + Object: objectToRaw(obj), + OldObject: objectToRaw(oldObj), + }, + }) + Expect(resp.Allowed).To(BeTrue()) + }) + }) + + Context("When running a non-update operation", func() { + It("Should allow the operation", func() { + resp := releaseValidateWebhook.Handle(ctx, admission.Request{ + AdmissionRequest: v1.AdmissionRequest{ + Operation: v1.Create, + Object: objectToRaw(obj), + }, + }) + Expect(resp.Allowed).To(BeTrue()) + }) + }) +}) diff --git a/internal/webhook/deploy/v1alpha1/rollback/rollback_suite_test.go b/internal/webhook/deploy/v1alpha1/rollback/rollback_suite_test.go new file mode 100644 index 000000000..5ec2d39a4 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/rollback/rollback_suite_test.go @@ -0,0 +1,13 @@ +package rollback + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRollback(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "webhook/deploy/v1alpha1/rollback") +} diff --git a/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook.go b/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook.go new file mode 100644 index 000000000..6d12bf8fe --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook.go @@ -0,0 +1,105 @@ +package rollback + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// RollbackTargetWebhook is a mutating webhook that sets the ToReleaseRef field +// if it's not already set. It finds the last healthy release by walking back +// from the active release using the PreviousRelease field. +type RollbackTargetWebhook struct { + logger logr.Logger + decoder admission.Decoder + client client.Client +} + +func NewRollbackTargetWebhook(logger logr.Logger, scheme *runtime.Scheme, client client.Client) *RollbackTargetWebhook { + decoder := admission.NewDecoder(scheme) + return &RollbackTargetWebhook{ + logger: logger, + decoder: decoder, + client: client, + } +} + +func (w *RollbackTargetWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + rollback := &deployv1alpha1.Rollback{} + if err := w.decoder.Decode(req, rollback); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + var targetRelease *deployv1alpha1.Release + copy := rollback.DeepCopy() + + targetReleases := &deployv1alpha1.ReleaseList{} + matchFields := client.MatchingFields(map[string]string{deploy.IndexFieldReleaseTarget: rollback.Spec.ToReleaseRef.Target}) + if err := w.client.List(ctx, targetReleases, client.InNamespace(req.Namespace), matchFields); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if len(targetReleases.Items) == 0 { + return admission.Denied(fmt.Sprintf("no releases found for target %q", rollback.Spec.ToReleaseRef.Target)) + } + + // Ensure there is an active release to roll back from + activeRelease := deployv1alpha1.FindActiveRelease(targetReleases) + if activeRelease == nil { + return admission.Denied(fmt.Sprintf("no active release found for target %q to rollback from", rollback.Spec.ToReleaseRef.Target)) + } + + // If ToReleaseRef.Name is already set, validate that the referenced Release exists + if rollback.Spec.ToReleaseRef.Name != "" { + targetRelease = &deployv1alpha1.Release{} + if err := w.client.Get(ctx, client.ObjectKey{Name: rollback.Spec.ToReleaseRef.Name, Namespace: req.Namespace}, targetRelease); err != nil { + if apierrors.IsNotFound(err) { + return admission.Denied(fmt.Sprintf("ToReleaseRef %q not found", rollback.Spec.ToReleaseRef.Name)) + } + return admission.Errored(http.StatusInternalServerError, err) + } + // Validate that the release belongs to the specified target + if targetRelease.ReleaseConfig.TargetName != rollback.Spec.ToReleaseRef.Target { + return admission.Denied(fmt.Sprintf("Release %q does not belong to target %q", rollback.Spec.ToReleaseRef.Name, rollback.Spec.ToReleaseRef.Target)) + } + } else { + w.logger.Info("ToReleaseRef.Name not set, finding latest healthy release for target", "target", rollback.Spec.ToReleaseRef.Target) + // Walk back from the active release to find the last healthy release + targetRelease = deployv1alpha1.FindLastHealthyRelease(targetReleases) + if targetRelease == nil { + return admission.Denied(fmt.Sprintf("no healthy release found for target %q to rollback to", rollback.Spec.ToReleaseRef.Target)) + } + + w.logger.Info("auto-setting rollback target", "targetRelease", targetRelease.Name) + copy.Spec.ToReleaseRef.Name = targetRelease.Name + } + + // Copy labels from the target release to the rollback + if len(targetRelease.Labels) > 0 { + if copy.Labels == nil { + copy.Labels = make(map[string]string) + } + maps.Copy(copy.Labels, targetRelease.Labels) + } + + // Set owner ref on the target release + w.logger.Info("setting release owner reference on rollback") + controllerutil.SetControllerReference(activeRelease, copy, w.client.Scheme()) + copyBytes, err := json.Marshal(copy) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + return admission.PatchResponseFromRaw(req.Object.Raw, copyBytes) +} diff --git a/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook_test.go b/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook_test.go new file mode 100644 index 000000000..e56533e53 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/rollback/rollback_target_webhook_test.go @@ -0,0 +1,440 @@ +package rollback + +import ( + "context" + "encoding/json" + + logr "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const namespace = "default" + +var _ = Describe("RollbackTargetWebhook", func() { + var ( + ctx context.Context + cancel context.CancelFunc + scheme *runtime.Scheme + fakeClient client.Client + webhook *RollbackTargetWebhook + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + + scheme = runtime.NewScheme() + Expect(deployv1alpha1.AddToScheme(scheme)).To(Succeed()) + }) + + setupFakeClientWithIndex := func(objects ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&deployv1alpha1.Release{}, deploy.IndexFieldReleaseTarget, func(obj client.Object) []string { + release := obj.(*deployv1alpha1.Release) + return []string{release.ReleaseConfig.TargetName} + }). + Build() + } + + AfterEach(func() { + cancel() + }) + + Context("when ToReleaseRef is already set", func() { + It("should set owner reference to the active release", func() { + // Seed a release matching the ToReleaseRef so validation passes + activeRelease := newRelease( + "my-release-v2", + true, // active + true, // healthy + "", + nil, + ) + + targetRelease := newRelease( + "my-release-v1", + false, // active + true, // healthy + "", + nil, + ) + + fakeClient = setupFakeClientWithIndex(activeRelease, targetRelease) + + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + Name: "my-release-v1", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(len(resp.Patches)).To(BeNumerically(">=", 1), "expected patches for owner reference") + + // Verify owner reference is set in the patches + var foundOwnerRef bool + for _, patch := range resp.Patches { + if patch.Path == "/metadata/ownerReferences" { + foundOwnerRef = true + // Verify it's an array with at least one owner reference + Expect(patch.Value).ToNot(BeNil()) + Expect(patch.Value).To(ContainElement(HaveKeyWithValue("name", activeRelease.Name))) + } + } + Expect(foundOwnerRef).To(BeTrue(), "expected patch for /metadata/ownerReferences, got patches: %+v", resp.Patches) + }) + }) + + Context("when ToReleaseRef is not set", func() { + It("should deny if no active release exists", func() { + fakeClient = setupFakeClientWithIndex() + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Message).To(ContainSubstring("no releases found for target")) + }) + + It("should deny if no healthy release exists in the chain", func() { + // Create an active release with no previous release + activeRelease := newRelease( + "my-service-v2", + true, // active + false, // healthy + "", + []deployv1alpha1.Revision{{Name: "app", ID: "abc123"}}, + ) + + fakeClient = setupFakeClientWithIndex(activeRelease) + + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Message).To(ContainSubstring("no healthy release found")) + }) + + It("should set ToReleaseRef to the last healthy release", func() { + // Create a chain: v1 (healthy) <- v2 (unhealthy) <- v3 (active) + releaseV1 := newRelease( + "my-service-v1", + false, // active + true, // healthy + "", + []deployv1alpha1.Revision{{Name: "app", ID: "abc111"}}, + ) + + releaseV2 := newRelease( + "my-service-v2", + false, // active + false, // healthy + "my-service-v1", + []deployv1alpha1.Revision{{Name: "app", ID: "abc222"}}, + ) + + releaseV3 := newRelease( + "my-service-v3", + true, // active + false, // healthy + "my-service-v2", + []deployv1alpha1.Revision{{Name: "app", ID: "abc333"}}, + ) + + fakeClient = setupFakeClientWithIndex(releaseV1, releaseV2, releaseV3) + + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue(), "response: %+v", resp) + Expect(len(resp.Patches)).To(BeNumerically(">=", 1), "patches: %+v", resp.Patches) + + // Verify the patch sets the correct target release + var foundTargetRelease bool + var foundOwnerRef bool + for _, patch := range resp.Patches { + if patch.Path == "/spec/toReleaseRef/name" { + Expect(patch.Value).To(Equal("my-service-v1")) + foundTargetRelease = true + } + if patch.Path == "/metadata/ownerReferences" { + foundOwnerRef = true + Expect(patch.Value).ToNot(BeNil()) + } + } + Expect(foundTargetRelease).To(BeTrue(), "expected patch for /spec/toReleaseRef/name, got patches: %+v", resp.Patches) + Expect(foundOwnerRef).To(BeTrue(), "expected patch for /metadata/ownerReferences, got patches: %+v", resp.Patches) + }) + + It("should select the immediate previous release if it is healthy", func() { + // Create a chain: v1 (healthy) <- v2 (healthy) <- v3 (active) + // Should select v2 as it's the most recent healthy release + releaseV1 := newRelease( + "my-service-v1", + false, // active + true, // healthy + "", + []deployv1alpha1.Revision{{Name: "app", ID: "abc111"}}, + ) + + releaseV2 := newRelease( + "my-service-v2", + false, // active + true, // healthy + "my-service-v1", + []deployv1alpha1.Revision{{Name: "app", ID: "abc222"}}, + ) + + releaseV3 := newRelease( + "my-service-v3", + true, // active + false, // healthy + "my-service-v2", + []deployv1alpha1.Revision{{Name: "app", ID: "abc333"}}, + ) + + fakeClient = setupFakeClientWithIndex(releaseV1, releaseV2, releaseV3) + + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + + // Verify the patch sets v2 as the target (most recent healthy) + var foundTargetRelease bool + var foundOwnerRef bool + for _, patch := range resp.Patches { + if patch.Path == "/spec/toReleaseRef/name" { + Expect(patch.Value).To(Equal("my-service-v2")) + foundTargetRelease = true + } + if patch.Path == "/metadata/ownerReferences" { + foundOwnerRef = true + Expect(patch.Value).ToNot(BeNil()) + } + } + Expect(foundTargetRelease).To(BeTrue(), "expected patch for /spec/toReleaseRef/name, got patches: %+v", resp.Patches) + Expect(foundOwnerRef).To(BeTrue(), "expected patch for /metadata/ownerReferences, got patches: %+v", resp.Patches) + }) + }) + + Context("label copying", func() { + It("should merge labels from target release with existing rollback labels", func() { + activeRelease := newRelease( + "my-release-v2", + true, + true, + "", + nil, + ) + + targetRelease := newRelease( + "my-release-v1", + false, + true, + "", + nil, + ) + targetRelease.Labels = map[string]string{ + "env": "production", + "version": "1.2.3", + } + + fakeClient = setupFakeClientWithIndex(activeRelease, targetRelease) + webhook = NewRollbackTargetWebhook( + logr.New(logr.Discard().GetSink()), + scheme, + fakeClient, + ) + + rollback := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: namespace, + Labels: map[string]string{ + "team": "platform", + "triggered": "manual", + }, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + Name: "my-release-v1", + }, + Reason: "Testing", + }, + } + + req := reqWithObj(rollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + + var foundLabels bool + for _, patch := range resp.Patches { + if patch.Path == "/metadata/labels/env" { + foundLabels = true + Expect(patch.Value).To(Equal("production")) + } + if patch.Path == "/metadata/labels/version" { + Expect(patch.Value).To(Equal("1.2.3")) + } + } + Expect(foundLabels).To(BeTrue(), "expected patches for labels, got patches: %+v", resp.Patches) + }) + }) +}) + +func reqWithObj(obj runtime.Object) admission.Request { + return admission.Request{AdmissionRequest: v1.AdmissionRequest{ + Namespace: namespace, + Object: objectToRaw(obj), + }} +} + +func objectToRaw(obj runtime.Object) runtime.RawExtension { + objRaw, err := json.Marshal(obj) + Expect(err).ToNot(HaveOccurred()) + return runtime.RawExtension{ + Raw: objRaw, + } +} + +func newRelease( + name string, + isActive bool, + isHealthy bool, + previousRef string, + revisions []deployv1alpha1.Revision, +) *deployv1alpha1.Release { + conditions := []metav1.Condition{ + { + Type: deployv1alpha1.ReleaseConditionActive, + Status: func() metav1.ConditionStatus { + if isActive { + return metav1.ConditionTrue + } + return metav1.ConditionFalse + }(), + }, + { + Type: deployv1alpha1.ReleaseConditionHealthy, + Status: func() metav1.ConditionStatus { + if isHealthy { + return metav1.ConditionTrue + } + return metav1.ConditionFalse + }(), + }, + } + + return &deployv1alpha1.Release{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + TargetName: "my-service", + Revisions: revisions, + }, + Status: deployv1alpha1.ReleaseStatus{ + Conditions: conditions, + PreviousRelease: deployv1alpha1.ReleaseTransition{ReleaseRef: previousRef}, + }, + } +} diff --git a/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook.go b/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook.go new file mode 100644 index 000000000..e2d1d8e6c --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook.go @@ -0,0 +1,54 @@ +package rollback + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + admission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// RollbackValidateWebhook is a validating webhook that ensures the isn't another +// rollback in progress for the same target. +type RollbackValidateWebhook struct { + logger logr.Logger + decoder admission.Decoder + client client.Client +} + +func NewRollbackValidateWebhook(logger logr.Logger, scheme *runtime.Scheme, client client.Client) *RollbackValidateWebhook { + decoder := admission.NewDecoder(scheme) + return &RollbackValidateWebhook{ + logger: logger, + decoder: decoder, + client: client, + } +} + +func (w *RollbackValidateWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + rollback := &deployv1alpha1.Rollback{} + if err := w.decoder.Decode(req, rollback); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + target := rollback.Spec.ToReleaseRef.Target + + // List all rollbacks for the target + targetRollbacks := &deployv1alpha1.RollbackList{} + matchFields := client.MatchingFields(map[string]string{deploy.IndexFieldRollbackTarget: target}) + if err := w.client.List(ctx, targetRollbacks, client.InNamespace(req.Namespace), matchFields); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + inProgressRollback := deployv1alpha1.FindInProgressRollback(targetRollbacks) + if inProgressRollback != nil { + return admission.Denied(fmt.Sprintf("another rollback in-progress for target %q", target)) + } + + return admission.Allowed("no in-progress rollbacks found") +} diff --git a/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook_test.go b/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook_test.go new file mode 100644 index 000000000..ef7090304 --- /dev/null +++ b/internal/webhook/deploy/v1alpha1/rollback/rollback_validate_webhook_test.go @@ -0,0 +1,124 @@ +package rollback + +import ( + "context" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + deploy "github.com/gocardless/theatre/v5/internal/controller/deploy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("RollbackValidateWebhook", func() { + var ( + ctx context.Context + cancel context.CancelFunc + scheme *runtime.Scheme + fakeClient client.Client + webhook *RollbackValidateWebhook + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + + scheme = runtime.NewScheme() + Expect(deployv1alpha1.AddToScheme(scheme)).To(Succeed()) + }) + + setupFakeClientWithIndex := func(objects ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&deployv1alpha1.Rollback{}, deploy.IndexFieldRollbackTarget, func(obj client.Object) []string { + rollback := obj.(*deployv1alpha1.Rollback) + return []string{rollback.Spec.ToReleaseRef.Target} + }). + Build() + } + + AfterEach(func() { + cancel() + }) + + Context("Handle requests with create operation", func() { + It("should allow create requests if there aren't any in progress", func() { + rollback := newRollback("my-service-rollback-1", "my-service", false) + + fakeClient = setupFakeClientWithIndex(rollback) + webhook = NewRollbackValidateWebhook(logr.New(logr.Discard().GetSink()), scheme, fakeClient) + + newRollback := newRollback("my-service-rollback-2", "my-service", true) + + req := reqWithObj(newRollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(resp.Result.Message).To(Equal("no in-progress rollbacks found")) + }) + + It("should allow create requests if there are in-progress rollbacks from another target", func() { + otherRollback := newRollback("other-service-rollback-1", "other", true) + + fakeClient = setupFakeClientWithIndex(otherRollback) + webhook = NewRollbackValidateWebhook(logr.New(logr.Discard().GetSink()), scheme, fakeClient) + + newRollback := newRollback("my-service-rollback-1", "my-service", true) + + req := reqWithObj(newRollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeTrue()) + Expect(resp.Result.Message).To(Equal("no in-progress rollbacks found")) + }) + + It("should deny create requests if there is an in progress rollback for the target", func() { + rollback := newRollback("my-service-rollback-1", "my-service", true) + + fakeClient = setupFakeClientWithIndex(rollback) + webhook = NewRollbackValidateWebhook(logr.New(logr.Discard().GetSink()), scheme, fakeClient) + + newRollback := newRollback("my-service-rollback-2", "my-service", true) + + req := reqWithObj(newRollback) + resp := webhook.Handle(ctx, req) + Expect(resp.Allowed).To(BeFalse()) + Expect(resp.Result.Message).To(Equal("another rollback in-progress for target \"my-service\"")) + }) + }) +}) + +func newRollback( + name string, + target string, + inProgress bool, +) *deployv1alpha1.Rollback { + conditions := []metav1.Condition{ + { + Type: deployv1alpha1.RollbackConditionInProgress, + Status: func() metav1.ConditionStatus { + if inProgress { + return metav1.ConditionTrue + } + return metav1.ConditionFalse + }(), + }, + } + + return &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: target, + }, + }, + Status: deployv1alpha1.RollbackStatus{ + Conditions: conditions, + }, + } +} diff --git a/internal/webhook/vault/v1alpha1/secretsinjector_webhook.go b/internal/webhook/vault/v1alpha1/secretsinjector_webhook.go index e09e7ea11..8d8728897 100644 --- a/internal/webhook/vault/v1alpha1/secretsinjector_webhook.go +++ b/internal/webhook/vault/v1alpha1/secretsinjector_webhook.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "path" + "slices" "strings" "time" @@ -22,6 +23,8 @@ import ( const SecretsInjectorFQDN = "secrets-injector.vault.crd.gocardless.com" const EnvconsulInjectorFQDN = "envconsul-injector.vault.crd.gocardless.com" +const SecretsInstallVolume = "theatre-secrets-install" +const SafeToEvictLocalVolumesAnnotation = "cluster-autoscaler.kubernetes.io/safe-to-evict-local-volumes" var FQDNArray = []string{SecretsInjectorFQDN, EnvconsulInjectorFQDN} @@ -201,12 +204,23 @@ func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { mutatedPod := pod.DeepCopy() expirySeconds := int64(i.ServiceAccountTokenExpiry / time.Second) + if mutatedPod.Annotations == nil { + mutatedPod.Annotations = map[string]string{} + } + if existing, ok := mutatedPod.Annotations[SafeToEvictLocalVolumesAnnotation]; ok { + if !slices.Contains(strings.Split(existing, ","), SecretsInstallVolume) { + mutatedPod.Annotations[SafeToEvictLocalVolumesAnnotation] = existing + "," + SecretsInstallVolume + } + } else { + mutatedPod.Annotations[SafeToEvictLocalVolumesAnnotation] = SecretsInstallVolume + } + mutatedPod.Spec.InitContainers = append(mutatedPod.Spec.InitContainers, i.buildInitContainer()) mutatedPod.Spec.Volumes = append( mutatedPod.Spec.Volumes, // Installation directory for theatre binaries, used as a scratch installation path corev1.Volume{ - Name: "theatre-secrets-install", + Name: SecretsInstallVolume, VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, @@ -299,7 +313,7 @@ func (i podInjector) buildInitContainer() corev1.Container { Command: []string{"theatre-secrets", "install", "--path", i.InstallPath}, VolumeMounts: []corev1.VolumeMount{ { - Name: "theatre-secrets-install", + Name: SecretsInstallVolume, MountPath: i.InstallPath, ReadOnly: false, }, @@ -351,7 +365,7 @@ func (i podInjector) configureContainer(reference corev1.Container, containerCon // Mount the binaries from our installation, ensuring we can run the command in this // container corev1.VolumeMount{ - Name: "theatre-secrets-install", + Name: SecretsInstallVolume, MountPath: i.InstallPath, ReadOnly: true, }, diff --git a/internal/webhook/vault/v1alpha1/secretsinjector_webhook_test.go b/internal/webhook/vault/v1alpha1/secretsinjector_webhook_test.go index 336f36085..bc8fc8032 100644 --- a/internal/webhook/vault/v1alpha1/secretsinjector_webhook_test.go +++ b/internal/webhook/vault/v1alpha1/secretsinjector_webhook_test.go @@ -9,7 +9,7 @@ import ( "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gstruct" ) @@ -170,7 +170,7 @@ var _ = Describe("PodInjector", func() { "Name": Equal("app"), "VolumeMounts": ContainElement( corev1.VolumeMount{ - Name: "theatre-secrets-install", + Name: SecretsInstallVolume, MountPath: "/var/run/theatre-secrets", ReadOnly: true, }, @@ -181,6 +181,39 @@ var _ = Describe("PodInjector", func() { ) }) + It("Sets safe-to-evict-local-volumes annotation for theatre-secrets-install", func() { + Expect(pod.Annotations).To(HaveKeyWithValue( + SafeToEvictLocalVolumesAnnotation, + SecretsInstallVolume, + )) + }) + + Context("With existing safe-to-evict-local-volumes annotation", func() { + BeforeEach(func() { + fixture.Annotations[SafeToEvictLocalVolumesAnnotation] = "existing-volume" + }) + + It("Appends theatre-secrets-install to existing annotation", func() { + Expect(pod.Annotations).To(HaveKeyWithValue( + SafeToEvictLocalVolumesAnnotation, + "existing-volume,theatre-secrets-install", + )) + }) + }) + + Context("With theatre-secrets-install already in safe-to-evict-local-volumes annotation", func() { + BeforeEach(func() { + fixture.Annotations[SafeToEvictLocalVolumesAnnotation] = "theatre-secrets-install,other-volume" + }) + + It("Does not duplicate theatre-secrets-install in annotation", func() { + Expect(pod.Annotations).To(HaveKeyWithValue( + SafeToEvictLocalVolumesAnnotation, + "theatre-secrets-install,other-volume", + )) + }) + }) + It("Adds service account volumeMount", func() { Expect(pod.Spec.Containers).To( ContainElement( diff --git a/internal/webhook/vault/v1alpha1/suite_test.go b/internal/webhook/vault/v1alpha1/suite_test.go index eebe070cf..8553130ca 100644 --- a/internal/webhook/vault/v1alpha1/suite_test.go +++ b/internal/webhook/vault/v1alpha1/suite_test.go @@ -3,7 +3,7 @@ package v1alpha1 import ( "testing" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/internal/webhook/workloads/v1alpha1/console_authorisation_webhook_test.go b/internal/webhook/workloads/v1alpha1/console_authorisation_webhook_test.go index 178ea6a91..8aef3998f 100644 --- a/internal/webhook/workloads/v1alpha1/console_authorisation_webhook_test.go +++ b/internal/webhook/workloads/v1alpha1/console_authorisation_webhook_test.go @@ -4,7 +4,7 @@ import ( "net/http" "os" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" diff --git a/internal/webhook/workloads/v1alpha1/suite_test.go b/internal/webhook/workloads/v1alpha1/suite_test.go index 102fb7129..dd24ff85a 100644 --- a/internal/webhook/workloads/v1alpha1/suite_test.go +++ b/internal/webhook/workloads/v1alpha1/suite_test.go @@ -3,7 +3,7 @@ package v1alpha1 import ( "testing" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) diff --git a/pkg/cicd/README.md b/pkg/cicd/README.md new file mode 100644 index 000000000..e5369c8ba --- /dev/null +++ b/pkg/cicd/README.md @@ -0,0 +1,41 @@ +# pkg/cicd + +Core interfaces and types for CI/CD integrations in Theatre's rollback system. + +## Overview + +This package defines the `Deployer` interface that CI/CD providers implement to trigger and monitor deployments during rollback operations. It provides a provider-agnostic abstraction for deployment orchestration. + +## Key Components + +- **`Deployer` interface**: Core interface for CI/CD integrations with methods to trigger deployments and check their status +- **`DeploymentRequest`**: Request structure containing rollback metadata and provider-specific options +- **`DeploymentResult`**: Response structure with deployment ID, status, and URL +- **`DeploymentStatus`**: Enumeration of deployment states (Pending, InProgress, Succeeded, Failed, Unknown) +- **`DeployerError`**: Structured error type with retry semantics +- **`NoopDeployer`**: Test implementation that always succeeds +- **`ParseDeploymentOptions`**: Utility for parsing JSONPath expressions in deployment options + +## Usage + +Implement the `Deployer` interface to integrate a new CI/CD provider: + +```go +type MyDeployer struct {} + +func (d *MyDeployer) TriggerDeployment(ctx context.Context, req cicd.DeploymentRequest) (*cicd.DeploymentResult, error) { + // Trigger deployment in your CI/CD system +} + +func (d *MyDeployer) GetDeploymentStatus(ctx context.Context, deploymentID string) (*cicd.DeploymentResult, error) { + // Poll deployment status +} + +func (d *MyDeployer) Name() string { + return "my-provider" +} +``` + +## Implementations + +- [`github`](./github/README.md): GitHub Deployments API integration diff --git a/pkg/cicd/deployer.go b/pkg/cicd/deployer.go new file mode 100644 index 000000000..b14335761 --- /dev/null +++ b/pkg/cicd/deployer.go @@ -0,0 +1,192 @@ +package cicd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/jsonpath" +) + +// DeploymentRequest represents a request to trigger a deployment via a CICD system. +type DeploymentRequest struct { + // Rollback is the Rollback resource being actioned + Rollback *deployv1alpha1.Rollback + + // ToRelease is the Release being rolled back to + ToRelease *deployv1alpha1.Release + + // Options contains provider-specific options to include in the deployment + // payload + Options map[string]interface{} +} + +// DeploymentStatus represents the status of a deployment in the CICD system. +type DeploymentStatus string + +const ( + // DeploymentStatusPending indicates the deployment has been queued + DeploymentStatusPending DeploymentStatus = "Pending" + + // DeploymentStatusInProgress indicates the deployment is running + DeploymentStatusInProgress DeploymentStatus = "InProgress" + + // DeploymentStatusSucceeded indicates the deployment completed successfully + DeploymentStatusSucceeded DeploymentStatus = "Succeeded" + + // DeploymentStatusFailed indicates the deployment failed + DeploymentStatusFailed DeploymentStatus = "Failed" + + // DeploymentStatusUnknown indicates the status could not be determined + DeploymentStatusUnknown DeploymentStatus = "Unknown" +) + +// DeploymentResult represents the response from a CICD system, used both +// when triggering a deployment and when polling for status. +type DeploymentResult struct { + // ID is a unique identifier for the deployment in the CICD system + ID string + + // Status is the current status of the deployment + Status DeploymentStatus + + // Message is a human-readable status message + Message string + + // URL is a link to the deployment job/pipeline in the CICD system's UI + URL string +} + +// Deployer is the interface that CICD providers must implement to integrate +// with the rollback controller. +type Deployer interface { + // TriggerDeployment initiates a deployment in the CICD system. + // Returns a DeploymentResult containing the deployment ID and initial status. + // The deployment ID can be used to check status via GetDeploymentStatus. + TriggerDeployment(ctx context.Context, req DeploymentRequest) (*DeploymentResult, error) + + // GetDeploymentStatus retrieves the current status of a deployment. + // The deploymentID should be the ID returned from TriggerDeployment. + GetDeploymentStatus(ctx context.Context, deploymentID string) (*DeploymentResult, error) + + // Name returns a human-readable name for this deployer (e.g., "github"). + // Used for logging and metrics. + Name() string +} + +// DeployerError represents an error from a CICD deployer with additional context. +type DeployerError struct { + // Deployer is the name of the deployer that produced the error + Deployer string + + // Operation is the operation that failed (e.g., "TriggerDeployment", "GetDeploymentStatus") + Operation string + + // Retryable indicates whether the operation can be retried + Retryable bool + + // Err is the underlying error + Err error +} + +func (e *DeployerError) Error() string { + return fmt.Sprintf("%s: %s failed: %v", e.Deployer, e.Operation, e.Err) +} + +func (e *DeployerError) Unwrap() error { + return e.Err +} + +// NewDeployerError creates a new DeployerError. +func NewDeployerError(deployer, operation string, retryable bool, err error) *DeployerError { + return &DeployerError{ + Deployer: deployer, + Operation: operation, + Retryable: retryable, + Err: err, + } +} + +// NoopDeployer is a Deployer implementation that does nothing. +// Useful for testing or when no CICD integration is configured. +type NoopDeployer struct{} + +var _ Deployer = &NoopDeployer{} + +func (n *NoopDeployer) TriggerDeployment(ctx context.Context, req DeploymentRequest) (*DeploymentResult, error) { + return &DeploymentResult{ + ID: fmt.Sprintf("noop-%s-%s", req.Rollback.Namespace, req.Rollback.Name), + Status: DeploymentStatusSucceeded, + Message: "noop deployer always succeeds", + }, nil +} + +func (n *NoopDeployer) GetDeploymentStatus(ctx context.Context, deploymentID string) (*DeploymentResult, error) { + return &DeploymentResult{ + ID: deploymentID, + Status: DeploymentStatusSucceeded, + Message: "noop deployer always succeeds", + }, nil +} + +func (n *NoopDeployer) Name() string { + return "noop" +} + +// ParseDeploymentOptions parses deployment options (currently Rollback spec deploymentOptions) +// using jsonpath when values are jsonpath expressions. Non-jsonpath values are returned as +// their native JSON types based on the RawExtension contents. +// E.g. "revision": "{.config.revisions[?(@.name==\"infrastructure\")].id}" -> "abc123" (string) +// +// "skip_queue": true -> true (bool) +func ParseDeploymentOptions(options map[string]apiextv1.JSON, object runtime.Object) (map[string]interface{}, error) { + result := make(map[string]interface{}, len(options)) + + for k, raw := range options { + if len(raw.Raw) == 0 { + continue + } + + var decoded interface{} + if err := json.Unmarshal(raw.Raw, &decoded); err != nil { + continue + } + + // If it's not a string, use the decoded value directly (e.g., bool, number, object) + valueStr, ok := decoded.(string) + if !ok { + result[k] = decoded + continue + } + + // Try to parse as JSONPath + parser := jsonpath.New(fmt.Sprintf("rollback_deployment_options_%s", k)) + parser.AllowMissingKeys(true) + + // If the value is not a valid jsonpath expression, treat it as a plain string value. + if err := parser.Parse(valueStr); err != nil { + result[k] = valueStr + continue + } + + buf := new(bytes.Buffer) + if err := parser.Execute(buf, object); err != nil { + // On execution error, fall back to the original string value. + result[k] = valueStr + continue + } + + out := buf.String() + if err := json.Unmarshal([]byte(out), &decoded); err == nil { + result[k] = decoded + } else { + result[k] = out + } + } + + return result, nil +} diff --git a/pkg/cicd/deployer_test.go b/pkg/cicd/deployer_test.go new file mode 100644 index 000000000..1e5dba62f --- /dev/null +++ b/pkg/cicd/deployer_test.go @@ -0,0 +1,77 @@ +package cicd + +import ( + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Deployer", func() { + It("should skip non-jsonpath values", func() { + options := map[string]apiextv1.JSON{ + "skip_queue": {Raw: []byte("true")}, + } + release := deployv1alpha1.Release{} + + parsed, err := ParseDeploymentOptions(options, &release) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed).To(Equal(map[string]interface{}{ + "skip_queue": true, + })) + }) + + It("should parse jsonpath values", func() { + options := map[string]apiextv1.JSON{ + "revision": {Raw: []byte("\"{.config.revisions[?(@.name==\\\"infrastructure\\\")].id}\"")}, + "name": {Raw: []byte("\"{.metadata.name}\"")}, + } + release := deployv1alpha1.Release{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-release", + }, + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + Revisions: []deployv1alpha1.Revision{ + { + Name: "infrastructure", + ID: "abc123", + }, + }, + }, + } + + parsed, err := ParseDeploymentOptions(options, &release) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed).To(Equal(map[string]interface{}{ + "revision": "abc123", + "name": "test-release", + })) + }) + + It("should parse any empty values in the object as empty", func() { + options := map[string]apiextv1.JSON{ + "name": {Raw: []byte("\"{.metadata.name}\"")}, + } + release := deployv1alpha1.Release{} + + parsed, err := ParseDeploymentOptions(options, &release) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed).To(Equal(map[string]interface{}{ + "name": "", + })) + }) + + It("should ignore invalid deployment options", func() { + options := map[string]apiextv1.JSON{ + "revision": {Raw: []byte("\"{.config.revisions[?(@.name==\\\"infrastructure\\\")].\"")}, + } + release := deployv1alpha1.Release{} + + parsed, err := ParseDeploymentOptions(options, &release) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed).To(Equal(map[string]interface{}{ + "revision": "{.config.revisions[?(@.name==\"infrastructure\")].", + })) + }) +}) diff --git a/pkg/cicd/github/README.md b/pkg/cicd/github/README.md new file mode 100644 index 000000000..bba095ad7 --- /dev/null +++ b/pkg/cicd/github/README.md @@ -0,0 +1,53 @@ +# pkg/cicd/github + +GitHub Deployments API implementation of the Theatre CI/CD deployer interface. + +## Overview + +This package implements `cicd.Deployer` using the GitHub Deployments API. It creates GitHub deployment events that can be consumed by any CI/CD system watching for them (e.g., GitHub Actions, external deployment tools). + +## How It Works + +1. **Trigger**: Creates a GitHub deployment event on a specified repository/revision +2. **Status**: Polls GitHub deployment statuses to track progress +3. **Metadata**: Includes rollback context (target, creator, reason) in the deployment payload + +## Configuration + +The deployer requires: +- **`deployment_revision_name`** (required): Name of the revision in the release config where the deployment should be created +- **`environment`** (optional): GitHub environment name for the deployment + +Additional options in `DeploymentRequest.Options` are merged into the deployment payload. + +## Revision Requirements + +The target release must contain a revision with: +- `Type: "github"` +- `Source: "owner/repo"` format +- `ID`: Git ref (commit SHA, branch, or tag) + +## Deployment Payload + +The GitHub deployment payload includes: + +```json +{ + "version": 3, + "target": "production", + "creator": "user@example.com", + "is_rollback": true, + "rollback_from": "release-v2.0", + "rollback_to": "release-v1.9", + "reason": "Critical bug in v2.0", + // ... additional user options +} +``` + +## Status Mapping + +GitHub deployment states are mapped to Theatre deployment statuses: +- `success` → `Succeeded` +- `failure`, `error` → `Failed` +- `pending`, `queued` → `Pending` +- `in_progress` → `InProgress` diff --git a/pkg/cicd/github/deployer.go b/pkg/cicd/github/deployer.go new file mode 100644 index 000000000..00bfff1f9 --- /dev/null +++ b/pkg/cicd/github/deployer.go @@ -0,0 +1,281 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/cicd" + "github.com/google/go-github/v34/github" +) + +const ( + DeploymentRevisionNameKey = "deployment_revision_name" +) + +// Deployer implements cicd.Deployer using the GitHub Deployments API. +// This creates GitHub deployment events that can be consumed by any CICD +// system that watches for them. +// +// The deployer extracts owner/repo from the target release's revision with +// Type="github" and Source in "owner/repo" format. Environment is optionally +// taken from Options["environment"]. +type Deployer struct { + client *github.Client + logger logr.Logger +} + +// Ensure Deployer implements the interface. +var _ cicd.Deployer = &Deployer{} + +// NewDeployer creates a new GitHub Deployments deployer. +func NewDeployer(client *github.Client, logger logr.Logger) *Deployer { + return &Deployer{ + client: client, + logger: logger.WithValues("deployer", "github"), + } +} + +func (d *Deployer) Name() string { + return "github" +} + +// TriggerDeployment creates a GitHub deployment event. GitHub deployments require +// the "deployment_revision_name" field to be set in the req.Options map. E.g. +// "deployment_revision_name": "application". This is the name of the revision on which +// the GitHub deployment will be created. +func (d *Deployer) TriggerDeployment(ctx context.Context, req cicd.DeploymentRequest) (*cicd.DeploymentResult, error) { + revisionName, ok := req.Options[DeploymentRevisionNameKey].(string) + if !ok || revisionName == "" { + return nil, cicd.NewDeployerError(d.Name(), "TriggerDeployment", false, + fmt.Errorf("missing %s option", DeploymentRevisionNameKey)) + } + + // Extract owner/repo from the github revision in the target release + // rename to deployment revision + deploymentRevision, err := d.findGitHubRevision(req.ToRelease.ReleaseConfig.Revisions, revisionName) + if err != nil { + return nil, cicd.NewDeployerError(d.Name(), "TriggerDeployment", false, err) + } + + owner, repo, err := d.parseOwnerRepo(deploymentRevision.Source) + if err != nil { + return nil, cicd.NewDeployerError(d.Name(), "TriggerDeployment", false, err) + } + + deploymentRevisionId := deploymentRevision.ID + if deploymentRevisionId == "" { + return nil, cicd.NewDeployerError(d.Name(), "TriggerDeployment", false, + fmt.Errorf("github revision has no ID")) + } + + // Build the deployment payload with rollback metadata and user options + payload := d.buildPayload(req) + + description := fmt.Sprintf("Rollback to release %s. Reason: %s", req.Rollback.Spec.ToReleaseRef.Name, req.Rollback.Spec.Reason) + if len(description) > 140 { + description = description[:137] + "..." + } + + deploymentReq := &github.DeploymentRequest{ + Ref: github.String(deploymentRevisionId), + Description: github.String(description), + AutoMerge: github.Bool(false), + RequiredContexts: &[]string{}, // Bypass status checks for rollbacks + Payload: payload, + } + + // Set environment from Options if provided + if env, ok := req.Options["environment"].(string); ok && env != "" { + deploymentReq.Environment = github.String(env) + } + + d.logger.Info("creating GitHub deployment", + "owner", owner, + "repo", repo, + "ref", deploymentRevisionId, + "rollback", req.Rollback.Name, + ) + + deployment, resp, err := d.client.Repositories.CreateDeployment( + ctx, + owner, + repo, + deploymentReq, + ) + if err != nil { + // Check if this is a retryable error (rate limiting, server errors) + retryable := resp != nil && (resp.StatusCode >= 500 || resp.StatusCode == 429) + return nil, cicd.NewDeployerError(d.Name(), "TriggerDeployment", retryable, + fmt.Errorf("failed to create deployment: %w", err)) + } + + deploymentID := fmt.Sprintf("https://github.com/%s/%s/deployments/%d", + owner, repo, deployment.GetID()) + deploymentURL := fmt.Sprintf("https://github.com/%s/%s/deployments", owner, repo) + + return &cicd.DeploymentResult{ + // Here we use the deployment URL as the ID since the deployments API additionally + // requires the owner and repo when querying status, which are encoded in the URL. + ID: deploymentID, + URL: deploymentURL, + Status: cicd.DeploymentStatusPending, + Message: "Deployment created", + }, nil +} + +// GetDeploymentStatus retrieves the current status of a GitHub deployment. +// The deploymentID should be the URL returned from TriggerDeployment +// (e.g., "https://github.com/owner/repo/deployments/123"). +func (d *Deployer) GetDeploymentStatus(ctx context.Context, deploymentID string) (*cicd.DeploymentResult, error) { + // Parse owner, repo, and deployment ID from the URL + owner, repo, id, err := d.parseDeploymentURL(deploymentID) + if err != nil { + return nil, cicd.NewDeployerError(d.Name(), "GetDeploymentStatus", false, err) + } + + statuses, resp, err := d.client.Repositories.ListDeploymentStatuses( + ctx, + owner, + repo, + id, + &github.ListOptions{PerPage: 1}, // Only need the latest status + ) + if err != nil { + retryable := resp != nil && (resp.StatusCode >= 500 || resp.StatusCode == 429) + return nil, cicd.NewDeployerError(d.Name(), "GetDeploymentStatus", retryable, + fmt.Errorf("failed to get deployment statuses: %w", err)) + } + + if len(statuses) == 0 { + return &cicd.DeploymentResult{ + ID: deploymentID, + Status: cicd.DeploymentStatusPending, + }, nil + } + + latestStatus := statuses[0] + status, message := d.mapGitHubStatus(latestStatus) + + return &cicd.DeploymentResult{ + ID: deploymentID, + Status: status, + Message: message, + URL: latestStatus.GetTargetURL(), + }, nil +} + +// buildPayload constructs the deployment payload from rollback metadata +// and user-provided options. +func (d *Deployer) buildPayload(req cicd.DeploymentRequest) map[string]interface{} { + payload := map[string]interface{}{ + // Standard rollback fields + "target": req.ToRelease.ReleaseConfig.TargetName, + "creator": req.Rollback.Spec.InitiatedBy.Principal, + "is_rollback": true, + "rollback_from": req.Rollback.Status.FromReleaseRef, + "rollback_to": req.Rollback.Spec.ToReleaseRef, + "reason": req.Rollback.Spec.Reason, + } + + // Merge user-provided options + for key, value := range req.Options { + payload[key] = value + } + + return payload +} + +// findGitHubRevision finds the revision with Type="github". +// If revisionName is specified, it returns the github revision with that name. +// If no revisionName is specified and there is only one github revision it returns +// it, otherwise if multiple github revisions exist it returns an error. +func (d *Deployer) findGitHubRevision(revisions []deployv1alpha1.Revision, revisionName string) (*deployv1alpha1.Revision, error) { + // If revisionName option is specified, find the matching revision + if revisionName != "" { + for i := range revisions { + if revisions[i].Type == "github" && revisions[i].Name == revisionName { + return &revisions[i], nil + } + } + return nil, fmt.Errorf("no github revision found with name %q", revisionName) + } + + // No revisionName specified - find all github revisions + var ghRevisions []*deployv1alpha1.Revision + for i := range revisions { + if revisions[i].Type == "github" { + ghRevisions = append(ghRevisions, &revisions[i]) + } + } + + if len(ghRevisions) == 0 { + return nil, fmt.Errorf("no revision with type 'github' found in target release") + } + + if len(ghRevisions) > 1 { + var sources []string + for _, r := range ghRevisions { + sources = append(sources, r.Name) + } + return nil, fmt.Errorf("multiple github revisions found (%v); specify a 'revisionName' to select one", sources) + } + + return ghRevisions[0], nil +} + +// parseOwnerRepo parses "owner/repo" format into separate components. +func (d *Deployer) parseOwnerRepo(source string) (owner, repo string, err error) { + parts := strings.Split(source, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid github repository format %q, expected 'owner/repo'", source) + } + return parts[0], parts[1], nil +} + +// parseDeploymentURL parses a GitHub deployment URL into owner, repo, and deployment ID. +// Expected format: "https://github.com/owner/repo/deployments/123" +func (d *Deployer) parseDeploymentURL(url string) (owner, repo string, id int64, err error) { + // Remove https://github.com/ prefix + trimmed := strings.TrimPrefix(url, "https://github.com/") + if trimmed == url { + return "", "", 0, fmt.Errorf("invalid deployment URL format: %s", url) + } + + // Expected: owner/repo/deployments/123 + parts := strings.Split(trimmed, "/") + if len(parts) != 4 || parts[2] != "deployments" { + return "", "", 0, fmt.Errorf("invalid deployment URL format: %s", url) + } + + owner = parts[0] + repo = parts[1] + + _, err = fmt.Sscanf(parts[3], "%d", &id) + if err != nil { + return "", "", 0, fmt.Errorf("invalid deployment ID in URL: %s", url) + } + + return owner, repo, id, nil +} + +// mapGitHubStatus converts GitHub deployment status to our generic status. +func (d *Deployer) mapGitHubStatus(status *github.DeploymentStatus) (cicd.DeploymentStatus, string) { + state := status.GetState() + description := status.GetDescription() + + switch state { + case "success": + return cicd.DeploymentStatusSucceeded, description + case "failure", "error": + return cicd.DeploymentStatusFailed, description + case "pending", "queued": + return cicd.DeploymentStatusPending, description + case "in_progress": + return cicd.DeploymentStatusInProgress, description + default: + return cicd.DeploymentStatusUnknown, fmt.Sprintf("unknown state: %s - %s", state, description) + } +} diff --git a/pkg/cicd/github/deployer_test.go b/pkg/cicd/github/deployer_test.go new file mode 100644 index 000000000..305742416 --- /dev/null +++ b/pkg/cicd/github/deployer_test.go @@ -0,0 +1,567 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/go-logr/logr" + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "github.com/gocardless/theatre/v5/pkg/cicd" + "github.com/google/go-github/v34/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gock "gopkg.in/h2non/gock.v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("GitHub Deployer", func() { + var ( + deployer *Deployer + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + + httpClient := &http.Client{Transport: http.DefaultTransport} + gock.InterceptClient(httpClient) + gock.DisableNetworking() + + ghClient := github.NewClient(httpClient) + deployer = NewDeployer(ghClient, logr.Discard()) + }) + + AfterEach(func() { + gock.Off() + }) + + Describe("Name", func() { + It("returns 'github'", func() { + Expect(deployer.Name()).To(Equal("github")) + }) + }) + + Describe("TriggerDeployment", func() { + var ( + req cicd.DeploymentRequest + result *cicd.DeploymentResult + err error + ) + + BeforeEach(func() { + req = cicd.DeploymentRequest{ + Rollback: &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollback", + Namespace: "default", + }, + Spec: deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: "my-service", + Name: "my-service-v1", + }, + Reason: "High error rate", + InitiatedBy: deployv1alpha1.RollbackInitiator{ + Principal: "alice@example.com", + Type: "user", + }, + }, + Status: deployv1alpha1.RollbackStatus{ + FromReleaseRef: &deployv1alpha1.ReleaseReference{ + Target: "my-service", + Name: "my-service-v2", + }, + }, + }, + ToRelease: &deployv1alpha1.Release{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service-v1", + Namespace: "default", + }, + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + TargetName: "my-service", + Revisions: []deployv1alpha1.Revision{ + { + Name: "commit", + Type: "github", + Source: "gocardless/my-service", + ID: "abc123def", + }, + }, + }, + }, + Options: map[string]interface{}{ + DeploymentRevisionNameKey: "commit", + }, + } + }) + + Context("with a valid request", func() { + var ( + createDeploymentMatcher func(*http.Request, *gock.Request) (bool, error) + createDeploymentResp map[string]interface{} + ) + + BeforeEach(func() { + createDeploymentMatcher = nil + createDeploymentResp = map[string]interface{}{ + "id": 12345, + "url": "https://api.github.com/repos/gocardless/my-service/deployments/12345", + } + }) + + JustBeforeEach(func() { + mock := gock.New("https://api.github.com"). + Post("/repos/gocardless/my-service/deployments") + + if createDeploymentMatcher != nil { + mock = mock.MatchType("json").AddMatcher(createDeploymentMatcher) + } + + mock.Reply(201).JSON(createDeploymentResp) + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("succeeds", func() { + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns the deployment ID", func() { + Expect(result.ID).To(Equal("https://github.com/gocardless/my-service/deployments/12345")) + }) + + It("returns the deployment URL", func() { + Expect(result.URL).To(Equal("https://github.com/gocardless/my-service/deployments")) + }) + + It("returns pending status", func() { + Expect(result.Status).To(Equal(cicd.DeploymentStatusPending)) + }) + + Context("with deployment options", func() { + var ( + createDeploymentBody map[string]interface{} + ) + + BeforeEach(func() { + req.Options = map[string]interface{}{ + DeploymentRevisionNameKey: "commit", + "environment": "staging", + "key_1": "value_1", + "key_2": "value_2", + } + + createDeploymentMatcher = func(req *http.Request, _ *gock.Request) (bool, error) { + b, err := io.ReadAll(req.Body) + if err != nil { + return false, err + } + req.Body = io.NopCloser(bytes.NewBuffer(b)) + + var body map[string]interface{} + if err := json.Unmarshal(b, &body); err != nil { + return false, err + } + createDeploymentBody = body + + return true, nil + } + }) + + It("succeeds", func() { + Expect(err).NotTo(HaveOccurred()) + Expect(result.ID).To(Equal("https://github.com/gocardless/my-service/deployments/12345")) + }) + + It("sends the environment in the deployment request", func() { + Expect(createDeploymentBody).NotTo(BeNil()) + Expect(createDeploymentBody["environment"]).To(Equal("staging")) + }) + + It("includes additional options in the payload", func() { + Expect(createDeploymentBody).NotTo(BeNil()) + payload, ok := createDeploymentBody["payload"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(payload["key_1"]).To(Equal("value_1")) + Expect(payload["key_2"]).To(Equal("value_2")) + }) + }) + }) + + Context("with no deployment_revision_name option set", func() { + BeforeEach(func() { + delete(req.Options, DeploymentRevisionNameKey) + req.ToRelease.ReleaseConfig.Revisions = []deployv1alpha1.Revision{ + { + Name: "commit", + Type: "github", + Source: "gocardless/my-service", + ID: "abc123def", + }, + } + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing %s option", DeploymentRevisionNameKey)) + }) + + It("is not retryable", func() { + deployerErr, ok := err.(*cicd.DeployerError) + Expect(ok).To(BeTrue()) + Expect(deployerErr.Retryable).To(BeFalse()) + }) + }) + + Context("with no github revision", func() { + BeforeEach(func() { + req.Options = map[string]interface{}{ + DeploymentRevisionNameKey: "commit", + } + req.ToRelease.ReleaseConfig.Revisions = []deployv1alpha1.Revision{ + { + Name: "image", + Type: "docker", + Source: "gcr.io/my-project/my-service", + ID: "sha256:abc123", + }, + } + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no github revision found with name \"commit\"")) + }) + + It("is not retryable", func() { + deployerErr, ok := err.(*cicd.DeployerError) + Expect(ok).To(BeTrue()) + Expect(deployerErr.Retryable).To(BeFalse()) + }) + }) + + Context("with invalid github repository format", func() { + BeforeEach(func() { + req.ToRelease.ReleaseConfig.Revisions[0].Source = "invalid-source" + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid github repository format")) + }) + }) + + Context("with empty github revision ID", func() { + BeforeEach(func() { + req.ToRelease.ReleaseConfig.Revisions[0].ID = "" + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("github revision has no ID")) + }) + }) + + Context("when GitHub API returns 500", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Post("/repos/gocardless/my-service/deployments"). + Reply(500). + JSON(map[string]string{"message": "Internal Server Error"}) + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + }) + + It("is retryable", func() { + deployerErr, ok := err.(*cicd.DeployerError) + Expect(ok).To(BeTrue()) + Expect(deployerErr.Retryable).To(BeTrue()) + }) + }) + + Context("when GitHub API returns 429 (rate limited)", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Post("/repos/gocardless/my-service/deployments"). + Reply(429). + JSON(map[string]string{"message": "Rate limit exceeded"}) + }) + + JustBeforeEach(func() { + result, err = deployer.TriggerDeployment(ctx, req) + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + }) + + It("is retryable", func() { + deployerErr, ok := err.(*cicd.DeployerError) + Expect(ok).To(BeTrue()) + Expect(deployerErr.Retryable).To(BeTrue()) + }) + }) + }) + + Describe("GetDeploymentStatus", func() { + var ( + deploymentURL string + result *cicd.DeploymentResult + err error + ) + + BeforeEach(func() { + deploymentURL = "https://github.com/gocardless/my-service/deployments/12345" + }) + + JustBeforeEach(func() { + result, err = deployer.GetDeploymentStatus(ctx, deploymentURL) + }) + + Context("with a successful deployment", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Get("/repos/gocardless/my-service/deployments/12345/statuses"). + Reply(200). + JSON([]map[string]interface{}{ + { + "id": 1, + "state": "success", + "description": "Deployment finished successfully", + "target_url": "https://ci.example.com/jobs/123", + }, + }) + }) + + It("succeeds", func() { + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns succeeded status", func() { + Expect(result.Status).To(Equal(cicd.DeploymentStatusSucceeded)) + }) + + It("returns the description as message", func() { + Expect(result.Message).To(Equal("Deployment finished successfully")) + }) + + It("returns the target URL", func() { + Expect(result.URL).To(Equal("https://ci.example.com/jobs/123")) + }) + }) + + Context("with a failed deployment", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Get("/repos/gocardless/my-service/deployments/12345/statuses"). + Reply(200). + JSON([]map[string]interface{}{ + { + "id": 1, + "state": "failure", + "description": "Deployment failed: container crashed", + }, + }) + }) + + It("returns failed status", func() { + Expect(err).NotTo(HaveOccurred()) + Expect(result.Status).To(Equal(cicd.DeploymentStatusFailed)) + }) + }) + + Context("with an in-progress deployment", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Get("/repos/gocardless/my-service/deployments/12345/statuses"). + Reply(200). + JSON([]map[string]interface{}{ + { + "id": 1, + "state": "in_progress", + "description": "Deploying...", + }, + }) + }) + + It("returns in-progress status", func() { + Expect(err).NotTo(HaveOccurred()) + Expect(result.Status).To(Equal(cicd.DeploymentStatusInProgress)) + }) + }) + + Context("with no statuses yet", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Get("/repos/gocardless/my-service/deployments/12345/statuses"). + Reply(200). + JSON([]map[string]interface{}{}) + }) + + It("returns pending status", func() { + Expect(err).NotTo(HaveOccurred()) + Expect(result.Status).To(Equal(cicd.DeploymentStatusPending)) + }) + }) + + Context("with invalid deployment URL", func() { + BeforeEach(func() { + deploymentURL = "invalid-url" + }) + + It("returns an error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid deployment URL format")) + }) + }) + + Context("when GitHub API returns 500", func() { + BeforeEach(func() { + gock.New("https://api.github.com"). + Get("/repos/gocardless/my-service/deployments/12345/statuses"). + Reply(500). + JSON(map[string]string{"message": "Internal Server Error"}) + }) + + It("returns a retryable error", func() { + Expect(err).To(HaveOccurred()) + deployerErr, ok := err.(*cicd.DeployerError) + Expect(ok).To(BeTrue()) + Expect(deployerErr.Retryable).To(BeTrue()) + }) + }) + }) + + Describe("parseOwnerRepo", func() { + It("parses valid owner/repo format", func() { + owner, repo, err := deployer.parseOwnerRepo("gocardless/theatre") + Expect(err).NotTo(HaveOccurred()) + Expect(owner).To(Equal("gocardless")) + Expect(repo).To(Equal("theatre")) + }) + + It("returns error for invalid format", func() { + _, _, err := deployer.parseOwnerRepo("invalid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid github repository format")) + }) + + It("returns error for too many parts", func() { + _, _, err := deployer.parseOwnerRepo("a/b/c") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("parseDeploymentURL", func() { + It("parses valid deployment URL", func() { + owner, repo, id, err := deployer.parseDeploymentURL("https://github.com/gocardless/theatre/deployments/12345") + Expect(err).NotTo(HaveOccurred()) + Expect(owner).To(Equal("gocardless")) + Expect(repo).To(Equal("theatre")) + Expect(id).To(Equal(int64(12345))) + }) + + It("returns error for non-github URL", func() { + _, _, _, err := deployer.parseDeploymentURL("https://gitlab.com/owner/repo/deployments/123") + Expect(err).To(HaveOccurred()) + }) + + It("returns error for missing deployments path", func() { + _, _, _, err := deployer.parseDeploymentURL("https://github.com/owner/repo/pulls/123") + Expect(err).To(HaveOccurred()) + }) + + It("returns error for non-numeric ID", func() { + _, _, _, err := deployer.parseDeploymentURL("https://github.com/owner/repo/deployments/abc") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("findGitHubRevision", func() { + It("finds single github revision without options", func() { + revisions := []deployv1alpha1.Revision{ + {Type: "docker", ID: "sha256:abc"}, + {Type: "github", Source: "gocardless/app", ID: "abc123"}, + {Type: "helm", ID: "1.0.0"}, + } + rev, err := deployer.findGitHubRevision(revisions, "") + Expect(err).NotTo(HaveOccurred()) + Expect(rev).NotTo(BeNil()) + Expect(rev.ID).To(Equal("abc123")) + }) + + It("returns error when no github revision exists", func() { + revisions := []deployv1alpha1.Revision{ + {Type: "docker", ID: "sha256:abc"}, + } + _, err := deployer.findGitHubRevision(revisions, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no revision with type 'github'")) + }) + + It("returns error for empty revisions", func() { + _, err := deployer.findGitHubRevision(nil, "") + Expect(err).To(HaveOccurred()) + }) + + It("returns error when multiple github revisions exist without revisionName option", func() { + revisions := []deployv1alpha1.Revision{ + {Type: "github", Source: "gocardless/app1", ID: "abc123"}, + {Type: "github", Source: "gocardless/app2", ID: "def456"}, + } + _, err := deployer.findGitHubRevision(revisions, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("multiple github revisions found")) + Expect(err.Error()).To(ContainSubstring("revisionName")) + }) + + It("finds matching revision when revisionName option is provided", func() { + revisions := []deployv1alpha1.Revision{ + {Type: "github", Name: "app1", Source: "gocardless/app1", ID: "abc123"}, + {Type: "github", Name: "app2", Source: "gocardless/app2", ID: "def456"}, + } + + rev, err := deployer.findGitHubRevision(revisions, "app2") + Expect(err).NotTo(HaveOccurred()) + Expect(rev.ID).To(Equal("def456")) + }) + + It("returns error when revisionName doesn't match any revision", func() { + revisions := []deployv1alpha1.Revision{ + {Type: "github", Name: "app1", Source: "gocardless/app1", ID: "abc123"}, + } + + _, err := deployer.findGitHubRevision(revisions, "other") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no github revision found with name")) + }) + }) +}) diff --git a/pkg/cicd/github/suite_test.go b/pkg/cicd/github/suite_test.go new file mode 100644 index 000000000..ce73d0465 --- /dev/null +++ b/pkg/cicd/github/suite_test.go @@ -0,0 +1,13 @@ +package github + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSuite(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "pkg/cicd/github") +} diff --git a/pkg/cicd/suite_test.go b/pkg/cicd/suite_test.go new file mode 100644 index 000000000..15197ef7e --- /dev/null +++ b/pkg/cicd/suite_test.go @@ -0,0 +1,13 @@ +package cicd + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRelease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "pkg/cicd") +} diff --git a/pkg/deploy/deploy.go b/pkg/deploy/deploy.go new file mode 100644 index 000000000..216119f85 --- /dev/null +++ b/pkg/deploy/deploy.go @@ -0,0 +1,113 @@ +package deploy + +import ( + "crypto/sha256" + "fmt" + "strings" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + "k8s.io/apimachinery/pkg/util/validation" +) + +// validateTargetName validates that the target name is within the allowed length +// and follows DNS subdomain rules, with some buffer for suffixes. +// The target name must be at most (DNS1123SubdomainMaxLength - 8) characters to allow +// for suffixes like "-18c1c1d" to be appended safely. +func validateTargetName(targetName string) error { + if len(targetName) > (validation.DNS1123SubdomainMaxLength - 8) { + return fmt.Errorf("target name too long: %d characters (max %d)", len(targetName), validation.DNS1123SubdomainMaxLength) + } + + errors := validation.IsDNS1123Subdomain(targetName) + if len(errors) > 0 { + return fmt.Errorf("invalid target name: %s", errors[0]) + } + + return nil +} + +func validateRevisionID(revisionID string) error { + if revisionID == "" { + return fmt.Errorf("revision ID cannot be empty") + } + return nil +} + +// Ensures there are no repeated revision names and revision IDs are valid +func validateRevisions(revisions []deployv1alpha1.Revision) error { + revisionNames := make(map[string]bool) + + if len(revisions) == 0 { + return fmt.Errorf("revisions cannot be empty") + } + + for _, revision := range revisions { + if revisionNames[revision.Name] { + return fmt.Errorf("duplicate revision name: %s", revision.Name) + } + revisionNames[revision.Name] = true + + if err := validateRevisionID(revision.ID); err != nil { + return err + } + } + return nil +} + +func HashString(b []byte) string { + hash := sha256.Sum256(b) + return fmt.Sprintf("%x", hash)[:7] +} + +// Generates a name for a release based on its target name and revision IDs. +// The generated name hash the format of `{targetName}-{hash}`, where hash is a +// SHA-256 hash of the appended revision Names and IDs. +func GenerateReleaseName(release deployv1alpha1.Release) (string, error) { + // Sort revision IDs to ensure consistent ordering + targetName := release.ReleaseConfig.TargetName + if err := validateTargetName(targetName); err != nil { + return "", err + } + + revisions := release.ReleaseConfig.DeepCopy().Revisions + if err := validateRevisions(revisions); err != nil { + return "", err + } + + releaseHash := HashString(release.Serialise()) + + return fmt.Sprintf("%s-%s", targetName, releaseHash), nil +} + +// GenerateAnalysisRunName generates a name for an AnalysisRun by concatenating the +// release name and template. If the result would be too long, parts are trimmed +// to 27 characters and a hash is appended. +// 27 char maximum is to ensure final name doesn't exceed 64 characters: +// -- +func GenerateAnalysisRunName(releaseName, templateName string) string { + if releaseName == "" { + releaseName = "release-name-missing" + } + if templateName == "" { + templateName = "template-name-missing" + } + + parts := []string{releaseName, templateName} + candidate := strings.Join(parts, "-") + + if len(candidate) <= 64 { + return candidate + } + + hash := HashString([]byte(releaseName + templateName)) + + for i, v := range parts { + if len(v) > 27 { + parts[i] = v[:27] + } + } + + parts = append(parts, hash) + + return strings.Join(parts, "-") +} diff --git a/pkg/deploy/deploy_suite_test.go b/pkg/deploy/deploy_suite_test.go new file mode 100644 index 000000000..81baf5cbb --- /dev/null +++ b/pkg/deploy/deploy_suite_test.go @@ -0,0 +1,13 @@ +package deploy_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeploy(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "pkg/deploy") +} diff --git a/pkg/deploy/deploy_test.go b/pkg/deploy/deploy_test.go new file mode 100644 index 000000000..f95cb2cb2 --- /dev/null +++ b/pkg/deploy/deploy_test.go @@ -0,0 +1,178 @@ +package deploy + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" +) + +var _ = Describe("Helpers", func() { + DescribeTable("validateTargetName", + func(name string, expectedErrorMatchers ...types.GomegaMatcher) { + Expect(validateTargetName(name)).To(And(expectedErrorMatchers...)) + }, + Entry("Should return error for empty target name", "", HaveOccurred()), + Entry("Should return an error if not a valid K8s name", ".1-test-target", HaveOccurred()), + Entry("Should return an error if too long", "a"+strings.Repeat("b", 245), HaveOccurred(), MatchError(ContainSubstring("target name too long"))), + Entry("Should not return an error for a valid K8s name", "test-target-123", Succeed()), + ) + + DescribeTable("validateRevision", + func(rev string, expectedErrorMatchers ...types.GomegaMatcher) { + Expect(validateRevisionID(rev)).To(And(expectedErrorMatchers...)) + }, + Entry("Should return error for empty revision", "", HaveOccurred()), + Entry("Should not return an error for a valid revision", "v1.0.0", Succeed()), + Entry("Should not return an error for a semantic version", "1.2.3-alpha.1+build.1", Succeed()), + Entry("Should not return an error for a commit hash", "e79564fbef044b63d560296cdc8e84c130175016", Succeed()), + ) + + DescribeTable("validateRevisions", + func(revs []deployv1alpha1.Revision, expectedErrorMatchers ...types.GomegaMatcher) { + Expect(validateRevisions(revs)).To(And(expectedErrorMatchers...)) + }, + Entry("Should return error for empty revisions", []deployv1alpha1.Revision{}, HaveOccurred()), + Entry("Should return error for duplicate revision names", []deployv1alpha1.Revision{ + {Name: "application", ID: "123"}, + {Name: "application", ID: "456"}, + }, HaveOccurred()), + Entry("Should not return error for valid revisions", []deployv1alpha1.Revision{ + {Name: "application", ID: "123"}, + {Name: "data-contracts", ID: "456"}, + }, Succeed()), + Entry("Should return error for empty or invalid revision ID", []deployv1alpha1.Revision{ + {Name: "application", ID: "123"}, + {Name: "data-contracts", ID: ""}, + }, HaveOccurred()), + ) + + Context("HashString", func() { + It("Should hash a string and return hex representation", func() { + result := HashString([]byte("test")) + Expect(result).To(HaveLen(7)) + Expect(result).To(MatchRegexp("^[0-9a-f]+$")) + }) + + It("Should produce consistent hashes for the same input", func() { + result1 := HashString([]byte("test")) + result2 := HashString([]byte("test")) + Expect(result1).To(Equal(result2)) + }) + + It("Should produce different hashes for different inputs", func() { + result1 := HashString([]byte("test")) + result2 := HashString([]byte("different")) + Expect(result1).NotTo(Equal(result2)) + }) + }) + + Context("generateReleaseName", func() { + var release deployv1alpha1.Release + + BeforeEach(func() { + release = deployv1alpha1.Release{ + ReleaseConfig: deployv1alpha1.ReleaseConfig{ + TargetName: "test-target", + Revisions: []deployv1alpha1.Revision{ + {Name: "application", ID: "abc123"}, + }, + }, + } + }) + + It("Should generate a release name", func() { + name, err := GenerateReleaseName(release) + Expect(err).NotTo(HaveOccurred()) + Expect(name).NotTo(BeEmpty()) + Expect(name).To(Equal("test-target-3978d50")) + }) + + It("Should generate consistent release names for the same input", func() { + releaseCopy := release.DeepCopy() + + name1, err1 := GenerateReleaseName(release) + name2, err2 := GenerateReleaseName(*releaseCopy) + + Expect(err1).NotTo(HaveOccurred()) + Expect(err2).NotTo(HaveOccurred()) + Expect(name1).To(Equal(name2)) + }) + + It("Should generate consistent release names for the same input if sorted differently", func() { + // Create two releases with the same revisions but in different orders + release.Revisions = append(release.Revisions, deployv1alpha1.Revision{Name: "database", ID: "def456"}) + + release1 := release.DeepCopy() + release2 := release.DeepCopy() + + release2.Revisions[0], release2.Revisions[1] = release2.Revisions[1], release2.Revisions[0] + + name1, err1 := GenerateReleaseName(*release1) + name2, err2 := GenerateReleaseName(*release2) + + Expect(err1).NotTo(HaveOccurred()) + Expect(err2).NotTo(HaveOccurred()) + Expect(name1).To(Equal(name2)) + }) + + It("Should error when invalid a revision is provided", func() { + release.Revisions = append(release.Revisions, deployv1alpha1.Revision{Name: "", ID: ""}) + _, err := GenerateReleaseName(release) + Expect(err).To(HaveOccurred()) + }) + + It("Should error when invalid targetName is provided", func() { + release.TargetName = "" + _, err := GenerateReleaseName(release) + Expect(err).To(HaveOccurred()) + }) + + }) + + Context("GenerateAnalysisRunName", func() { + DescribeTable("GenerateAnalysisRunName", func(releaseName, templateName, expected string) { + result := GenerateAnalysisRunName(releaseName, templateName) + Expect(len(result)).To(BeNumerically("<=", 64)) + + var releaseNameTrim string + var templateNameTrim string + + if len(releaseName) > 27 { + releaseNameTrim = releaseName[:27] + } else { + releaseNameTrim = releaseName + } + + if len(templateName) > 27 { + templateNameTrim = templateName[:27] + } else { + templateNameTrim = templateName + } + + // we should always have AT LEAST the first 27 characters of each part. + Expect(result).To(HavePrefix(releaseNameTrim)) + Expect(result).To(ContainSubstring(templateNameTrim)) + + // only check expected value if it is not empty. + // if names are truncated, we don't know what the hash will be + if expected != "" { + Expect(result).To(Equal(expected)) + } + }, + Entry("short names", "release", "template", "release-template"), + Entry("short names 2", "foo", "bar", "foo-bar"), + Entry("missing release name", "", "bar", "release-name-missing-bar"), + Entry("missing template name", "foo", "", "foo-template-name-missing"), + Entry("both names missing", "", "", "release-name-missing-template-name-missing"), + Entry("long but acceptable release name", "release-name-is-very-long-but-still-fits-in-the-maxx", "template12", "release-name-is-very-long-but-still-fits-in-the-maxx-template12"), + Entry("long but acceptable template name", "releasefoo", "template-name-is-very-long-but-still-fits-in-the-max", "releasefoo-template-name-is-very-long-but-still-fits-in-the-max"), + Entry("release name too long", "release-name-is-very-long-and-does-not-fit-in-the-max", "template12", ""), + Entry("template name too long", "releasefoo", "template-name-is-very-long-and-does-not-fit-in-the-max", ""), + Entry("both names too long", "release-name-is-very-long-too-longx", "template-name-is-very-long-too-long", ""), + ) + }) +}) diff --git a/pkg/deploy/runner/runner.go b/pkg/deploy/runner/runner.go new file mode 100644 index 000000000..5343c3429 --- /dev/null +++ b/pkg/deploy/runner/runner.go @@ -0,0 +1,256 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "sort" + + deployv1alpha1 "github.com/gocardless/theatre/v5/api/deploy/v1alpha1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Runner provides operations for managing deploy resources +type Runner struct { + client client.Client +} + +// New builds a runner from a Kubernetes rest config +func New(cfg *rest.Config) (*Runner, error) { + scheme := runtime.NewScheme() + if err := deployv1alpha1.AddToScheme(scheme); err != nil { + return nil, err + } + + cl, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + + return &Runner{client: cl}, nil +} + +type CreateRollbackOptions struct { + Name string + GenerateNamePrefix string + Namespace string + Labels map[string]string + + Reason string + ToReleaseTarget string + ToReleaseName string + + InitiatedByPrincipal string + InitiatedByType string + + DeploymentOptions map[string]apiextv1.JSON +} + +func (r *Runner) CreateRollback(ctx context.Context, opts CreateRollbackOptions) (*deployv1alpha1.Rollback, error) { + if opts.Namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if opts.Reason == "" { + return nil, fmt.Errorf("reason is required") + } + if opts.ToReleaseTarget == "" { + return nil, fmt.Errorf("toReleaseTarget is required") + } + if opts.Name != "" && opts.GenerateNamePrefix != "" { + return nil, fmt.Errorf("only one of name or generateNamePrefix may be set") + } + + spec := deployv1alpha1.RollbackSpec{ + ToReleaseRef: deployv1alpha1.ReleaseReference{ + Target: opts.ToReleaseTarget, + Name: opts.ToReleaseName, + }, + Reason: opts.Reason, + InitiatedBy: deployv1alpha1.RollbackInitiator{ + Principal: opts.InitiatedByPrincipal, + Type: opts.InitiatedByType, + }, + DeploymentOptions: opts.DeploymentOptions, + } + + rb := &deployv1alpha1.Rollback{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: opts.Namespace, + Labels: opts.Labels, + }, + Spec: spec, + } + + if opts.Name != "" { + rb.Name = opts.Name + } else { + rb.GenerateName = opts.GenerateNamePrefix + } + + if err := r.client.Create(ctx, rb); err != nil { + return nil, err + } + + return rb, nil +} + +// ListRollbacksOptions defines parameters for listing rollbacks +type ListRollbacksOptions struct { + Namespace string + Target string + Limit int +} + +// ListRollbacks fetches rollbacks for a specific target in a namespace, sorted by completion time (most recent first) +func (r *Runner) ListRollbacks(ctx context.Context, opts ListRollbacksOptions) ([]deployv1alpha1.Rollback, error) { + var rollbackList deployv1alpha1.RollbackList + if err := r.client.List(ctx, &rollbackList, client.InNamespace(opts.Namespace)); err != nil { + return nil, err + } + + rollbacks := rollbackList.Items + + if opts.Target != "" { + var filtered []deployv1alpha1.Rollback + for _, rollback := range rollbacks { + if rollback.Spec.ToReleaseRef.Target == opts.Target { + filtered = append(filtered, rollback) + } + } + rollbacks = filtered + } + + sortRollbacksByEffectiveTime(rollbacks) + + if opts.Limit > 0 && len(rollbacks) > opts.Limit { + rollbacks = rollbacks[:opts.Limit] + } + + return rollbacks, nil +} + +// Sorts rollbacks by effective time (most recent first) +func sortRollbacksByEffectiveTime(rollbacks []deployv1alpha1.Rollback) { + sort.Slice(rollbacks, func(i, j int) bool { + return rollbacks[i].GetEffectiveTime().After(rollbacks[j].GetEffectiveTime()) + }) +} + +// ListReleasesOptions defines parameters for listing releases +type ListReleasesOptions struct { + Namespace string + Target string + Limit int +} + +// ListReleases fetches releases for a specific target in a namespace, sorted by deployment end time (most recent first) +func (r *Runner) ListReleases(ctx context.Context, opts ListReleasesOptions) ([]deployv1alpha1.Release, error) { + var releaseList deployv1alpha1.ReleaseList + if err := r.client.List(ctx, &releaseList, client.InNamespace(opts.Namespace)); err != nil { + return nil, err + } + + releases := releaseList.Items + + if opts.Target != "" { + var filtered []deployv1alpha1.Release + for _, release := range releases { + if release.ReleaseConfig.TargetName == opts.Target { + filtered = append(filtered, release) + } + } + releases = filtered + } + + sortReleasesByEffectiveTime(releases) + + if opts.Limit > 0 && len(releases) > opts.Limit { + releases = releases[:opts.Limit] + } + + return releases, nil +} + +// Sorts releases by effective time (most recent first) +func sortReleasesByEffectiveTime(releases []deployv1alpha1.Release) { + sort.Slice(releases, func(i, j int) bool { + return releases[i].GetEffectiveTime().After(releases[j].GetEffectiveTime()) + }) +} + +type GetReleaseOptions struct { + Namespace string + Name string +} + +func (r *Runner) GetRelease(ctx context.Context, opts GetReleaseOptions) (*deployv1alpha1.Release, error) { + var release deployv1alpha1.Release + if err := r.client.Get(ctx, client.ObjectKey{Namespace: opts.Namespace, Name: opts.Name}, &release); err != nil { + return nil, err + } + return &release, nil +} + +func HasRevision(release deployv1alpha1.Release, revisionName string) bool { + for _, revision := range release.ReleaseConfig.Revisions { + if revision.Name == revisionName { + return true + } + } + return false +} + +var ErrAutomatedRollbackPolicyNotFound = errors.New("automated rollback policy not found") + +type GetAutomatedRollbackPolicyOptions struct { + Namespace string + TargetName string +} + +// GetAutomatedRollbackPolicyByTarget retrieves an automated rollback policy by target name +func (r *Runner) GetAutomatedRollbackPolicyByTarget(ctx context.Context, opts GetAutomatedRollbackPolicyOptions) (*deployv1alpha1.AutomatedRollbackPolicy, error) { + if opts.Namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + + if opts.TargetName == "" { + return nil, fmt.Errorf("targetName is required") + } + + var policyList deployv1alpha1.AutomatedRollbackPolicyList + + if err := r.client.List(ctx, &policyList, + client.InNamespace(opts.Namespace), + ); err != nil { + return nil, err + } + for _, policy := range policyList.Items { + if policy.Spec.TargetName == opts.TargetName { + return &policy, nil + } + } + return nil, ErrAutomatedRollbackPolicyNotFound +} + +type UpdateAutomatedRollbackPolicyOptions struct { + Namespace string + TargetName string + Enabled bool +} + +// UpdateAutomatedRollbackPolicy updates the enabled status of an automated rollback policy +func (r *Runner) UpdateAutomatedRollbackPolicy(ctx context.Context, opts UpdateAutomatedRollbackPolicyOptions) error { + policy, err := r.GetAutomatedRollbackPolicyByTarget(ctx, GetAutomatedRollbackPolicyOptions{ + Namespace: opts.Namespace, + TargetName: opts.TargetName, + }) + if err != nil { + return err + } + policy.Spec.Enabled = opts.Enabled + return r.client.Update(ctx, policy) +} diff --git a/pkg/recutil/reconcile.go b/pkg/recutil/reconcile.go index 989f6c78a..07c8dfe62 100644 --- a/pkg/recutil/reconcile.go +++ b/pkg/recutil/reconcile.go @@ -10,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -127,10 +128,11 @@ type DiffFunc func(runtime.Object, runtime.Object) Outcome type Outcome string const ( - Create Outcome = "create" - Update Outcome = "update" - None Outcome = "none" - Error Outcome = "error" + Create Outcome = "create" + Update Outcome = "update" + StatusUpdate Outcome = "status_update" + None Outcome = "none" + Error Outcome = "error" ) // ObjWithMeta describes a Kubernetes resource with a metadata field. It's a combination @@ -173,6 +175,11 @@ func CreateOrUpdate(ctx context.Context, c client.Client, existing ObjWithMeta, return Error, err } return Update, nil + case StatusUpdate: + if err := c.Status().Update(ctx, existing); err != nil { + return Error, err + } + return StatusUpdate, nil case None: return None, nil default: @@ -212,3 +219,73 @@ func DirectoryRoleBindingDiff(expectedObj runtime.Object, existingObj runtime.Ob return operation } + +// StatusDiff is a generic DiffFunc that compares the Status field of two objects +// using reflection. It returns StatusUpdate if they differ, None otherwise. +// The objects must have a Status field accessible via reflection. +// When comparing, LastTransitionTime in Status.Conditions is ignored to avoid +// unnecessary updates when only the transition time has changed. +func StatusDiff(expectedObj runtime.Object, existingObj runtime.Object) Outcome { + expectedStatus := reflect.ValueOf(expectedObj).Elem().FieldByName("Status") + existingStatus := reflect.ValueOf(existingObj).Elem().FieldByName("Status") + + if !expectedStatus.IsValid() || !existingStatus.IsValid() { + return None + } + + // Compare with normalized copies (LastTransitionTime zeroed in Conditions) + if !reflect.DeepEqual(normaliseStatus(expectedStatus), normaliseStatus(existingStatus)) { + existingStatus.Set(expectedStatus) + return StatusUpdate + } + + return None +} + +// normaliseStatus returns an interface{} copy of the status with LastTransitionTime +// zeroed in any Conditions slice, for comparison purposes. +func normaliseStatus(statusVal reflect.Value) interface{} { + statusCopy := reflect.New(statusVal.Type()).Elem() + statusCopy.Set(statusVal) + + if conditions := statusCopy.FieldByName("Conditions"); conditions.IsValid() && conditions.Kind() == reflect.Slice && conditions.Len() > 0 { + // Deep copy the conditions slice to avoid modifying the original + conditionsCopy := reflect.MakeSlice(conditions.Type(), conditions.Len(), conditions.Len()) + for i := 0; i < conditions.Len(); i++ { + conditionsCopy.Index(i).Set(conditions.Index(i)) + if lastTransitionTime := conditionsCopy.Index(i).FieldByName("LastTransitionTime"); lastTransitionTime.IsValid() && lastTransitionTime.CanSet() { + lastTransitionTime.Set(reflect.Zero(lastTransitionTime.Type())) + } + } + statusCopy.FieldByName("Conditions").Set(conditionsCopy) + } + + return statusCopy.Interface() +} + +// IsConditionStatusKnown returns true if all provided conditions are present and +// have true or false, but not unknown, status +func IsConditionStatusKnown(conditions []metav1.Condition, conditionTypes []string) bool { + for _, v := range conditionTypes { + cond := meta.FindStatusCondition(conditions, v) + if cond == nil || cond.Status == metav1.ConditionUnknown { + return false + } + } + return true +} + +func HasConditionTransitioned(oldConditions, newConditions []metav1.Condition, conditionType string) bool { + oldCond := meta.FindStatusCondition(oldConditions, conditionType) + newCond := meta.FindStatusCondition(newConditions, conditionType) + + if oldCond == nil && newCond != nil { + return true + } + + if oldCond != nil && newCond != nil { + return oldCond.Status != newCond.Status + } + + return false +} diff --git a/pkg/workloads/console/runner/integration/integration_test.go b/pkg/workloads/console/runner/integration/integration_test.go index 713903e07..00d3ca06a 100644 --- a/pkg/workloads/console/runner/integration/integration_test.go +++ b/pkg/workloads/console/runner/integration/integration_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/google/uuid" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" diff --git a/pkg/workloads/console/runner/integration/suite_test.go b/pkg/workloads/console/runner/integration/suite_test.go index 124b1d342..70ca3c4e5 100644 --- a/pkg/workloads/console/runner/integration/suite_test.go +++ b/pkg/workloads/console/runner/integration/suite_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/onsi/gomega/gexec" "k8s.io/apimachinery/pkg/runtime" @@ -61,7 +61,7 @@ var _ = BeforeSuite(func() { kubeClient, err = client.New(cfg, client.Options{Scheme: scheme}) Expect(err).NotTo(HaveOccurred(), "could not create client") -}, 60) +}) var _ = AfterSuite(func() { By("tearing down the test environment") diff --git a/pkg/workloads/console/runner/runner_test.go b/pkg/workloads/console/runner/runner_test.go index 780632ebc..abab41269 100644 --- a/pkg/workloads/console/runner/runner_test.go +++ b/pkg/workloads/console/runner/runner_test.go @@ -1,7 +1,7 @@ package runner import ( - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" diff --git a/pkg/workloads/console/runner/suite_test.go b/pkg/workloads/console/runner/suite_test.go index 28586512b..409438333 100644 --- a/pkg/workloads/console/runner/suite_test.go +++ b/pkg/workloads/console/runner/suite_test.go @@ -3,7 +3,7 @@ package runner import ( "testing" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" )