From 2be866461f8541f0318bbf28fc2a21d3bc71de13 Mon Sep 17 00:00:00 2001 From: Joe Lanford Date: Thu, 16 Jul 2026 10:52:59 -0400 Subject: [PATCH 01/26] .claude: add SDD skills and project docs --- .claude/commands/sdd-cleanup.md | 54 +++++++++++ .claude/commands/sdd-ideate.md | 59 +++++++++++++ .claude/commands/sdd-implement.md | 50 +++++++++++ .claude/commands/sdd-plan-next-phase.md | 112 +++++++++++++++++++++++ .claude/commands/sdd-quick-item.md | 28 ++++++ .claude/commands/sdd-review.md | 54 +++++++++++ .claude/commands/sdd-ship.md | 38 ++++++++ CLAUDE.md | 61 +++++++++++++ specs/conventions.md | 76 ++++++++++++++++ specs/mission.md | 43 +++++++++ specs/tech-stack.md | 113 ++++++++++++++++++++++++ 11 files changed, 688 insertions(+) create mode 100644 .claude/commands/sdd-cleanup.md create mode 100644 .claude/commands/sdd-ideate.md create mode 100644 .claude/commands/sdd-implement.md create mode 100644 .claude/commands/sdd-plan-next-phase.md create mode 100644 .claude/commands/sdd-quick-item.md create mode 100644 .claude/commands/sdd-review.md create mode 100644 .claude/commands/sdd-ship.md create mode 100644 CLAUDE.md create mode 100644 specs/conventions.md create mode 100644 specs/mission.md create mode 100644 specs/tech-stack.md diff --git a/.claude/commands/sdd-cleanup.md b/.claude/commands/sdd-cleanup.md new file mode 100644 index 0000000000..4d4d49888a --- /dev/null +++ b/.claude/commands/sdd-cleanup.md @@ -0,0 +1,54 @@ +Clean up completed specs: update statuses, backfill PR links, archive done work, and flag stale ideas. + +## Step 1: Inventory specs + +1. List all directories under `specs/` matching `YYYY-MM-DD-*/`. +2. Read the `README.md` frontmatter in each to get its status and pr field. +3. Categorize into: `done` (with pr), `done` (without pr), `pr-submitted`, `in-progress`, `ready`, `idea`. +4. Present a summary table to the user. + +## Step 2: Promote merged pr-submitted specs + +For each spec with `status: pr-submitted`: + +1. If it has a `pr:` field, check whether the linked PR has been merged (use `gh pr view`). +2. If it has no `pr:` field, search for a merged PR referencing the spec slug (same approach as Step 3). +3. If the PR is merged, update the README.md frontmatter to `status: done` (keep or add the `pr:` field). +4. Report which specs were promoted. + +## Step 3: Backfill PR links on done specs + +For each spec with `status: done` and no `pr:` field: + +1. Search for a merged PR whose title or body references the spec slug (use `gh pr list --state merged --search ""`). +2. If exactly one PR matches, add `pr: ` to the frontmatter. +3. If multiple match, present the candidates and use AskUserQuestion to let the user pick. +4. If none match, note it in the summary (some specs predate the PR workflow — that's fine, skip them). + +## Step 4: Archive done specs + +1. Create `specs/closed/` if it doesn't exist. +2. Move every spec directory with `status: done` into `specs/closed/`. +3. Report how many specs were archived. + +## Step 5: Flag stale ideas + +For each spec with `status: idea`: + +1. Check when it was last modified (use `jj log -r 'ancestors(@)' --no-graph -T 'commit_id ++ "\n"' -n 1 -- ` or fall back to `git log -1 --format=%ci -- `). +2. If last touched more than 30 days ago, flag it as stale. +3. Present any stale ideas to the user and use AskUserQuestion to ask whether to drop, keep, or refine each one. + +## Step 6: Commit + +1. Use AskUserQuestion to confirm before committing. +2. Commit all changes with message: `chore: clean up completed specs`. + +## Step 7: Summary + +Report what was done: +- Specs promoted from pr-submitted to done +- PR links backfilled +- Specs archived to specs/closed/ +- Stale ideas flagged (and any actions taken) +- Remaining active specs (in-progress, ready, idea) diff --git a/.claude/commands/sdd-ideate.md b/.claude/commands/sdd-ideate.md new file mode 100644 index 0000000000..3a8691a40e --- /dev/null +++ b/.claude/commands/sdd-ideate.md @@ -0,0 +1,59 @@ +Brainstorm and add new work items to the backlog. + +## Step 1: Gather context + +1. Read `specs/mission.md` to understand project goals and non-goals. +2. Read `specs/tech-stack.md` to understand the current tech stack. +3. List all `specs/YYYY-MM-DD-*/` directories and read their README.md files to understand existing work items and their statuses. +4. Briefly summarize the current state: what exists, what's in progress, what's done, and where the backlog currently stands. + +## Step 2: Surface unrefined items + +If there are existing work items with `status: idea`, list them and use AskUserQuestion to ask whether the user wants to refine any of them further before brainstorming new ideas. If so, use AskUserQuestion iteratively to flesh out the idea with more detail, then update its README.md accordingly (keeping `status: idea` - refinement to `ready` happens in `/sdd-plan-next-phase`). + +## Step 3: Brainstorm + +If the user provided input via $ARGUMENTS, use that as a starting theme. + +Use AskUserQuestion to explore: +- Problems or gaps in the current codebase +- User requests or feature ideas +- Technical debt worth addressing +- Mission goals that aren't yet addressed by any work item +- Dependencies between potential items + +## Step 4: Propose candidates + +For each candidate work item, present: +- **Short name** (slug-friendly) +- **One-line description** +- **3-6 deliverable bullets** +- **Why it matters** (connection to mission goals) + +## Step 5: Iterate + +Use AskUserQuestion to refine: +- Split large items into smaller ones +- Merge overlapping items +- Reorder by priority +- Check for dependencies between items +- Verify none conflict with non-goals from `specs/mission.md` + +## Step 6: Create work items + +For each finalized item, create `specs/YYYY-MM-DD-/README.md`: + +```markdown +--- +status: idea +--- +# + +<One or two sentence description of the idea.> +``` + +Use today's date for the `YYYY-MM-DD` prefix. + +## Step 7: Summary + +Summarize what was added to the backlog. Suggest running `/sdd-plan-next-phase` to refine and start the next item. diff --git a/.claude/commands/sdd-implement.md b/.claude/commands/sdd-implement.md new file mode 100644 index 0000000000..89db7a489a --- /dev/null +++ b/.claude/commands/sdd-implement.md @@ -0,0 +1,50 @@ +Implement a work item from its spec. + +## Step 1: Identify the work item + +If the user provided input via $ARGUMENTS, use that to find the spec directory. + +Otherwise, list `specs/YYYY-MM-DD-*/` directories with `status: ready` or `status: in-progress` in their README.md frontmatter. Use AskUserQuestion to ask which one to implement. + +## Step 2: Read the spec + +Read all files in the work item's spec directory: +- `README.md` - summary and design +- `requirements.md` - functional requirements and acceptance criteria +- `plan.md` - implementation plan +- `verification.md` - verification criteria + +If the spec is incomplete (still an `idea` or missing files), stop and tell the user to run `/sdd-plan-next-phase` to refine it first. + +## Step 3: Update status + +Set the frontmatter `status: in-progress` in the work item's README.md. + +## Step 4: Implement + +Follow the implementation plan in `plan.md` task groups in order. For each task group: + +1. Start a new jj change for this task group: `jj new -m ":sparkles: <description>"` (use the appropriate emoji prefix from `specs/conventions.md`). +2. Read `specs/mission.md` for design principles and `specs/tech-stack.md` for technical guidance. +3. Implement the changes for this task group. Changes are automatically tracked by jj. +4. Run `make lint && make test-unit` to verify nothing is broken. +5. Use AskUserQuestion for any decisions not covered by the spec. +6. When done, the current jj change already contains the committed work. Start the next task group with another `jj new -m "..."`. + +## Step 5: Verify + +After implementation is complete: + +1. Walk through each check in `verification.md` and confirm it passes. +2. Walk through each acceptance criterion in `requirements.md` and confirm it is met. +3. Run `make lint && make test-unit` one final time. +4. Run `make verify` to ensure generated code is up-to-date. +5. If any verification check or criterion fails, fix it in the change where the issue was introduced (navigate with `jj edit` and changes are applied automatically). + +## Step 6: Update governing docs + +Review whether anything learned during implementation should be reflected in global SDD documents (`specs/mission.md`, `specs/tech-stack.md`, `specs/conventions.md`, `CLAUDE.md`, or any other top-level specs). If updates are needed, make them in a dedicated change separate from the implementation changes. + +## Step 7: Suggest review + +Suggest the user run `/sdd-review` to review the changes for correctness and consistency before shipping. diff --git a/.claude/commands/sdd-plan-next-phase.md b/.claude/commands/sdd-plan-next-phase.md new file mode 100644 index 0000000000..7670070faa --- /dev/null +++ b/.claude/commands/sdd-plan-next-phase.md @@ -0,0 +1,112 @@ +Plan the next work item for the project. + +## Step 1: Check for clean state + +Ensure the working tree is on a fresh, empty change with no pending modifications. If there are pending changes, use AskUserQuestion to ask whether to proceed anyway or abort. + +## Step 2: Analyze the backlog + +1. List all directories under `specs/` matching `YYYY-MM-DD-*/`. +2. Read the `README.md` in each to get its title, status (from frontmatter), and summary. +3. Categorize items by status: `idea`, `ready`, `in-progress`, `pr-submitted`, `done`. +4. Present a summary to the user showing the current state of the backlog. + +## Step 3: Choose what to work on + +If the user provided input via $ARGUMENTS, use that as a starting point. + +Otherwise, use AskUserQuestion to help the user decide: +- Show `idea` and `ready` items as candidates +- Suggest which item to tackle next based on: dependencies between items, logical ordering, and project goals from `specs/mission.md` +- The user can also describe a new idea to create + +## Step 4: Create a bookmark and branch + +1. Create a new jj change: `jj new -m "planning: <work item name>"` +2. Create a jj bookmark for this work: `jj bookmark create <slug>` + +## Step 5: Create or refine the work item + +### If creating a new item: + +1. Create `specs/YYYY-MM-DD-<slug>/README.md` with this structure: + ```markdown + --- + status: idea + --- + # <Title> + + <One or two sentence description of the idea.> + ``` +2. Use AskUserQuestion to ask: should we refine this now or leave it as an idea for later? + +### If refining an existing `idea` item (or a new item the user wants to refine now): + +Use AskUserQuestion iteratively to gather requirements, implementation approach, and verification criteria. Reference `specs/tech-stack.md` for tech choices and `specs/mission.md` for design principles throughout. + +When refined, update the spec directory to the full structure with four files: + +**README.md** - high-level summary and overview: +```markdown +--- +status: ready +--- +# <Title> + +## Summary +<What this work item delivers and why it matters.> + +## Design +<Key design decisions, type definitions, caller patterns, and how different +implementations map to the API. This is the heart of the spec - it should +be detailed enough that a reader understands the full shape of the work.> +``` + +**requirements.md** - functional requirements: +```markdown +# Requirements + +- <Requirement 1> +- <Requirement 2> +- ... + +## Acceptance Criteria +- <Criterion 1> +- <Criterion 2> +- ... +``` + +**plan.md** - specific implementation plan: +```markdown +# Implementation Plan + +1. <Task group 1> +2. <Task group 2> +3. ... +``` + +**verification.md** - how to verify the implementation: +```markdown +# Verification + +## Implementation Correctness +- [ ] <Verification that the implementation plan was followed correctly> +- [ ] <Verification step 2> +- ... + +## Project Conventions +- [ ] <Check against specs/conventions.md> +- [ ] <Check against specs/mission.md design principles> +- [ ] <Check against specs/tech-stack.md> +- ... +``` + +## Step 6: Review + +After writing, re-read all spec files and check: +- Does the implementation plan align with `specs/mission.md` design principles? +- Does it use the tech stack from `specs/tech-stack.md` correctly? +- Are acceptance criteria testable and specific? +- Are there any gaps or ambiguities? + +Fix straightforward issues directly. Use AskUserQuestion for anything with multiple valid options. diff --git a/.claude/commands/sdd-quick-item.md b/.claude/commands/sdd-quick-item.md new file mode 100644 index 0000000000..f8db4f254b --- /dev/null +++ b/.claude/commands/sdd-quick-item.md @@ -0,0 +1,28 @@ +Quickly capture a work item idea to the backlog. + +## Step 1: Get the idea + +If the user provided input via $ARGUMENTS, use that as the idea description. + +Otherwise, use AskUserQuestion to ask the user to describe the idea in one or two sentences. + +## Step 2: Generate a slug + +Derive a short, descriptive slug from the idea (lowercase, hyphens, no special characters). + +## Step 3: Create the work item + +Create `specs/YYYY-MM-DD-<slug>/README.md` using today's date: + +```markdown +--- +status: idea +--- +# <Title> + +<The user's idea description.> +``` + +## Step 4: Confirm + +Report the created file path. Suggest `/sdd-plan-next-phase` to refine it when ready. diff --git a/.claude/commands/sdd-review.md b/.claude/commands/sdd-review.md new file mode 100644 index 0000000000..e1aa293a4b --- /dev/null +++ b/.claude/commands/sdd-review.md @@ -0,0 +1,54 @@ +Review the current branch's changes for correctness and consistency. + +## Step 1: Identify changes + +Use `jj diff -r @` and `jj log` to identify all changes on the current bookmark compared to main. Read each changed file. + +## Step 2: Find the work item spec + +Look for a `specs/YYYY-MM-DD-*/` directory with `status: in-progress`. If found, read all files in the spec directory (README.md, requirements.md, plan.md, verification.md) for context on what the changes should accomplish. + +## Step 3: Check correctness + +For each changed file: +- Does the code follow Go conventions and the project's design principles from `specs/mission.md`? +- Are there bugs, logic errors, or edge cases missed? +- Are tests adequate for the changes? +- Is the public API surface intentional and minimal? +- Are legacy dependencies (`operator-framework/api`, `operator-framework/operator-registry`) used only where necessary? +- Is there any code that introduces Kubernetes cluster dependencies (kubeconfig, kube client, etc.)? + +## Step 4: Check consistency with governing specs + +- Does the implementation match the requirements and acceptance criteria in `requirements.md` (if one exists)? +- Was `plan.md` followed correctly? +- Do all checks in `verification.md` pass? +- Is the code consistent with `specs/tech-stack.md` (correct dependencies, project structure)? +- Do change descriptions follow `specs/conventions.md`? +- Does `CLAUDE.md` need updating to reflect new packages, commands, or conventions? + +## Step 5: Check for issues + +Look for: +- Dead code or unused imports +- Inconsistent naming or patterns across the changeset +- Missing or incomplete test coverage +- Overly broad public API (things that should be in `internal/`) + +## Step 6: Run project checks + +Run `make lint && make test-unit` to confirm all checks pass. If API types changed, also run `make verify-crd-compatibility`. + +## Step 7: Act on findings + +- Apply straightforward fixes directly (formatting, obvious bugs, missing error checks). +- Use AskUserQuestion for issues with multiple valid options. +- Summarize any remaining concerns that need the author's judgment. + +## Step 8: Update spec status + +If the review found no blocking issues and the work item's spec is still `status: in-progress`, use AskUserQuestion to ask whether to set it to `status: done`. If yes, update the README.md frontmatter. + +## Step 9: Suggest shipping + +If no blocking issues remain, suggest the user run `/sdd-ship` to finalize and publish the changes. diff --git a/.claude/commands/sdd-ship.md b/.claude/commands/sdd-ship.md new file mode 100644 index 0000000000..116a140326 --- /dev/null +++ b/.claude/commands/sdd-ship.md @@ -0,0 +1,38 @@ +Finalize and publish the current branch's changes. + +## Phase 1: Verify + +1. Run `make lint` - all linting must pass. +2. Run `make test-unit` - all unit tests must pass. +3. Run `make verify` - all generated code must be up-to-date. +4. Run `make fmt` - code must be formatted. +5. If any API types were changed, run `make verify-crd-compatibility` to check backward compatibility. +6. If a work item spec exists (`specs/YYYY-MM-DD-*/` with `status: in-progress`), verify all acceptance criteria are met. +7. Check that `CLAUDE.md` is up to date with any new packages or conventions. + +If any check fails, stop and report the issue. + +## Phase 2: Commit + +1. Read `specs/conventions.md` for commit and PR format. +2. Review the jj log on this bookmark. If there are fixup changes that should be squashed, squash them (use `jj squash -m "combined description"` or `jj squash -u` to avoid editor prompts). +3. Ensure each change has a well-formed description following `specs/conventions.md` (emoji prefix, imperative mood, issue reference). +4. Use AskUserQuestion to confirm the commit history looks correct before proceeding. + +## Phase 3: Publish + +1. Use AskUserQuestion to confirm before pushing. +2. Push the bookmark with `jj git push` and create a PR following the conventions in `specs/conventions.md`: + - Title: emoji-prefixed summary matching the primary change + - Body: Description of changes and motivation, plus the Reviewer Checklist from the PR template + - Link to the work item spec directory if one exists +3. If a work item spec exists, update its frontmatter: + - Set `status: pr-submitted` + - Add `pr: <PR URL>` + +## Phase 4: Monitor CI + +1. Spawn a background agent to watch the PR's CI checks. The agent should: + - Poll the PR's check runs periodically until they complete. + - Report back with any failures, including the failing check name and a summary of the error. +2. Summarize: PR URL, what was shipped, and that CI is being monitored in the background. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..8e91b4aa08 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# operator-controller + +The central component of Operator Lifecycle Manager (OLM) v1, providing APIs and controllers for packaging, distributing, and managing the lifecycle of Kubernetes cluster extensions - bundles of arbitrary Kubernetes objects that extend cluster functionality. The project contains two components: operator-controller (core lifecycle management) and catalogd (catalog serving). + +## Architecture + +- `api/v1/` - Kubernetes API types (ClusterExtension, ClusterCatalog, etc.) +- `cmd/operator-controller/` and `cmd/catalogd/` - binary entry points +- `internal/operator-controller/` and `internal/catalogd/` - controller logic +- `internal/shared/` - shared internal packages +- `config/` - deployment manifests (kustomize) +- `helm/` - Helm chart for installation +- `test/e2e/`, `test/upgrade-e2e/`, `test/regression/` - test suites + +## Design Principles + +1. Do not fight Kubernetes - work with global API registration, single-owner semantics +2. Secure by default - no cluster-admin; user-supplied service accounts +3. Simple and predictable - declarative, eventually-consistent, GitOps-friendly +4. Opinionated guardrails with escape hatches +5. Extension-agnostic packaging - bundles can contain any Kubernetes objects +6. Constraint checking, not dependency management + +## Build and Test + +``` +make build # Build binaries +make test-unit # Unit tests (envtest) +make test-e2e # End-to-end tests +make lint # golangci-lint (includes custom linter) +make fmt # Format code (Go + YAML) +make verify # Verify all generated code is up-to-date +make generate # Generate DeepCopy, mocks, apply configurations +make manifests # Generate CRDs and deployment manifests +make verify-crd-compatibility # Check CRD backward compatibility +make tidy # go mod tidy +``` + +Local development uses kind clusters (`make kind-cluster`, `make kind-deploy`) and Tilt for live reload. + +## Conventions + +- Commit messages use emoji prefixes: `:sparkles:` (feature), `:bug:` (fix), `:seedling:` (chore), `:book:` (docs), `:warning:` (breaking) +- PR titles use the same emoji prefix format +- See `specs/conventions.md` for full details + +## SDD Workflow + +Work items are tracked as spec directories under `specs/YYYY-MM-DD-<slug>/` with status frontmatter (`idea`, `ready`, `in-progress`, `pr-submitted`, `done`). + +| Command | Purpose | +|---|---| +| `/sdd-ideate` | Brainstorm and add new work items to the backlog | +| `/sdd-plan-next-phase` | Plan and spec out the next work item | +| `/sdd-implement` | Implement a work item from its spec | +| `/sdd-review` | Review changes for correctness and consistency | +| `/sdd-ship` | Verify, commit, push, and create PR | +| `/sdd-cleanup` | Archive completed specs, flag stale items | +| `/sdd-quick-item` | Quickly capture an idea to the backlog | + +Governing docs live in `specs/`: `mission.md`, `tech-stack.md`, `conventions.md`. diff --git a/specs/conventions.md b/specs/conventions.md new file mode 100644 index 0000000000..7abdf7ef99 --- /dev/null +++ b/specs/conventions.md @@ -0,0 +1,76 @@ +# Conventions + +## Commit Messages + +Prefix commit subjects with an emoji shortcode indicating the type of change: + +| Prefix | Meaning | +|---|---| +| `:warning:` | Major/breaking change | +| `:sparkles:` | Minor/compatible change (new feature) | +| `:bug:` | Bug fix | +| `:book:` | Documentation | +| `:seedling:` | Other (dependency bumps, chores, refactoring) | + +Format: +``` +:prefix: Short summary of the change (#issue) + +Optional longer description explaining motivation and context. +``` + +Examples: +``` +:sparkles: Add version pinning support for ClusterExtension (#1234) + +:bug: Fix missing olm.operatorNamespace annotation (#2803) + +:seedling: Bump github.com/klauspost/compress from 1.18.6 to 1.19.0 (#2815) + +:warning: Remove HelmChartSupport feature (#2798) +``` + +Guidelines: +- Keep the subject line concise (under ~72 characters after the prefix) +- Reference the GitHub issue number when applicable +- Use imperative mood ("Add support" not "Added support") +- Body is optional but encouraged for non-trivial changes + +## Pull Requests + +### Title Format + +PR titles use the same emoji prefix as commit messages: + +``` +:sparkles: Add version pinning support for ClusterExtension +:bug: Fix missing annotation on namespace-scoped resources +``` + +### Description + +Follow the PR template (`.github/pull_request_template.md`): + +1. **Description** - Summary of changes and motivation +2. **Reviewer Checklist**: + - API Go documentation + - Tests (unit tests, and e2e tests if appropriate) + - Comprehensive commit messages + - Links to related GitHub issues + +### Reviewer Expectations + +- API changes need Go doc comments on exported types and fields +- Non-trivial changes should include unit tests at minimum +- Changes affecting user-facing behavior should include e2e tests +- Each commit in the PR should be self-contained and pass CI independently + +## Branch Naming + +No strict convention enforced. Common patterns used in the project: + +``` +joe/short-description +fix/issue-number-description +feature/short-name +``` diff --git a/specs/mission.md b/specs/mission.md new file mode 100644 index 0000000000..50dd3bdfb5 --- /dev/null +++ b/specs/mission.md @@ -0,0 +1,43 @@ +# Mission + +## Overview + +operator-controller is the central component of Operator Lifecycle Manager (OLM) v1. It provides APIs, controllers, and tooling for packaging, distributing, and managing the lifecycle of Kubernetes cluster extensions - bundles of arbitrary Kubernetes objects (cluster-scoped or namespace-scoped) that extend cluster functionality. + +OLM v1 consists of two components: +- **operator-controller** - the core lifecycle management controller +- **catalogd** - the catalog serving component + +## Goals + +1. **Align with Kubernetes designs and user assumptions** - APIs and controllers follow standard Kubernetes patterns; CRDs and controllers are treated as trusted cluster extensions with global API registration. +2. **Provide secure, predictable user experiences centered around declarative GitOps concepts** - GitOps-friendly APIs with declarative, eventually-consistent behavior. Secure by default (no cluster-admin permissions; user-supplied service accounts for installs). +3. **Give cluster admins minimal necessary controls** - Fine-grained version pinning, upgrade control per extension, and optional guardrails with escape hatches. Admins have ultimate control over their cluster architecture. +4. **Support packaging, distribution, and lifecycling of cluster extensions** - Install, upgrade, and delete bundles of arbitrary Kubernetes objects. Automated upgrades, health monitoring, CRD upgrade safety checks, and constraint checking. +5. **Complement on-cluster APIs with official CLI tooling** - On-cluster APIs cover 100% of use cases; the CLI covers standard ~80% workflows. Advanced use cases interact directly with cluster APIs. + +## Non-Goals + +- **Multi-tenancy** - Kubernetes APIs are global; multi-tenancy promises made by OLM v0 were infeasible due to the global API system. OLM v1 does not design around multi-tenant control planes. +- **Multi-cluster extension distribution** - OLM v1 manages extensions within a single cluster. +- **Namespace-specific controller configurations** - No first-class API for configuring watched namespaces. OLM v1 assumes controllers reconcile objects cluster-wide. +- **Automatic dependency resolution and installation** - OLM v1 performs constraint checking (are dependencies met?) but does not auto-install missing dependencies. Predictability over magic. +- **Covering complex/advanced scenarios in the CLI** - The CLI handles common workflows. Complex use cases use the on-cluster APIs directly. + +## Design Principles + +1. **Do not fight Kubernetes** - Work with Kubernetes's global API registration, ownership model, and reconciliation patterns. API registration is cluster-scoped; OLM v1 enforces single-owner semantics for managed objects. +2. **Secure by default** - No cluster-admin permissions for OLM itself. User-supplied service accounts authorize installs/upgrades. Secure communication between all components. +3. **Simple and predictable semantics** - Two primary APIs (catalogs and install intent). Declarative, eventually-consistent behavior. Avoid the complexity that made OLM v0 difficult to reason about. +4. **Opinionated guardrails with escape hatches** - CRD upgrade safety checks, upgrade-edge enforcement, and version constraints are on by default. Admins can disable any guardrail. +5. **Extension-agnostic packaging** - Bundles can contain any Kubernetes objects, not just operator-pattern controllers. A bundle with a Deployment + Service + Ingress is as valid as one with CRDs and controllers. +6. **Constraint checking, not dependency management** - Check whether preconditions are met and report unmet constraints. Do not auto-install or auto-manage dependency trees. + +## Development Practices + +- All PRs must pass CI: lint (`make lint`), unit tests (`make test-unit`), and e2e tests (`make test-e2e`) +- Generated code must be up-to-date: run `make verify` before submitting +- API changes require CRD compatibility checks (`make verify-crd-compatibility`) +- Code formatting enforced via `make fmt` (yamlfmt, gofmt) +- Mock generation via mockgen; managed by bingo for version consistency +- Helm chart linting via `make lint-helm` diff --git a/specs/tech-stack.md b/specs/tech-stack.md new file mode 100644 index 0000000000..c8e336d4d5 --- /dev/null +++ b/specs/tech-stack.md @@ -0,0 +1,113 @@ +# Tech Stack + +## Language and Runtime + +- **Go** (version pinned in `go.mod`, currently 1.26.x) +- **Kubernetes controller-runtime** for controller infrastructure +- Module path: `github.com/operator-framework/operator-controller` + +## Core Dependencies + +| Dependency | Purpose | +|---|---| +| `sigs.k8s.io/controller-runtime` | Controller framework, reconciliation, webhooks | +| `github.com/operator-framework/operator-registry` | Catalog/registry content formats and APIs | +| `github.com/operator-framework/helm-operator-plugins` | Helm-based extension installation | +| `github.com/operator-framework/api` | Shared OLM API types | +| `github.com/google/go-containerregistry` | OCI image/registry interaction | +| `github.com/cert-manager/cert-manager` | TLS certificate management | +| `github.com/graphql-go/graphql` | GraphQL API for catalog queries | +| `helm.sh/helm/v3` | Helm chart rendering and installation | + +## Dev Tooling + +| Tool | Purpose | Managed By | +|---|---|---| +| `golangci-lint` | Linting (includes custom linter) | bingo | +| `controller-gen` | CRD/RBAC/DeepCopy code generation | bingo | +| `setup-envtest` | Kubernetes API server for unit tests | bingo | +| `mockgen` | Mock generation for testing | bingo | +| `yamlfmt` | YAML formatting | bingo | +| `helm` | Chart linting and templating | bingo | +| `kind` | Local Kubernetes clusters | bingo | +| `conftest` | Policy-based Helm chart testing | bingo | +| `crd-diff` | CRD compatibility checking | bingo | +| `bingo` | Dev tool version management | go install | + +## Project Structure + +``` +operator-controller/ + api/v1/ # Kubernetes API types (ClusterExtension, ClusterCatalog, etc.) + applyconfigurations/ # Generated apply configuration types + cmd/ + operator-controller/ # operator-controller binary entry point + catalogd/ # catalogd binary entry point + internal/ + operator-controller/ # operator-controller controllers and logic + catalogd/ # catalogd controllers and logic + shared/ # Shared internal packages + testing/ # Test helpers + testutil/ # Test utilities + config/ # Kustomize/deployment manifests + helm/ # Helm chart + test/ + e2e/ # End-to-end tests + extension-developer-e2e/ # Extension developer workflow tests + upgrade-e2e/ # Upgrade scenario tests + regression/ # Regression tests + docs/ # Documentation (published to GitHub Pages) + hack/ # Build and CI scripts + scripts/ # Utility scripts + manifests/ # Generated manifests + testdata/ # Test fixtures +``` + +## Build Commands + +| Command | Purpose | +|---|---| +| `make build` | Build binaries for local GOOS/GOARCH | +| `make build-linux` | Build binaries for linux | +| `make test-unit` | Run unit tests (uses envtest) | +| `make test-e2e` | Run end-to-end tests | +| `make test-regression` | Run regression tests | +| `make lint` | Run golangci-lint (includes custom linter) | +| `make lint-helm` | Lint Helm chart | +| `make fmt` | Format code (Go + YAML) | +| `make generate` | Generate DeepCopy, mocks, apply configurations | +| `make manifests` | Generate CRDs and deployment manifests | +| `make verify` | Verify all generated code is up-to-date | +| `make verify-crd-compatibility` | Check CRD backward compatibility | +| `make tidy` | Run go mod tidy | + +## Containers + +- `Dockerfile.operator-controller` - operator-controller image +- `Dockerfile.catalogd` - catalogd image +- Registry: `quay.io/operator-framework/operator-controller` and `quay.io/operator-framework/catalogd` +- Tag convention: `devel` for local, semver tags for releases + +## Local Development + +- **kind** clusters for local Kubernetes (`make kind-cluster`, `make kind-deploy`) +- **Tilt** for live-reload development +- `make kind-load` to push images into kind +- `make wait` to wait for deployments to be ready + +## CI/CD + +GitHub Actions workflows: +- `unit-test.yaml` - unit tests +- `e2e.yaml` - end-to-end tests +- `sanity.yaml` - lint, verify, format checks +- `crd-diff.yaml` - CRD compatibility +- `api-diff-lint.yaml` / `go-apidiff.yaml` - API compatibility +- `pr-title.yaml` - PR title format validation +- `release.yaml` - release automation +- `pages.yaml` - documentation publishing + +## Version Control + +- **jj (Jujutsu)** co-located with git (`.jj/` directory present) +- Use jj commands for all VCS operations; git is the transport layer From 2ac92d3ba64777234e281c055ecabc535dfedec6 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 14:46:32 -0400 Subject: [PATCH 02/26] :seedling: Add orb-operator integration backlog specs --- specs/2026-08-12-cod-generator/README.md | 14 ++++++++ .../README.md | 36 +++++++++++++++++++ .../plan.md | 29 +++++++++++++++ .../requirements.md | 17 +++++++++ .../verification.md | 24 +++++++++++++ .../2026-08-12-orb-operator-wiring/README.md | 23 ++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 specs/2026-08-12-cod-generator/README.md create mode 100644 specs/2026-08-12-orb-operator-dependency/README.md create mode 100644 specs/2026-08-12-orb-operator-dependency/plan.md create mode 100644 specs/2026-08-12-orb-operator-dependency/requirements.md create mode 100644 specs/2026-08-12-orb-operator-dependency/verification.md create mode 100644 specs/2026-08-12-orb-operator-wiring/README.md diff --git a/specs/2026-08-12-cod-generator/README.md b/specs/2026-08-12-cod-generator/README.md new file mode 100644 index 0000000000..33ec81b64b --- /dev/null +++ b/specs/2026-08-12-cod-generator/README.md @@ -0,0 +1,14 @@ +--- +status: idea +--- +# COD Generator + +Implement the `CODGenerator` interface and `RegistryV1CODGenerator` that converts a bundle `fs.FS` and ClusterExtension into an inline `ClusterObjectDeploymentApplyConfiguration`. This is the translation layer between OLM's registry+v1 bundle format and orb-operator's phased object model. + +## Deliverables + +- `CODGenerator` interface with `GenerateCOD(ctx, bundleFS, ext, revisionAnnotations) (*orbac.ClusterObjectDeploymentApplyConfiguration, error)` +- `RegistryV1CODGenerator` implementation that uses the existing `ManifestProvider` to render bundle manifests, then organizes them into orb-operator phases with appropriate assertions and collision protection +- Phasing logic: CRDs/namespaces in early phases, workloads in later phases, with progression assertions (e.g., CRD Established=True before proceeding) +- Unit tests covering phase ordering, assertion generation, and edge cases +- Reference: speed-run branch commits `sn` and `xuk` diff --git a/specs/2026-08-12-orb-operator-dependency/README.md b/specs/2026-08-12-orb-operator-dependency/README.md new file mode 100644 index 0000000000..3d872b5122 --- /dev/null +++ b/specs/2026-08-12-orb-operator-dependency/README.md @@ -0,0 +1,36 @@ +--- +status: in-progress +--- +# orb-operator Dependency + +## Summary + +Add the `github.com/joelanford/orb-operator` Go module dependency, register its API types (`orbv1alpha1`) with the controller-runtime scheme, and add orb-operator installation to the experimental deploy/install scripts. This is the foundational prerequisite for all other orb-operator integration work. + +## Design + +### Go module dependency + +Add `github.com/joelanford/orb-operator` as a direct dependency in `go.mod`. The version should be the latest release (currently v0.0.2). This brings in: +- `api/v1alpha1` - ClusterObjectDeployment, ClusterObjectSet, ClusterObjectSlice types +- `applyconfigurations/api/v1alpha1` - SSA apply configuration builders + +### Scheme registration + +Register `orbv1alpha1` in `internal/operator-controller/scheme/scheme.go` alongside the existing OLM and Kubernetes types. This ensures the Go import keeps the dependency in `go.mod` and makes orb-operator types available to all controller-runtime clients. + +### Experimental deployment + +The install script (`scripts/install.tpl.sh`) needs an orb-operator install step, gated on whether `ORB_OPERATOR_VERSION` is set. Only the experimental variant passes this variable; the standard variant leaves it empty, so the install block is skipped. + +The Makefile needs: +- `ORB_OPERATOR_VERSION` variable (derived from `go list -m`) +- The experimental `install-sh` call passes `ORB_OPERATOR_VERSION` as an extra envsubst variable +- Test/deploy targets that use the experimental manifest export `ORB_OPERATOR_VERSION` + +orb-operator publishes an `install.json` at each release, so the install step is a single `kubectl apply -f` of the release URL, followed by a deployment wait. + +### What this does NOT include + +- No feature gate, controller wiring, or applier code - those belong in later specs +- No changes to the standard deployment variant - orb-operator is only installed in experimental mode diff --git a/specs/2026-08-12-orb-operator-dependency/plan.md b/specs/2026-08-12-orb-operator-dependency/plan.md new file mode 100644 index 0000000000..c7a37a8fbe --- /dev/null +++ b/specs/2026-08-12-orb-operator-dependency/plan.md @@ -0,0 +1,29 @@ +# Implementation Plan + +1. Add Go dependency and register scheme + - `go get github.com/joelanford/orb-operator@latest` + - Add `orbv1alpha1` import and `AddToScheme` call in `internal/operator-controller/scheme/scheme.go` + - `go mod tidy` + - Verify `go build ./...` succeeds + +2. Update Makefile + - Add `ORB_OPERATOR_VERSION` variable derived from `go list -m -f '{{.Version}}'` + - Update the `install-sh` macro to accept an optional third argument (`$(3)`) for extra env vars, prepended before the envsubst call + - Experimental `install-sh` call passes `ORB_OPERATOR_VERSION=$$(ORB_OPERATOR_VERSION)` as the third arg; standard call passes empty + - Export `ORB_OPERATOR_VERSION` in `test-experimental-e2e`, `experimental-e2e-setup`, and `run-experimental` targets + - Add `$$ORB_OPERATOR_VERSION` to the envsubst variable list in the `kind-deploy-%` recipe (the one that pipes to `bash -s`) + - In the `release` target, add `$$ORB_OPERATOR_VERSION` to envsubst lists for both standard and experimental release install scripts, but only set the env var (`ORB_OPERATOR_VERSION=$(ORB_OPERATOR_VERSION)`) for the experimental one + +3. Update install script + - Add `orb_operator_version=$ORB_OPERATOR_VERSION` variable alongside the other version variables + - After the cert-manager install/wait block, add a conditional orb-operator install block: + - Gate on `[[ -n "$orb_operator_version" ]]` + - Idempotency check: if the CRD and deployment already exist, skip with a message + - Otherwise `kubectl apply -f` the release install.json URL + - `kubectl_wait` for the orb-operator deployment + +4. Verify + - `make build` + - `make test-unit` + - `make lint` + - `make verify` diff --git a/specs/2026-08-12-orb-operator-dependency/requirements.md b/specs/2026-08-12-orb-operator-dependency/requirements.md new file mode 100644 index 0000000000..06ddafd4ae --- /dev/null +++ b/specs/2026-08-12-orb-operator-dependency/requirements.md @@ -0,0 +1,17 @@ +# Requirements + +- Add `github.com/joelanford/orb-operator` as a direct dependency in `go.mod` +- Register `orbv1alpha1.AddToScheme` in the shared scheme package +- Add orb-operator installation to `scripts/install.tpl.sh`, gated on `ORB_OPERATOR_VERSION` being non-empty +- Pass `ORB_OPERATOR_VERSION` through the Makefile for experimental targets only +- The standard deployment variant must be unaffected + +## Acceptance Criteria + +- `go build ./...` succeeds with the new dependency +- `make test-unit` passes (no regressions from the new dependency) +- `make lint` passes +- `make verify` passes (generated code is up-to-date) +- `make manifests/experimental.yaml` produces a valid manifest +- The experimental install script includes the orb-operator install block +- The standard install script does not install orb-operator diff --git a/specs/2026-08-12-orb-operator-dependency/verification.md b/specs/2026-08-12-orb-operator-dependency/verification.md new file mode 100644 index 0000000000..f3dbc32aee --- /dev/null +++ b/specs/2026-08-12-orb-operator-dependency/verification.md @@ -0,0 +1,24 @@ +# Verification + +## Implementation Correctness + +- [ ] `go.mod` lists `github.com/joelanford/orb-operator` as a direct dependency +- [ ] `internal/operator-controller/scheme/scheme.go` registers `orbv1alpha1.AddToScheme` +- [ ] `scripts/install.tpl.sh` has a conditional orb-operator install block gated on `$orb_operator_version` being non-empty +- [ ] The install block uses the release URL pattern: `https://github.com/joelanford/orb-operator/releases/download/${orb_operator_version}/install.json` +- [ ] The install block waits for the orb-operator deployment to be ready +- [ ] Makefile derives `ORB_OPERATOR_VERSION` from `go list -m` +- [ ] Only experimental targets export `ORB_OPERATOR_VERSION`; standard targets do not + +## Build Verification + +- [ ] `make build` succeeds +- [ ] `make test-unit` passes +- [ ] `make lint` passes +- [ ] `make verify` passes + +## Project Conventions + +- [ ] Commit message uses `:seedling:` prefix (chore/dependency change) +- [ ] No unnecessary code changes beyond what is specified +- [ ] Install script follows existing patterns (idempotent check before install, `kubectl_wait` for readiness) diff --git a/specs/2026-08-12-orb-operator-wiring/README.md b/specs/2026-08-12-orb-operator-wiring/README.md new file mode 100644 index 0000000000..0284f67c26 --- /dev/null +++ b/specs/2026-08-12-orb-operator-wiring/README.md @@ -0,0 +1,23 @@ +--- +status: idea +--- +# orb-operator Wiring + +Wire up the orb-operator applier path in `main.go` behind a new feature gate, following the same pattern as the existing Helm/Boxcutter selection. This makes the orb-operator runtime selectable at startup. + +## Deliverables + +- `OrbOperatorRuntime` feature gate in `internal/operator-controller/features/features.go` (alpha, default off) +- `orbOperatorReconcilerConfigurator` in `main.go` implementing the existing configurator pattern: + - Constructs `OrbOperator` applier with CODGenerator, preflights, and field owner + - Sets reconcile steps: HandleFinalizers, ValidateClusterExtension, RetrieveRevisionStates, ResolveBundle, UnpackBundle, ApplyBundleWithOrbOperator + - Registers finalizers (cleanup on CE deletion) +- Cache options: add COD, COS, COSL to the informer cache when the feature gate is enabled +- Controller builder: `WithOwns` for COD resources, watches for COS changes +- Feature gate selection logic alongside the existing Helm/Boxcutter check +- Integration with existing e2e test hooks for feature-gate-aware test setup + +## Dependencies + +- orb-operator-reconcile-step (for the step function and revision states getter) +- orb-operator-dependency (for scheme registration and deployment manifests) From cb5e6b00d9d9e29ccb159c1a85785fe7a3a61629 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 16:41:35 -0400 Subject: [PATCH 03/26] :seedling: Add orb-operator dependency and experimental deployment --- Makefile | 16 ++-- go.mod | 30 ++++---- go.sum | 76 +++++++++---------- internal/operator-controller/scheme/scheme.go | 2 + scripts/install.tpl.sh | 12 +++ 5 files changed, 77 insertions(+), 59 deletions(-) diff --git a/Makefile b/Makefile index e9237b0ac5..02261d4cc8 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ ENVTEST_VERSION := $(K8S_VERSION).x # Define dependency versions (use go.mod if we also use Go code from dependency) export CERT_MGR_VERSION := v1.18.2 +ORB_OPERATOR_VERSION := $(shell go list -m -f '{{.Version}}' github.com/joelanford/orb-operator) export WAIT_TIMEOUT := 60s # Install default ClusterCatalogs @@ -247,11 +248,11 @@ define install-sh $(1)/install.sh: manifests @echo -e "\n\U1F4D8 Using $(1).yaml as source manifest\n" sed "s/cert-git-version/cert-$$(VERSION)/g" manifests/$(1).yaml > $(2) - MANIFEST=$(2) INSTALL_DEFAULT_CATALOGS=false DEFAULT_CATALOG=$$(RELEASE_CATALOGS) envsubst '$$$$DEFAULT_CATALOG,$$$$CERT_MGR_VERSION,$$$$INSTALL_DEFAULT_CATALOGS,$$$$MANIFEST' < scripts/install.tpl.sh > $(1)-install.sh + MANIFEST=$(2) INSTALL_DEFAULT_CATALOGS=false DEFAULT_CATALOG=$$(RELEASE_CATALOGS) $(3) envsubst '$$$$DEFAULT_CATALOG,$$$$CERT_MGR_VERSION,$$$$INSTALL_DEFAULT_CATALOGS,$$$$MANIFEST,$$$$ORB_OPERATOR_VERSION' < scripts/install.tpl.sh > $(1)-install.sh endef -$(eval $(call install-sh,experimental,operator-controller-experimental.yaml)) -$(eval $(call install-sh,standard,operator-controller-standard.yaml)) +$(eval $(call install-sh,experimental,operator-controller-experimental.yaml,ORB_OPERATOR_VERSION=$$(ORB_OPERATOR_VERSION))) +$(eval $(call install-sh,standard,operator-controller-standard.yaml,)) .PHONY: test test: manifests generate fmt lint test-unit test-e2e test-regression #HELP Run all tests. @@ -322,6 +323,7 @@ test-experimental-e2e: SOURCE_MANIFEST := $(EXPERIMENTAL_E2E_MANIFEST) test-experimental-e2e: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST) test-experimental-e2e: export DEFAULT_CATALOG := $(CATALOGS_MANIFEST) test-experimental-e2e: export INSTALL_DEFAULT_CATALOGS := false +test-experimental-e2e: export ORB_OPERATOR_VERSION := $(ORB_OPERATOR_VERSION) test-experimental-e2e: E2E_PROMETHEUS_VALUES := testdata/prometheus/values-experimental.yaml test-experimental-e2e: E2E_TIMEOUT ?= 25m e2e-setup: SOURCE_MANIFEST := $(STANDARD_E2E_MANIFEST) @@ -333,6 +335,7 @@ experimental-e2e-setup: SOURCE_MANIFEST := $(EXPERIMENTAL_E2E_MANIFEST) experimental-e2e-setup: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST) experimental-e2e-setup: export DEFAULT_CATALOG := $(CATALOGS_MANIFEST) experimental-e2e-setup: export INSTALL_DEFAULT_CATALOGS := false +experimental-e2e-setup: export ORB_OPERATOR_VERSION := $(ORB_OPERATOR_VERSION) E2E_KUBECONFIG = $(KUBECONFIG_DIR)/$*.kubeconfig @@ -362,7 +365,7 @@ kind-deploy-%: kind-load-% manifests CERT_MGR_VERSION=$(CERT_MGR_VERSION) \ INSTALL_DEFAULT_CATALOGS=$(INSTALL_DEFAULT_CATALOGS) \ MANIFEST=$(MANIFEST); \ - envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST' < scripts/install.tpl.sh | bash -s + envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST,$$ORB_OPERATOR_VERSION' < scripts/install.tpl.sh | bash -s .PHONY: lint-deployed-% lint-deployed-%: kind-deploy-% $(KUBE_SCORE) @@ -635,6 +638,7 @@ run: wait-$(KIND_CLUSTER_NAME) #HELP Build operator-controller then deploy it wi run-experimental: SOURCE_MANIFEST := $(EXPERIMENTAL_MANIFEST) run-experimental: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST) run-experimental: export DEFAULT_CATALOG := $(RELEASE_CATALOGS) +run-experimental: export ORB_OPERATOR_VERSION := $(ORB_OPERATOR_VERSION) run-experimental: wait-$(KIND_CLUSTER_NAME) #HELP Build the operator-controller then deploy it with the experimental manifest into a new kind cluster. CATD_NAMESPACE := olmv1-system @@ -672,8 +676,8 @@ quickstart: manifests #EXHELP Generate the unified installation release manifest sed "s/:devel/:$(VERSION)/g" $(STANDARD_MANIFEST) | sed "s/cert-git-version/cert-$(VERSION)/g" > $(STANDARD_RELEASE_MANIFEST) sed "s/:devel/:$(VERSION)/g" $(EXPERIMENTAL_MANIFEST) | sed "s/cert-git-version/cert-$(VERSION)/g" > $(EXPERIMENTAL_RELEASE_MANIFEST) cp $(CATALOGS_MANIFEST) $(RELEASE_CATALOGS) - MANIFEST=$(STANDARD_MANIFEST_URL) envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST' < scripts/install.tpl.sh > $(STANDARD_RELEASE_INSTALL) - MANIFEST=$(EXPERIMENTAL_MANIFEST_URL) envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST' < scripts/install.tpl.sh > $(EXPERIMENTAL_RELEASE_INSTALL) + MANIFEST=$(STANDARD_MANIFEST_URL) envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST,$$ORB_OPERATOR_VERSION' < scripts/install.tpl.sh > $(STANDARD_RELEASE_INSTALL) + MANIFEST=$(EXPERIMENTAL_MANIFEST_URL) ORB_OPERATOR_VERSION=$(ORB_OPERATOR_VERSION) envsubst '$$DEFAULT_CATALOG,$$CERT_MGR_VERSION,$$INSTALL_DEFAULT_CATALOGS,$$MANIFEST,$$ORB_OPERATOR_VERSION' < scripts/install.tpl.sh > $(EXPERIMENTAL_RELEASE_INSTALL) ##@ Docs diff --git a/go.mod b/go.mod index 3292d1ed0d..6ff1c8aed3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/google/renameio/v2 v2.0.2 github.com/gorilla/handlers v1.5.2 github.com/graphql-go/graphql v0.8.1 + github.com/joelanford/orb-operator v0.0.3 github.com/klauspost/compress v1.19.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -47,7 +48,7 @@ require ( k8s.io/component-base v0.36.3 k8s.io/klog/v2 v2.140.0 k8s.io/utils v0.0.0-20260626114624-be93311217bd - pkg.package-operator.run/boxcutter v0.14.0 + pkg.package-operator.run/boxcutter v0.14.1-0.20260710084406-8f7a02854da8 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/controller-tools v0.21.0 sigs.k8s.io/crdify v0.6.1-0.20260602124154-bb9957dbf465 @@ -108,20 +109,19 @@ require ( github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.0 // indirect + github.com/go-openapi/swag/cmdutils v0.27.0 // indirect + github.com/go-openapi/swag/conv v0.27.0 // indirect + github.com/go-openapi/swag/fileutils v0.27.0 // indirect + github.com/go-openapi/swag/jsonutils v0.27.0 // indirect + github.com/go-openapi/swag/loading v0.27.0 // indirect + github.com/go-openapi/swag/mangling v0.27.0 // indirect + github.com/go-openapi/swag/netutils v0.27.0 // indirect + github.com/go-openapi/swag/stringutils v0.27.0 // indirect + github.com/go-openapi/swag/typeutils v0.27.0 // indirect + github.com/go-openapi/swag/yamlutils v0.27.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gobwas/glob v0.2.3 // indirect diff --git a/go.sum b/go.sum index d24232f742..6a7f4d411e 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoL github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -171,40 +171,38 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE= +github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.27.0 h1:ib5jMUqGq5tY1EyO4inlrabsaeDAleFU+XD1FXQcgp8= +github.com/go-openapi/swag/fileutils v0.27.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= +github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= +github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= +github.com/go-openapi/swag/mangling v0.27.0 h1:rpPJuqQHa6z2pDiP3iIpXOyNXlSs9cQCxnJSAxzdfOc= +github.com/go-openapi/swag/mangling v0.27.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= +github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= +github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= @@ -322,6 +320,8 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joelanford/ignore v0.1.2 h1:n/9VnxthX0NVx8VOdB6quLFOV4AcAGqaZOonrXt55HU= github.com/joelanford/ignore v0.1.2/go.mod h1:45jOCHVmjfCK/1ZhM05iog2HRkmfv22VqBUgPl+e2iA= +github.com/joelanford/orb-operator v0.0.3 h1:Yj1cy6YxCiKsd+fzBoaCsyGYsmb/7QsRPOEBEOYRxCc= +github.com/joelanford/orb-operator v0.0.3/go.mod h1:lH5aVYEQ03k9PTHAyhJ0FDCpb72L5fpWFGxl84/aJ34= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -799,8 +799,8 @@ k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI0 k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= -pkg.package-operator.run/boxcutter v0.14.0 h1:6aUYMtj/gfyHkro/uIAYA9iRmkRweeqOVKRrf+j4dZU= -pkg.package-operator.run/boxcutter v0.14.0/go.mod h1:9FXDqdX+TzIf6clHoKeNfSR7pD1QgWfXhQX7uNJ6iO8= +pkg.package-operator.run/boxcutter v0.14.1-0.20260710084406-8f7a02854da8 h1:wbFGCYJDExLtjnsYZkfnlICOpe4x91i/iqBa6xoDjCE= +pkg.package-operator.run/boxcutter v0.14.1-0.20260710084406-8f7a02854da8/go.mod h1:MCeFUw1k4BNLEJ7CYRcn4q2QXRy8otOFVw1JAV3bnCs= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/internal/operator-controller/scheme/scheme.go b/internal/operator-controller/scheme/scheme.go index bb8d44ef78..de29c4799e 100644 --- a/internal/operator-controller/scheme/scheme.go +++ b/internal/operator-controller/scheme/scheme.go @@ -1,6 +1,7 @@ package scheme import ( + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" @@ -18,5 +19,6 @@ func init() { utilruntime.Must(ocv1.AddToScheme(Scheme)) utilruntime.Must(appsv1.AddToScheme(Scheme)) utilruntime.Must(corev1.AddToScheme(Scheme)) + utilruntime.Must(orbv1alpha1.AddToScheme(Scheme)) //+kubebuilder:scaffold:scheme } diff --git a/scripts/install.tpl.sh b/scripts/install.tpl.sh index 4be62168bf..38aace3be4 100644 --- a/scripts/install.tpl.sh +++ b/scripts/install.tpl.sh @@ -44,6 +44,7 @@ fi default_catalogs_manifest=$DEFAULT_CATALOG cert_mgr_version=$CERT_MGR_VERSION +orb_operator_version=$ORB_OPERATOR_VERSION install_default_catalogs=$INSTALL_DEFAULT_CATALOGS catalog_wait_timeout=${CATALOG_WAIT_TIMEOUT:-60s} @@ -112,6 +113,17 @@ kubectl_wait "cert-manager" "deployment/cert-manager" "60s" kubectl_wait_for_query "mutatingwebhookconfigurations/cert-manager-webhook" '{.webhooks[0].clientConfig.caBundle}' 60 5 kubectl_wait_for_query "validatingwebhookconfigurations/cert-manager-webhook" '{.webhooks[0].clientConfig.caBundle}' 60 5 +# Install orb-operator only if version is set (experimental variant only). +if [[ -n "$orb_operator_version" ]]; then + if kubectl get crd clusterobjectdeployments.orb.operatorframework.io &>/dev/null && \ + kubectl get deployment -n orb-operator-system orb-operator &>/dev/null; then + echo "orb-operator is already installed, skipping installation" + else + kubectl apply -f "https://github.com/joelanford/orb-operator/releases/download/${orb_operator_version}/install.json" + fi + kubectl_wait "orb-operator-system" "deployment/orb-operator" "60s" +fi + # Change the file into a file:// url if [ -f "${olmv1_manifest}" ]; then olmv1_manifest=file://localhost$(realpath ${olmv1_manifest}) From df36966ff85cb584aa0c83ef279eeaded6453a5b Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 16:56:07 -0400 Subject: [PATCH 04/26] :seedling: Mark orb-operator-dependency spec done --- specs/{ => closed}/2026-08-12-orb-operator-dependency/README.md | 2 +- specs/{ => closed}/2026-08-12-orb-operator-dependency/plan.md | 0 .../2026-08-12-orb-operator-dependency/requirements.md | 0 .../2026-08-12-orb-operator-dependency/verification.md | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename specs/{ => closed}/2026-08-12-orb-operator-dependency/README.md (98%) rename specs/{ => closed}/2026-08-12-orb-operator-dependency/plan.md (100%) rename specs/{ => closed}/2026-08-12-orb-operator-dependency/requirements.md (100%) rename specs/{ => closed}/2026-08-12-orb-operator-dependency/verification.md (100%) diff --git a/specs/2026-08-12-orb-operator-dependency/README.md b/specs/closed/2026-08-12-orb-operator-dependency/README.md similarity index 98% rename from specs/2026-08-12-orb-operator-dependency/README.md rename to specs/closed/2026-08-12-orb-operator-dependency/README.md index 3d872b5122..a9d638e4c4 100644 --- a/specs/2026-08-12-orb-operator-dependency/README.md +++ b/specs/closed/2026-08-12-orb-operator-dependency/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator Dependency diff --git a/specs/2026-08-12-orb-operator-dependency/plan.md b/specs/closed/2026-08-12-orb-operator-dependency/plan.md similarity index 100% rename from specs/2026-08-12-orb-operator-dependency/plan.md rename to specs/closed/2026-08-12-orb-operator-dependency/plan.md diff --git a/specs/2026-08-12-orb-operator-dependency/requirements.md b/specs/closed/2026-08-12-orb-operator-dependency/requirements.md similarity index 100% rename from specs/2026-08-12-orb-operator-dependency/requirements.md rename to specs/closed/2026-08-12-orb-operator-dependency/requirements.md diff --git a/specs/2026-08-12-orb-operator-dependency/verification.md b/specs/closed/2026-08-12-orb-operator-dependency/verification.md similarity index 100% rename from specs/2026-08-12-orb-operator-dependency/verification.md rename to specs/closed/2026-08-12-orb-operator-dependency/verification.md From 5ac911a267769952ab56a90eb32d5c2fa4ca1a97 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 17:15:08 -0400 Subject: [PATCH 05/26] planning: orb-operator feature gate and wiring stub --- .../2026-08-12-orb-operator-wiring/README.md | 150 ++++++++++++++++-- specs/2026-08-12-orb-operator-wiring/plan.md | 29 ++++ .../requirements.md | 19 +++ .../verification.md | 28 ++++ 4 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 specs/2026-08-12-orb-operator-wiring/plan.md create mode 100644 specs/2026-08-12-orb-operator-wiring/requirements.md create mode 100644 specs/2026-08-12-orb-operator-wiring/verification.md diff --git a/specs/2026-08-12-orb-operator-wiring/README.md b/specs/2026-08-12-orb-operator-wiring/README.md index 0284f67c26..47c9207d41 100644 --- a/specs/2026-08-12-orb-operator-wiring/README.md +++ b/specs/2026-08-12-orb-operator-wiring/README.md @@ -1,23 +1,141 @@ --- -status: idea +status: in-progress --- -# orb-operator Wiring +# orb-operator Feature Gate and Wiring Stub -Wire up the orb-operator applier path in `main.go` behind a new feature gate, following the same pattern as the existing Helm/Boxcutter selection. This makes the orb-operator runtime selectable at startup. +## Summary -## Deliverables +Add the `OrbOperatorRuntime` feature gate and wire it into `main.go` following the existing Helm/Boxcutter configurator pattern. Create a stub `OrbOperator` applier type with the shared-infrastructure fields it will need (client, scheme, preflights, field owner) but no actual implementation yet. OrbOperator-specific fields (CODGenerator, etc.) will be added in later phases when the applier is implemented. -- `OrbOperatorRuntime` feature gate in `internal/operator-controller/features/features.go` (alpha, default off) -- `orbOperatorReconcilerConfigurator` in `main.go` implementing the existing configurator pattern: - - Constructs `OrbOperator` applier with CODGenerator, preflights, and field owner - - Sets reconcile steps: HandleFinalizers, ValidateClusterExtension, RetrieveRevisionStates, ResolveBundle, UnpackBundle, ApplyBundleWithOrbOperator - - Registers finalizers (cleanup on CE deletion) -- Cache options: add COD, COS, COSL to the informer cache when the feature gate is enabled -- Controller builder: `WithOwns` for COD resources, watches for COS changes -- Feature gate selection logic alongside the existing Helm/Boxcutter check -- Integration with existing e2e test hooks for feature-gate-aware test setup +This establishes the feature gate, cache configuration, controller watches, and reconcile step pipeline so that subsequent phases can focus purely on applier logic without touching wiring. -## Dependencies +## Design -- orb-operator-reconcile-step (for the step function and revision states getter) -- orb-operator-dependency (for scheme registration and deployment manifests) +### Feature gate + +Add `OrbOperatorRuntime` to `internal/operator-controller/features/features.go`: + +```go +OrbOperatorRuntime featuregate.Feature = "OrbOperatorRuntime" +``` + +Alpha, default off, not locked. `OrbOperatorRuntime` and `BoxcutterRuntime` are mutually exclusive - enabling both is a startup error. + +### Mutual exclusivity check + +In `run()`, after feature gate flags are parsed but before any wiring, validate: + +```go +if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) && + features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + return fmt.Errorf("BoxcutterRuntime and OrbOperatorRuntime feature gates are mutually exclusive") +} +``` + +### OrbOperator applier stub + +Create `internal/operator-controller/applier/orboperator.go` with a stub type: + +```go +type OrbOperator struct { + Client client.Client + Scheme *runtime.Scheme + Preflights []Preflight + FieldOwner string +} + +func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, + objectLabels, revisionAnnotations map[string]string) (bool, string, error) { + return false, "", fmt.Errorf("OrbOperatorRuntime applier not yet implemented") +} +``` + +This implements the existing `Applier` interface. The shared fields (Client, Scheme, Preflights, FieldOwner) come from the same infrastructure already set up in main.go for Helm/Boxcutter. OrbOperator-specific fields (CODGenerator, etc.) are added in later phases. + +### Configurator + +Add `orbOperatorReconcilerConfigurator` to `main.go` following the existing pattern: + +```go +type orbOperatorReconcilerConfigurator struct { + mgr manager.Manager + preflights []applier.Preflight + regv1ManifestProvider applier.ManifestProvider + resolver resolve.Resolver + imageCache imageutil.Cache + imagePuller imageutil.Puller + finalizers crfinalizer.Finalizers +} +``` + +No `trackingCache` field - the orb-operator COD controller handles drift detection, so the tracking cache is not needed for this path. + +The `Configure` method: +1. Registers a no-op finalizer for `ClusterExtensionCleanupContentManagerCacheFinalizer` (same pattern as Boxcutter - orb-operator doesn't use contentmanager either). +2. Constructs the `OrbOperator` applier with the shared fields. +3. Sets reconcile steps using `ApplyBundle(appl)` - the existing step function works since `OrbOperator` implements `Applier`. (A custom step function can replace this in a later phase if needed.) +4. For `RevisionStatesGetter`, uses a stub that returns empty states (no installed/rolling-out state). This is temporary until the orb-operator-specific revision states getter is implemented. + +### Cache options + +When `OrbOperatorRuntime` is enabled, add to the informer cache with label selectors scoped to resources managed by the CE controller: + +- `orbv1alpha1.ClusterObjectDeployment` - label selector: `olm.operatorframework.io/owner-kind=ClusterExtension` (set on COD top-level metadata by the applier) +- `orbv1alpha1.ClusterObjectSet` - label selector: `olm.operatorframework.io/owner-kind=ClusterExtension` (propagated from COD `template.metadata.labels` by orb-operator's `template.BuildCOS`, which clones template labels onto each COS revision) +- `orbv1alpha1.ClusterObjectSlice` - label selector: `olm.operatorframework.io/owner-kind=ClusterExtension` (set directly by the applier when creating slices) + +Note: the applier must set the owner-kind label in both the COD's top-level metadata and the COD template's metadata. Top-level labels filter CODs in the cache; template labels propagate to COSs. + +### Field indexer + +Register a field indexer on `spec.group` for `ClusterObjectSet` in the `Configure` method: + +```go +mgr.GetFieldIndexer().IndexField(ctx, &orbv1alpha1.ClusterObjectSet{}, "spec.group", + func(obj client.Object) []string { + return []string{obj.(*orbv1alpha1.ClusterObjectSet).Spec.Group} + }) +``` + +Since `spec.group` equals the COD name (which equals the CE name), this enables efficient lookups: `client.MatchingFields{"spec.group": ext.Name}` instead of listing all COSs and filtering in-process. + +### Controller builder options + +When `OrbOperatorRuntime` is enabled: +- `WithOwns(&orbv1alpha1.ClusterObjectDeployment{})` - re-reconcile CE when its COD changes +- `WithOwns(&orbv1alpha1.ClusterObjectSlice{})` - re-reconcile CE when its COSLs change + +### Feature gate selection + +The three-way selection in `main.go`: +```go +if features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + cerCfg = &orbOperatorReconcilerConfigurator{...} +} else if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + cerCfg = &boxcutterReconcilerConfigurator{...} +} else { + cerCfg = &helmReconcilerConfigurator{...} +} +``` + +### Stub RevisionStatesGetter + +A minimal implementation in the controllers package: + +```go +type OrbOperatorRevisionStatesGetter struct{} + +func (o *OrbOperatorRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*RevisionStates, error) { + return &RevisionStates{}, nil +} +``` + +This returns empty revision states. The real implementation will read COD/COS status in a later phase. + +### What this does NOT include + +- Actual `Apply` implementation (returns error) +- CODGenerator, externalizer, or COSL GC +- Custom reconcile step function (uses existing `ApplyBundle`) +- Real revision states getter (uses stub) +- Storage migration from Helm/Boxcutter diff --git a/specs/2026-08-12-orb-operator-wiring/plan.md b/specs/2026-08-12-orb-operator-wiring/plan.md new file mode 100644 index 0000000000..cc623a1409 --- /dev/null +++ b/specs/2026-08-12-orb-operator-wiring/plan.md @@ -0,0 +1,29 @@ +# Implementation Plan + +1. Add feature gate + - Add `OrbOperatorRuntime` constant and feature spec in `internal/operator-controller/features/features.go` + - Add mutual exclusivity check in `run()` in `main.go`, after feature gate flags are parsed + +2. Create stub applier + - Create `internal/operator-controller/applier/orboperator.go` with the `OrbOperator` struct and stub `Apply` method + - Fields: `Client`, `Scheme`, `Preflights`, `FieldOwner` + - `Apply` returns `false, "", fmt.Errorf("OrbOperatorRuntime applier not yet implemented")` + +3. Create stub revision states getter + - Add `OrbOperatorRevisionStatesGetter` in the controllers package (e.g., in a new file `orboperator_reconcile_steps.go` or alongside existing step code) + - Returns `&RevisionStates{}, nil` + +4. Add configurator and wiring in main.go + - Add `orbOperatorReconcilerConfigurator` struct (without `trackingCache` field) + - Implement `Configure` method: + - Register no-op finalizer for `ClusterExtensionCleanupContentManagerCacheFinalizer` + - Construct `applier.OrbOperator` with shared fields + - Set reconcile steps: HandleFinalizers, ValidateClusterExtension, RetrieveRevisionStates, ResolveBundle, UnpackBundle, ApplyBundle + - Update cache options: add COD, COS, COSL to informer cache when `OrbOperatorRuntime` is enabled + - Register field indexer on `spec.group` for COS in the `Configure` method + - Update controller builder options: `WithOwns(&orbv1alpha1.ClusterObjectDeployment{})` when `OrbOperatorRuntime` is enabled + - Update feature gate selection: three-way if/else (OrbOperator, Boxcutter, Helm) + +5. Verify + - `make build` + - `make test-unit` diff --git a/specs/2026-08-12-orb-operator-wiring/requirements.md b/specs/2026-08-12-orb-operator-wiring/requirements.md new file mode 100644 index 0000000000..6c86f99dda --- /dev/null +++ b/specs/2026-08-12-orb-operator-wiring/requirements.md @@ -0,0 +1,19 @@ +# Requirements + +- Add `OrbOperatorRuntime` feature gate (alpha, default off) +- `OrbOperatorRuntime` and `BoxcutterRuntime` are mutually exclusive; enabling both must fail at startup +- Add `orbOperatorReconcilerConfigurator` following the existing configurator pattern +- Create stub `OrbOperator` applier type implementing the `Applier` interface +- Create stub `OrbOperatorRevisionStatesGetter` implementing `RevisionStatesGetter` +- Configure informer cache for COD, COS, and COSL types when `OrbOperatorRuntime` is enabled +- Add controller builder option to own COD resources +- The stub applier's `Apply` method must return an error indicating it is not yet implemented +- Helm and Boxcutter paths must be completely unaffected when `OrbOperatorRuntime` is not enabled + +## Acceptance Criteria + +- `make build` succeeds +- `make test-unit` passes +- Enabling `--feature-gates=OrbOperatorRuntime=true` starts the controller without panic (reconciliation returns the "not implemented" error on each attempt) +- Enabling both `--feature-gates=BoxcutterRuntime=true,OrbOperatorRuntime=true` fails at startup with a clear error +- The default path (both gates off) still uses Helm, unchanged diff --git a/specs/2026-08-12-orb-operator-wiring/verification.md b/specs/2026-08-12-orb-operator-wiring/verification.md new file mode 100644 index 0000000000..f4072e13e7 --- /dev/null +++ b/specs/2026-08-12-orb-operator-wiring/verification.md @@ -0,0 +1,28 @@ +# Verification + +## Implementation Correctness + +- [ ] `OrbOperatorRuntime` feature gate exists in `features.go` (alpha, default off, not locked) +- [ ] Mutual exclusivity check prevents enabling both `BoxcutterRuntime` and `OrbOperatorRuntime` +- [ ] `OrbOperator` struct in `applier/orboperator.go` has fields: `Client`, `Scheme`, `Preflights`, `FieldOwner` +- [ ] `OrbOperator.Apply` returns an error (not a panic) +- [ ] `OrbOperatorRevisionStatesGetter` returns empty `RevisionStates` +- [ ] `orbOperatorReconcilerConfigurator` struct does not include `trackingCache` +- [ ] `Configure` method registers a no-op finalizer for `ClusterExtensionCleanupContentManagerCacheFinalizer` +- [ ] Reconcile steps use `ApplyBundle(appl)` (the existing Applier-interface step) +- [ ] Cache options include COD, COS, COSL when `OrbOperatorRuntime` is enabled, each with a label selector filtering on `olm.operatorframework.io/owner-kind=ClusterExtension` +- [ ] Field indexer on `spec.group` is registered for COS in the `Configure` method +- [ ] Controller builder uses `WithOwns(&orbv1alpha1.ClusterObjectDeployment{})` and `WithOwns(&orbv1alpha1.ClusterObjectSlice{})` when `OrbOperatorRuntime` is enabled +- [ ] Three-way feature gate selection: OrbOperator -> Boxcutter -> Helm + +## Build Verification + +- [ ] `make build` succeeds +- [ ] `make test-unit` passes + +## Project Conventions + +- [ ] Commit message uses `:seedling:` prefix (infrastructure/wiring change) +- [ ] No unnecessary code changes beyond what is specified +- [ ] Feature gate follows existing patterns (same struct, same registration) +- [ ] Configurator follows existing patterns (same fields, same Configure signature) From 20743103f831be136cc9684f1ece22547badb99d Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 17:28:20 -0400 Subject: [PATCH 06/26] :seedling: Add OrbOperatorRuntime feature gate and wiring stub --- cmd/operator-controller/main.go | 89 ++- config/samples/olm_v1_clusterextension.yaml | 2 +- helm/experimental.yaml | 2 +- helm/tilt.yaml | 6 +- .../applier/orboperator.go | 24 + .../orboperator_reconcile_steps.go | 13 + .../operator-controller/features/features.go | 8 + manifests/experimental-e2e.yaml | 675 +----------------- manifests/experimental.yaml | 675 +----------------- 9 files changed, 141 insertions(+), 1353 deletions(-) create mode 100644 internal/operator-controller/applier/orboperator.go create mode 100644 internal/operator-controller/controllers/orboperator_reconcile_steps.go diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 2fcea83ef0..3bf093b912 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -28,6 +28,7 @@ import ( "strings" "time" + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" "github.com/spf13/cobra" "go.podman.io/image/v5/types" corev1 "k8s.io/api/core/v1" @@ -68,6 +69,7 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/finalizers" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" @@ -132,6 +134,16 @@ type helmReconcilerConfigurator struct { trackingCache managedcache.TrackingCache } +type orbOperatorReconcilerConfigurator struct { + mgr manager.Manager + preflights []applier.Preflight + regv1ManifestProvider applier.ManifestProvider + resolver resolve.Resolver + imageCache imageutil.Cache + imagePuller imageutil.Puller + finalizers crfinalizer.Finalizers +} + const ( authFilePrefix = "operator-controller-global-pull-secrets" fieldOwnerPrefix = "olm.operatorframework.io" @@ -226,6 +238,11 @@ func run() error { // log feature gate status after parsing flags and setting up logger features.LogFeatureGateStates(setupLog, features.OperatorControllerFeatureGate) + if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) && + features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + return fmt.Errorf("BoxcutterRuntime and OrbOperatorRuntime feature gates are mutually exclusive") + } + authFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s.json", authFilePrefix, apimachineryrand.String(8))) var globalPullSecretKey *k8stypes.NamespacedName if cfg.globalPullSecret != "" { @@ -266,6 +283,21 @@ func run() error { } } + if features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + ownerKindSelector := k8slabels.SelectorFromSet(k8slabels.Set{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + }) + cacheOptions.ByObject[&orbv1alpha1.ClusterObjectDeployment{}] = crcache.ByObject{ + Label: ownerKindSelector, + } + cacheOptions.ByObject[&orbv1alpha1.ClusterObjectSet{}] = crcache.ByObject{ + Label: ownerKindSelector, + } + cacheOptions.ByObject[&orbv1alpha1.ClusterObjectSlice{}] = crcache.ByObject{ + Label: ownerKindSelector, + } + } + saKey, err := sautil.GetServiceAccount() if err != nil { setupLog.Error(err, "Failed to extract serviceaccount from JWT") @@ -477,7 +509,12 @@ func run() error { } var ctrlBuilderOpts []controllers.ControllerBuilderOption - if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + if features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + ctrlBuilderOpts = append(ctrlBuilderOpts, + controllers.WithOwns(&orbv1alpha1.ClusterObjectDeployment{}), + controllers.WithOwns(&orbv1alpha1.ClusterObjectSlice{}), + ) + } else if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithOwns(&ocv1.ClusterObjectSet{})) } else { ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithWatchesRawSource( @@ -509,7 +546,17 @@ func run() error { IsDeploymentConfigEnabled: features.OperatorControllerFeatureGate.Enabled(features.DeploymentConfig), } var cerCfg reconcilerConfigurator - if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { + if features.OperatorControllerFeatureGate.Enabled(features.OrbOperatorRuntime) { + cerCfg = &orbOperatorReconcilerConfigurator{ + mgr: mgr, + preflights: preflights, + regv1ManifestProvider: regv1ManifestProvider, + resolver: resolver, + imageCache: imageCache, + imagePuller: imagePuller, + finalizers: clusterExtensionFinalizers, + } + } else if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { cerCfg = &boxcutterReconcilerConfigurator{ mgr: mgr, preflights: preflights, @@ -697,6 +744,44 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl return nil } +func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers.ClusterExtensionReconciler) error { + err := c.finalizers.Register(controllers.ClusterExtensionCleanupContentManagerCacheFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { + return crfinalizer.Result{}, nil + })) + if err != nil { + setupLog.Error(err, "unable to register content manager cleanup finalizer for orb-operator") + return err + } + + if err := c.mgr.GetFieldIndexer().IndexField(context.Background(), &orbv1alpha1.ClusterObjectSet{}, "spec.group", + func(obj client.Object) []string { + return []string{obj.(*orbv1alpha1.ClusterObjectSet).Spec.Group} + }); err != nil { + return fmt.Errorf("unable to create field indexer for ClusterObjectSet spec.group: %w", err) + } + + fieldOwner := fmt.Sprintf("%s/clusterextension-controller", fieldOwnerPrefix) + appl := &applier.OrbOperator{ + Client: c.mgr.GetClient(), + Scheme: c.mgr.GetScheme(), + Preflights: c.preflights, + FieldOwner: fieldOwner, + } + revisionStatesGetter := &controllers.OrbOperatorRevisionStatesGetter{} + ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{ + controllers.HandleFinalizers(c.finalizers), + controllers.ValidateClusterExtension( + controllers.ServiceAccountDeprecationWarning(), + ), + controllers.RetrieveRevisionStates(revisionStatesGetter), + controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), + controllers.UnpackBundle(c.imagePuller, c.imageCache), + controllers.ApplyBundle(appl), + } + + return nil +} + func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.ClusterExtensionReconciler) error { coreClient, err := corev1client.NewForConfig(c.mgr.GetConfig()) if err != nil { diff --git a/config/samples/olm_v1_clusterextension.yaml b/config/samples/olm_v1_clusterextension.yaml index 658746dbaf..ef7fc39dbd 100644 --- a/config/samples/olm_v1_clusterextension.yaml +++ b/config/samples/olm_v1_clusterextension.yaml @@ -14,4 +14,4 @@ spec: sourceType: Catalog catalog: packageName: argocd-operator - version: 0.6.0 + version: 0.13.0 diff --git a/helm/experimental.yaml b/helm/experimental.yaml index cfbfa16afb..8f4dca7ee3 100644 --- a/helm/experimental.yaml +++ b/helm/experimental.yaml @@ -11,9 +11,9 @@ options: replicas: 2 features: enabled: - - BoxcutterRuntime - BundleReleaseSupport - DeploymentConfig + - OrbOperatorRuntime - SingleOwnNamespaceInstallSupport - WebhookProviderCertManager disabled: diff --git a/helm/tilt.yaml b/helm/tilt.yaml index b1f3d07ce8..8a6ee64661 100644 --- a/helm/tilt.yaml +++ b/helm/tilt.yaml @@ -14,10 +14,14 @@ options: operatorController: features: enabled: - - SingleOwnNamespaceInstallSupport + - BundleReleaseSupport + - DeploymentConfig + - OrbOperatorRuntime + - WebhookProviderCertManager disabled: - WebhookProviderOpenshiftServiceCA catalogd: features: enabled: - APIV1MetasHandler + - GraphQLCatalogQueries diff --git a/internal/operator-controller/applier/orboperator.go b/internal/operator-controller/applier/orboperator.go new file mode 100644 index 0000000000..cda03013f3 --- /dev/null +++ b/internal/operator-controller/applier/orboperator.go @@ -0,0 +1,24 @@ +package applier + +import ( + "context" + "io/fs" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +type OrbOperator struct { + Client client.Client + Scheme *runtime.Scheme + Preflights []Preflight + FieldOwner string +} + +func (o *OrbOperator) Apply(ctx context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (bool, string, error) { + log.FromContext(ctx).Info("OrbOperatorRuntime applier not yet implemented") + return false, "", nil +} diff --git a/internal/operator-controller/controllers/orboperator_reconcile_steps.go b/internal/operator-controller/controllers/orboperator_reconcile_steps.go new file mode 100644 index 0000000000..16ad6892ad --- /dev/null +++ b/internal/operator-controller/controllers/orboperator_reconcile_steps.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "context" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +type OrbOperatorRevisionStatesGetter struct{} + +func (o *OrbOperatorRevisionStatesGetter) GetRevisionStates(_ context.Context, _ *ocv1.ClusterExtension) (*RevisionStates, error) { + return &RevisionStates{}, nil +} diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 610295dd9c..39e6f0c462 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -15,6 +15,7 @@ const ( WebhookProviderCertManager featuregate.Feature = "WebhookProviderCertManager" WebhookProviderOpenshiftServiceCA featuregate.Feature = "WebhookProviderOpenshiftServiceCA" BoxcutterRuntime featuregate.Feature = "BoxcutterRuntime" + OrbOperatorRuntime featuregate.Feature = "OrbOperatorRuntime" DeploymentConfig featuregate.Feature = "DeploymentConfig" BundleReleaseSupport featuregate.Feature = "BundleReleaseSupport" ) @@ -58,6 +59,13 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature LockToDefault: false, }, + // OrbOperatorRuntime configures OLM to use the orb-operator runtime for extension lifecycling + OrbOperatorRuntime: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, + // DeploymentConfig enables support for customizing operator deployments // via spec.config.inline.deploymentConfig in ClusterExtension resources. DeploymentConfig: { diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 6d9346b4ae..70105ef968 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1346,679 +1346,6 @@ spec: subresources: status: {} --- -# Source: olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.20.1 - olm.operatorframework.io/generator: experimental - name: clusterobjectsets.olm.operatorframework.io -spec: - group: olm.operatorframework.io - names: - kind: ClusterObjectSet - listKind: ClusterObjectSetList - plural: clusterobjectsets - singular: clusterobjectset - scope: Cluster - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=='Available')].status - name: Available - type: string - - jsonPath: .status.conditions[?(@.type=='Progressing')].status - name: Progressing - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - ClusterObjectSet represents an immutable snapshot of Kubernetes objects - for a specific version of a ClusterExtension. Each revision contains objects - organized into phases that roll out sequentially. The same object can only be managed by a single revision - at a time. Ownership of objects is transitioned from one revision to the next as the extension is upgraded - or reconfigured. Once the latest revision has rolled out successfully, previous active revisions are archived for - posterity. - 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 the ClusterObjectSet. - properties: - collisionProtection: - description: |- - collisionProtection specifies the default collision protection strategy for all objects - in this revision. Individual phases or objects can override this value. - - When set, this value is used as the default for any phase or object that does not - explicitly specify its own collisionProtection. - - The resolution order is: object > phase > spec - enum: - - Prevent - - IfNoController - - None - type: string - x-kubernetes-validations: - - message: collisionProtection is immutable - rule: self == oldSelf - lifecycleState: - description: |- - lifecycleState specifies the lifecycle state of the ClusterObjectSet. - - When set to "Active", the revision is actively managed and reconciled. - When set to "Archived", the revision is inactive and any resources not managed by a subsequent revision are deleted. - The revision is removed from the owner list of all objects previously under management. - All objects that did not transition to a succeeding revision are deleted. - - Once a revision is set to "Archived", it cannot be un-archived. - - It is possible for more than one revision to be "Active" simultaneously. This will occur when - moving from one revision to another. The old revision will not be set to "Archived" until the - new revision has been completely rolled out. - enum: - - Active - - Archived - type: string - x-kubernetes-validations: - - message: cannot un-archive - rule: oldSelf == 'Active' || oldSelf == 'Archived' && oldSelf == - self - phases: - description: |- - phases is an optional, immutable list of phases that group objects to be applied together. - - Objects are organized into phases based on their Group-Kind. Common phases include: - - namespaces: Namespace objects - - policies: ResourceQuota, LimitRange, NetworkPolicy objects - - rbac: ServiceAccount, Role, RoleBinding, ClusterRole, ClusterRoleBinding objects - - crds: CustomResourceDefinition objects - - storage: PersistentVolume, PersistentVolumeClaim, StorageClass objects - - deploy: Deployment, StatefulSet, DaemonSet, Service, ConfigMap, Secret objects - - publish: Ingress, APIService, Route, Webhook objects - - All objects in a phase are applied in no particular order. - The revision progresses to the next phase only after all objects in the current phase pass their readiness probes. - - Once set, even if empty, the phases field is immutable. - - Each phase in the list must have a unique name. The maximum number of phases is 20. - items: - description: |- - ClusterObjectSetPhase represents a group of objects that are applied together. The phase is considered - complete only after all objects pass their status probes. - properties: - collisionProtection: - description: |- - collisionProtection specifies the default collision protection strategy for all objects - in this phase. Individual objects can override this value. - - When set, this value is used as the default for any object in this phase that does not - explicitly specify its own collisionProtection. - - When omitted, we use .spec.collistionProtection as the default for any object in this phase that does not - explicitly specify its own collisionProtection. - enum: - - Prevent - - IfNoController - - None - type: string - name: - description: |- - name is a required identifier for this phase. - - phase names must follow the DNS label standard as defined in [RFC 1123]. - They must contain only lowercase alphanumeric characters or hyphens (-), - start and end with an alphanumeric character, and be no longer than 63 characters. - - Common phase names include: namespaces, policies, rbac, crds, storage, deploy, publish. - - [RFC 1123]: https://tools.ietf.org/html/rfc1123 - maxLength: 63 - minLength: 1 - type: string - x-kubernetes-validations: - - message: the value must consist of only lowercase alphanumeric - characters and hyphens, and must start and end with an alphanumeric - character. - rule: '!format.dns1123Label().validate(self).hasValue()' - objects: - description: |- - objects is a required list of all Kubernetes objects that belong to this phase. - - All objects in this list are applied to the cluster in no particular order. The maximum number of objects per phase is 50. - items: - description: |- - ClusterObjectSetObject represents a Kubernetes object to be applied as part - of a phase, along with its collision protection settings. - - Exactly one of object or ref must be set. - properties: - collisionProtection: - description: |- - collisionProtection controls whether the operator can adopt and modify objects - that already exist on the cluster. - - Allowed values are: "Prevent", "IfNoController", and "None". - - When set to "Prevent", the operator only manages objects it created itself. - This prevents ownership collisions. - - When set to "IfNoController", the operator can adopt and modify pre-existing objects - that are not owned by another controller. - This is useful for taking over management of manually-created resources. - - When set to "None", the operator can adopt and modify any pre-existing object, even if - owned by another controller. - Use this setting with extreme caution as it may cause multiple controllers to fight over - the same resource, resulting in increased load on the API server and etcd. - - When omitted, the value is inherited from the phase, then spec. - enum: - - Prevent - - IfNoController - - None - type: string - object: - description: |- - object is an optional embedded Kubernetes object to be applied. - - Exactly one of object or ref must be set. - - This object must be a valid Kubernetes resource with apiVersion, kind, and metadata fields. - type: object - x-kubernetes-embedded-resource: true - x-kubernetes-preserve-unknown-fields: true - ref: - description: |- - ref is an optional reference to a Secret that holds the serialized - object manifest. - - Exactly one of object or ref must be set. - properties: - key: - description: |- - key is the data key within the referenced Secret containing the - object manifest content. The value at this key must be a - JSON-serialized Kubernetes object manifest. - maxLength: 253 - minLength: 1 - type: string - name: - description: name is the name of the referenced Secret. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - namespace is the namespace of the referenced Secret. - When empty, defaults to the OLM system namespace during ref resolution. - maxLength: 63 - type: string - required: - - key - - name - type: object - type: object - x-kubernetes-validations: - - message: exactly one of the fields in [object ref] must - be set - rule: '[has(self.object),has(self.ref)].filter(x,x==true).size() - == 1' - maxItems: 50 - type: array - required: - - name - - objects - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: phases is immutable - rule: self == oldSelf || oldSelf.size() == 0 - progressDeadlineMinutes: - description: |- - progressDeadlineMinutes is an optional field that defines the maximum period - of time in minutes after which an installation should be considered failed and - require manual intervention. This functionality is disabled when no value - is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours). - format: int32 - maximum: 720 - minimum: 10 - type: integer - progressionProbes: - description: |- - progressionProbes is an optional field which provides the ability to define custom readiness probes - for objects defined within spec.phases. As documented in that field, most kubernetes-native objects - within the phases already have some kind of readiness check built-in, but this field allows for checks - which are tailored to the objects being rolled out - particularly custom resources. - - Probes defined within the progressionProbes list will apply to every phase in the revision. However, the probes will only - execute against phase objects which are a match for the provided selector type. For instance, a probe using a GroupKind selector - for ConfigMaps will automatically be considered to have passed for any non-ConfigMap object, but will halt any phase containing - a ConfigMap if that particular object does not pass the probe check. - - The maximum number of probes is 20. - items: - description: ProgressionProbe provides a custom probe definition, - consisting of an object selection method and assertions. - properties: - assertions: - description: |- - assertions is a required list of checks which will run against the objects selected by the selector. If - one or more assertions fail then the phase within which the object lives will be not be considered - 'Ready', blocking rollout of all subsequent phases. - items: - description: Assertion is a discriminated union which defines - the probe type and definition used as an assertion. - properties: - conditionEqual: - description: conditionEqual contains the expected condition - type and status. - properties: - status: - description: |- - status sets the expected condition status. - - Allowed values are "True" and "False". - enum: - - "True" - - "False" - type: string - type: - description: type sets the expected condition type, - i.e. "Ready". - maxLength: 200 - minLength: 1 - type: string - required: - - status - - type - type: object - fieldValue: - description: fieldValue contains the expected field path - and value found within. - properties: - fieldPath: - description: |- - fieldPath sets the field path for the field to check, i.e. "status.phase". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - value: - description: value sets the expected value found at - fieldPath, i.e. "Bound". - maxLength: 200 - minLength: 1 - type: string - required: - - fieldPath - - value - type: object - fieldsEqual: - description: fieldsEqual contains the two field paths - whose values are expected to match. - properties: - fieldA: - description: |- - fieldA sets the field path for the first field, i.e. "spec.replicas". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - fieldB: - description: |- - fieldB sets the field path for the second field, i.e. "status.readyReplicas". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - required: - - fieldA - - fieldB - type: object - type: - description: |- - type is a required field which specifies the type of probe to use. - - The allowed probe types are "ConditionEqual", "FieldsEqual", and "FieldValue". - - When set to "ConditionEqual", the probe checks objects that have reached a condition of specified type and status. - When set to "FieldsEqual", the probe checks that the values found at two provided field paths are matching. - When set to "FieldValue", the probe checks that the value found at the provided field path matches what was specified. - enum: - - ConditionEqual - - FieldsEqual - - FieldValue - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: conditionEqual is required when type is ConditionEqual, - and forbidden otherwise - rule: 'self.type == ''ConditionEqual'' ?has(self.conditionEqual) - : !has(self.conditionEqual)' - - message: fieldsEqual is required when type is FieldsEqual, - and forbidden otherwise - rule: 'self.type == ''FieldsEqual'' ?has(self.fieldsEqual) - : !has(self.fieldsEqual)' - - message: fieldValue is required when type is FieldValue, - and forbidden otherwise - rule: 'self.type == ''FieldValue'' ?has(self.fieldValue) - : !has(self.fieldValue)' - maxItems: 20 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - selector: - description: |- - selector is a required field which defines the method by which we select objects to apply the below - assertions to. Any object which matches the defined selector will have all the associated assertions - applied against it. - - If no objects within a phase are selected by the provided selector, then all assertions defined here - are considered to have succeeded. - properties: - groupKind: - description: |- - groupKind specifies the group and kind of objects to select. - - Required when type is "GroupKind". - - Uses the Kubernetes format specified here: - https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#GroupKind - properties: - group: - type: string - kind: - type: string - required: - - group - - kind - type: object - label: - description: |- - label is the label selector definition. - - Required when type is "Label". - - A probe using a Label selector will be executed against every object matching the labels or expressions; you must use care - when using this type of selector. For example, if multiple Kind objects are selected via labels then the probe is - likely to fail because the values of different Kind objects rarely share the same schema. - - The LabelSelector field uses the following Kubernetes format: - https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#LabelSelector - Requires exactly one of matchLabels or matchExpressions. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - 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 - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - x-kubernetes-validations: - - message: exactly one of matchLabels or matchExpressions - must be set - rule: (has(self.matchExpressions) && !has(self.matchLabels)) - || (!has(self.matchExpressions) && has(self.matchLabels)) - type: - description: |- - type is a required field which specifies the type of selector to use. - - The allowed selector types are "GroupKind" and "Label". - - When set to "GroupKind", all objects which match the specified group and kind will be selected. - When set to "Label", all objects which match the specified labels and/or expressions will be selected. - enum: - - GroupKind - - Label - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: groupKind is required when type is GroupKind, and - forbidden otherwise - rule: 'self.type == ''GroupKind'' ?has(self.groupKind) : !has(self.groupKind)' - - message: label is required when type is Label, and forbidden - otherwise - rule: 'self.type == ''Label'' ?has(self.label) : !has(self.label)' - required: - - assertions - - selector - type: object - maxItems: 20 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - revision: - description: |- - revision is a required, immutable sequence number representing a specific revision - of the parent ClusterExtension. - - The revision field must be a positive integer. - Each ClusterObjectSet belonging to the same parent ClusterExtension must have a unique revision number. - The revision number must always be the previous revision number plus one, or 1 for the first revision. - format: int64 - minimum: 1 - type: integer - x-kubernetes-validations: - - message: revision is immutable - rule: self == oldSelf - required: - - collisionProtection - - lifecycleState - - revision - type: object - status: - description: status is optional and defines the observed state of the - ClusterObjectSet. - properties: - conditions: - description: |- - conditions is an optional list of status conditions describing the state of the - ClusterObjectSet. - - The Progressing condition represents whether the revision is actively rolling out: - - When status is True and reason is RollingOut, the ClusterObjectSet rollout is actively making progress and is in transition. - - When status is True and reason is Retrying, the ClusterObjectSet has encountered an error that could be resolved on subsequent reconciliation attempts. - - When status is True and reason is Succeeded, the ClusterObjectSet has reached the desired state. - - When status is False and reason is Blocked, the ClusterObjectSet has encountered an error that requires manual intervention for recovery. - - When status is False and reason is Archived, the ClusterObjectSet is archived and not being actively reconciled. - - The Available condition represents whether the revision has been successfully rolled out and is available: - - When status is True and reason is ProbesSucceeded, the ClusterObjectSet has been successfully rolled out and all objects pass their readiness probes. - - When status is False and reason is ProbeFailure, one or more objects are failing their readiness probes during rollout. - - When status is Unknown and reason is Reconciling, the ClusterObjectSet has encountered an error that prevented it from observing the probes. - - When status is Unknown and reason is Archived, the ClusterObjectSet has been archived and its objects have been torn down. - - When status is Unknown and reason is Migrated, the ClusterObjectSet was migrated from an existing release and object status probe results have not yet been observed. - - The Succeeded condition represents whether the revision has successfully completed its rollout: - - When status is True and reason is Succeeded, the ClusterObjectSet has successfully completed its rollout. This condition is set once and persists even if the revision later becomes unavailable. - 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 - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - observedPhases: - description: |- - observedPhases records the content hashes of resolved phases - at first successful reconciliation. This is used to detect if - referenced object sources were deleted and recreated with - different content. Each entry covers all fully-resolved object - manifests within a phase, making it source-agnostic. - items: - description: ObservedPhase records the observed content digest of - a resolved phase. - properties: - digest: - description: |- - digest is the digest of the phase's resolved object content - at first successful resolution, in the format "<algorithm>:<hex>". - maxLength: 256 - minLength: 1 - type: string - x-kubernetes-validations: - - message: digest must be in the format '<algorithm>:<hex>' - rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') - name: - description: name is the phase name matching a phase in spec.phases. - maxLength: 63 - minLength: 1 - type: string - x-kubernetes-validations: - - message: the value must consist of only lowercase alphanumeric - characters and hyphens, and must start and end with an alphanumeric - character. - rule: '!format.dns1123Label().validate(self).hasValue()' - required: - - digest - - name - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 - type: object - type: object - served: true - storage: true - subresources: - status: {} ---- # Source: olmv1/templates/rbac/clusterrole-catalogd-manager-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -2713,9 +2040,9 @@ spec: - --metrics-bind-address=:8443 - --pprof-bind-address=:6060 - --leader-elect - - --feature-gates=BoxcutterRuntime=true - --feature-gates=BundleReleaseSupport=true - --feature-gates=DeploymentConfig=true + - --feature-gates=OrbOperatorRuntime=true - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=WebhookProviderCertManager=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index f8c3add53b..112884bc19 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1307,679 +1307,6 @@ spec: subresources: status: {} --- -# Source: olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.20.1 - olm.operatorframework.io/generator: experimental - name: clusterobjectsets.olm.operatorframework.io -spec: - group: olm.operatorframework.io - names: - kind: ClusterObjectSet - listKind: ClusterObjectSetList - plural: clusterobjectsets - singular: clusterobjectset - scope: Cluster - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=='Available')].status - name: Available - type: string - - jsonPath: .status.conditions[?(@.type=='Progressing')].status - name: Progressing - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1 - schema: - openAPIV3Schema: - description: |- - ClusterObjectSet represents an immutable snapshot of Kubernetes objects - for a specific version of a ClusterExtension. Each revision contains objects - organized into phases that roll out sequentially. The same object can only be managed by a single revision - at a time. Ownership of objects is transitioned from one revision to the next as the extension is upgraded - or reconfigured. Once the latest revision has rolled out successfully, previous active revisions are archived for - posterity. - 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 the ClusterObjectSet. - properties: - collisionProtection: - description: |- - collisionProtection specifies the default collision protection strategy for all objects - in this revision. Individual phases or objects can override this value. - - When set, this value is used as the default for any phase or object that does not - explicitly specify its own collisionProtection. - - The resolution order is: object > phase > spec - enum: - - Prevent - - IfNoController - - None - type: string - x-kubernetes-validations: - - message: collisionProtection is immutable - rule: self == oldSelf - lifecycleState: - description: |- - lifecycleState specifies the lifecycle state of the ClusterObjectSet. - - When set to "Active", the revision is actively managed and reconciled. - When set to "Archived", the revision is inactive and any resources not managed by a subsequent revision are deleted. - The revision is removed from the owner list of all objects previously under management. - All objects that did not transition to a succeeding revision are deleted. - - Once a revision is set to "Archived", it cannot be un-archived. - - It is possible for more than one revision to be "Active" simultaneously. This will occur when - moving from one revision to another. The old revision will not be set to "Archived" until the - new revision has been completely rolled out. - enum: - - Active - - Archived - type: string - x-kubernetes-validations: - - message: cannot un-archive - rule: oldSelf == 'Active' || oldSelf == 'Archived' && oldSelf == - self - phases: - description: |- - phases is an optional, immutable list of phases that group objects to be applied together. - - Objects are organized into phases based on their Group-Kind. Common phases include: - - namespaces: Namespace objects - - policies: ResourceQuota, LimitRange, NetworkPolicy objects - - rbac: ServiceAccount, Role, RoleBinding, ClusterRole, ClusterRoleBinding objects - - crds: CustomResourceDefinition objects - - storage: PersistentVolume, PersistentVolumeClaim, StorageClass objects - - deploy: Deployment, StatefulSet, DaemonSet, Service, ConfigMap, Secret objects - - publish: Ingress, APIService, Route, Webhook objects - - All objects in a phase are applied in no particular order. - The revision progresses to the next phase only after all objects in the current phase pass their readiness probes. - - Once set, even if empty, the phases field is immutable. - - Each phase in the list must have a unique name. The maximum number of phases is 20. - items: - description: |- - ClusterObjectSetPhase represents a group of objects that are applied together. The phase is considered - complete only after all objects pass their status probes. - properties: - collisionProtection: - description: |- - collisionProtection specifies the default collision protection strategy for all objects - in this phase. Individual objects can override this value. - - When set, this value is used as the default for any object in this phase that does not - explicitly specify its own collisionProtection. - - When omitted, we use .spec.collistionProtection as the default for any object in this phase that does not - explicitly specify its own collisionProtection. - enum: - - Prevent - - IfNoController - - None - type: string - name: - description: |- - name is a required identifier for this phase. - - phase names must follow the DNS label standard as defined in [RFC 1123]. - They must contain only lowercase alphanumeric characters or hyphens (-), - start and end with an alphanumeric character, and be no longer than 63 characters. - - Common phase names include: namespaces, policies, rbac, crds, storage, deploy, publish. - - [RFC 1123]: https://tools.ietf.org/html/rfc1123 - maxLength: 63 - minLength: 1 - type: string - x-kubernetes-validations: - - message: the value must consist of only lowercase alphanumeric - characters and hyphens, and must start and end with an alphanumeric - character. - rule: '!format.dns1123Label().validate(self).hasValue()' - objects: - description: |- - objects is a required list of all Kubernetes objects that belong to this phase. - - All objects in this list are applied to the cluster in no particular order. The maximum number of objects per phase is 50. - items: - description: |- - ClusterObjectSetObject represents a Kubernetes object to be applied as part - of a phase, along with its collision protection settings. - - Exactly one of object or ref must be set. - properties: - collisionProtection: - description: |- - collisionProtection controls whether the operator can adopt and modify objects - that already exist on the cluster. - - Allowed values are: "Prevent", "IfNoController", and "None". - - When set to "Prevent", the operator only manages objects it created itself. - This prevents ownership collisions. - - When set to "IfNoController", the operator can adopt and modify pre-existing objects - that are not owned by another controller. - This is useful for taking over management of manually-created resources. - - When set to "None", the operator can adopt and modify any pre-existing object, even if - owned by another controller. - Use this setting with extreme caution as it may cause multiple controllers to fight over - the same resource, resulting in increased load on the API server and etcd. - - When omitted, the value is inherited from the phase, then spec. - enum: - - Prevent - - IfNoController - - None - type: string - object: - description: |- - object is an optional embedded Kubernetes object to be applied. - - Exactly one of object or ref must be set. - - This object must be a valid Kubernetes resource with apiVersion, kind, and metadata fields. - type: object - x-kubernetes-embedded-resource: true - x-kubernetes-preserve-unknown-fields: true - ref: - description: |- - ref is an optional reference to a Secret that holds the serialized - object manifest. - - Exactly one of object or ref must be set. - properties: - key: - description: |- - key is the data key within the referenced Secret containing the - object manifest content. The value at this key must be a - JSON-serialized Kubernetes object manifest. - maxLength: 253 - minLength: 1 - type: string - name: - description: name is the name of the referenced Secret. - maxLength: 253 - minLength: 1 - type: string - namespace: - description: |- - namespace is the namespace of the referenced Secret. - When empty, defaults to the OLM system namespace during ref resolution. - maxLength: 63 - type: string - required: - - key - - name - type: object - type: object - x-kubernetes-validations: - - message: exactly one of the fields in [object ref] must - be set - rule: '[has(self.object),has(self.ref)].filter(x,x==true).size() - == 1' - maxItems: 50 - type: array - required: - - name - - objects - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: phases is immutable - rule: self == oldSelf || oldSelf.size() == 0 - progressDeadlineMinutes: - description: |- - progressDeadlineMinutes is an optional field that defines the maximum period - of time in minutes after which an installation should be considered failed and - require manual intervention. This functionality is disabled when no value - is provided. The minimum period is 10 minutes, and the maximum is 720 minutes (12 hours). - format: int32 - maximum: 720 - minimum: 10 - type: integer - progressionProbes: - description: |- - progressionProbes is an optional field which provides the ability to define custom readiness probes - for objects defined within spec.phases. As documented in that field, most kubernetes-native objects - within the phases already have some kind of readiness check built-in, but this field allows for checks - which are tailored to the objects being rolled out - particularly custom resources. - - Probes defined within the progressionProbes list will apply to every phase in the revision. However, the probes will only - execute against phase objects which are a match for the provided selector type. For instance, a probe using a GroupKind selector - for ConfigMaps will automatically be considered to have passed for any non-ConfigMap object, but will halt any phase containing - a ConfigMap if that particular object does not pass the probe check. - - The maximum number of probes is 20. - items: - description: ProgressionProbe provides a custom probe definition, - consisting of an object selection method and assertions. - properties: - assertions: - description: |- - assertions is a required list of checks which will run against the objects selected by the selector. If - one or more assertions fail then the phase within which the object lives will be not be considered - 'Ready', blocking rollout of all subsequent phases. - items: - description: Assertion is a discriminated union which defines - the probe type and definition used as an assertion. - properties: - conditionEqual: - description: conditionEqual contains the expected condition - type and status. - properties: - status: - description: |- - status sets the expected condition status. - - Allowed values are "True" and "False". - enum: - - "True" - - "False" - type: string - type: - description: type sets the expected condition type, - i.e. "Ready". - maxLength: 200 - minLength: 1 - type: string - required: - - status - - type - type: object - fieldValue: - description: fieldValue contains the expected field path - and value found within. - properties: - fieldPath: - description: |- - fieldPath sets the field path for the field to check, i.e. "status.phase". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - value: - description: value sets the expected value found at - fieldPath, i.e. "Bound". - maxLength: 200 - minLength: 1 - type: string - required: - - fieldPath - - value - type: object - fieldsEqual: - description: fieldsEqual contains the two field paths - whose values are expected to match. - properties: - fieldA: - description: |- - fieldA sets the field path for the first field, i.e. "spec.replicas". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - fieldB: - description: |- - fieldB sets the field path for the second field, i.e. "status.readyReplicas". The probe will fail - if the path does not exist. - maxLength: 200 - minLength: 1 - type: string - x-kubernetes-validations: - - message: must contain a valid field path. valid - fields contain upper or lower-case alphanumeric - characters separated by the "." character. - rule: self.matches('^[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*$') - required: - - fieldA - - fieldB - type: object - type: - description: |- - type is a required field which specifies the type of probe to use. - - The allowed probe types are "ConditionEqual", "FieldsEqual", and "FieldValue". - - When set to "ConditionEqual", the probe checks objects that have reached a condition of specified type and status. - When set to "FieldsEqual", the probe checks that the values found at two provided field paths are matching. - When set to "FieldValue", the probe checks that the value found at the provided field path matches what was specified. - enum: - - ConditionEqual - - FieldsEqual - - FieldValue - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: conditionEqual is required when type is ConditionEqual, - and forbidden otherwise - rule: 'self.type == ''ConditionEqual'' ?has(self.conditionEqual) - : !has(self.conditionEqual)' - - message: fieldsEqual is required when type is FieldsEqual, - and forbidden otherwise - rule: 'self.type == ''FieldsEqual'' ?has(self.fieldsEqual) - : !has(self.fieldsEqual)' - - message: fieldValue is required when type is FieldValue, - and forbidden otherwise - rule: 'self.type == ''FieldValue'' ?has(self.fieldValue) - : !has(self.fieldValue)' - maxItems: 20 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - selector: - description: |- - selector is a required field which defines the method by which we select objects to apply the below - assertions to. Any object which matches the defined selector will have all the associated assertions - applied against it. - - If no objects within a phase are selected by the provided selector, then all assertions defined here - are considered to have succeeded. - properties: - groupKind: - description: |- - groupKind specifies the group and kind of objects to select. - - Required when type is "GroupKind". - - Uses the Kubernetes format specified here: - https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#GroupKind - properties: - group: - type: string - kind: - type: string - required: - - group - - kind - type: object - label: - description: |- - label is the label selector definition. - - Required when type is "Label". - - A probe using a Label selector will be executed against every object matching the labels or expressions; you must use care - when using this type of selector. For example, if multiple Kind objects are selected via labels then the probe is - likely to fail because the values of different Kind objects rarely share the same schema. - - The LabelSelector field uses the following Kubernetes format: - https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#LabelSelector - Requires exactly one of matchLabels or matchExpressions. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - 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 - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - x-kubernetes-validations: - - message: exactly one of matchLabels or matchExpressions - must be set - rule: (has(self.matchExpressions) && !has(self.matchLabels)) - || (!has(self.matchExpressions) && has(self.matchLabels)) - type: - description: |- - type is a required field which specifies the type of selector to use. - - The allowed selector types are "GroupKind" and "Label". - - When set to "GroupKind", all objects which match the specified group and kind will be selected. - When set to "Label", all objects which match the specified labels and/or expressions will be selected. - enum: - - GroupKind - - Label - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: groupKind is required when type is GroupKind, and - forbidden otherwise - rule: 'self.type == ''GroupKind'' ?has(self.groupKind) : !has(self.groupKind)' - - message: label is required when type is Label, and forbidden - otherwise - rule: 'self.type == ''Label'' ?has(self.label) : !has(self.label)' - required: - - assertions - - selector - type: object - maxItems: 20 - minItems: 1 - type: array - x-kubernetes-list-type: atomic - revision: - description: |- - revision is a required, immutable sequence number representing a specific revision - of the parent ClusterExtension. - - The revision field must be a positive integer. - Each ClusterObjectSet belonging to the same parent ClusterExtension must have a unique revision number. - The revision number must always be the previous revision number plus one, or 1 for the first revision. - format: int64 - minimum: 1 - type: integer - x-kubernetes-validations: - - message: revision is immutable - rule: self == oldSelf - required: - - collisionProtection - - lifecycleState - - revision - type: object - status: - description: status is optional and defines the observed state of the - ClusterObjectSet. - properties: - conditions: - description: |- - conditions is an optional list of status conditions describing the state of the - ClusterObjectSet. - - The Progressing condition represents whether the revision is actively rolling out: - - When status is True and reason is RollingOut, the ClusterObjectSet rollout is actively making progress and is in transition. - - When status is True and reason is Retrying, the ClusterObjectSet has encountered an error that could be resolved on subsequent reconciliation attempts. - - When status is True and reason is Succeeded, the ClusterObjectSet has reached the desired state. - - When status is False and reason is Blocked, the ClusterObjectSet has encountered an error that requires manual intervention for recovery. - - When status is False and reason is Archived, the ClusterObjectSet is archived and not being actively reconciled. - - The Available condition represents whether the revision has been successfully rolled out and is available: - - When status is True and reason is ProbesSucceeded, the ClusterObjectSet has been successfully rolled out and all objects pass their readiness probes. - - When status is False and reason is ProbeFailure, one or more objects are failing their readiness probes during rollout. - - When status is Unknown and reason is Reconciling, the ClusterObjectSet has encountered an error that prevented it from observing the probes. - - When status is Unknown and reason is Archived, the ClusterObjectSet has been archived and its objects have been torn down. - - When status is Unknown and reason is Migrated, the ClusterObjectSet was migrated from an existing release and object status probe results have not yet been observed. - - The Succeeded condition represents whether the revision has successfully completed its rollout: - - When status is True and reason is Succeeded, the ClusterObjectSet has successfully completed its rollout. This condition is set once and persists even if the revision later becomes unavailable. - 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 - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - observedPhases: - description: |- - observedPhases records the content hashes of resolved phases - at first successful reconciliation. This is used to detect if - referenced object sources were deleted and recreated with - different content. Each entry covers all fully-resolved object - manifests within a phase, making it source-agnostic. - items: - description: ObservedPhase records the observed content digest of - a resolved phase. - properties: - digest: - description: |- - digest is the digest of the phase's resolved object content - at first successful resolution, in the format "<algorithm>:<hex>". - maxLength: 256 - minLength: 1 - type: string - x-kubernetes-validations: - - message: digest must be in the format '<algorithm>:<hex>' - rule: self.matches('^[a-z0-9]+:[a-f0-9]+$') - name: - description: name is the phase name matching a phase in spec.phases. - maxLength: 63 - minLength: 1 - type: string - x-kubernetes-validations: - - message: the value must consist of only lowercase alphanumeric - characters and hyphens, and must start and end with an alphanumeric - character. - rule: '!format.dns1123Label().validate(self).hasValue()' - required: - - digest - - name - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - x-kubernetes-validations: - - message: observedPhases is immutable - rule: self == oldSelf || oldSelf.size() == 0 - type: object - type: object - served: true - storage: true - subresources: - status: {} ---- # Source: olmv1/templates/rbac/clusterrole-catalogd-manager-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -2619,9 +1946,9 @@ spec: - --health-probe-bind-address=:8081 - --metrics-bind-address=:8443 - --leader-elect - - --feature-gates=BoxcutterRuntime=true - --feature-gates=BundleReleaseSupport=true - --feature-gates=DeploymentConfig=true + - --feature-gates=OrbOperatorRuntime=true - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=WebhookProviderCertManager=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false From d2ae7962f4bd88ed2596b8950592b34aeaa5fcc7 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 17:32:09 -0400 Subject: [PATCH 07/26] :seedling: Mark orb-operator-wiring spec done --- specs/{ => closed}/2026-08-12-orb-operator-wiring/README.md | 2 +- specs/{ => closed}/2026-08-12-orb-operator-wiring/plan.md | 0 .../{ => closed}/2026-08-12-orb-operator-wiring/requirements.md | 0 .../{ => closed}/2026-08-12-orb-operator-wiring/verification.md | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename specs/{ => closed}/2026-08-12-orb-operator-wiring/README.md (99%) rename specs/{ => closed}/2026-08-12-orb-operator-wiring/plan.md (100%) rename specs/{ => closed}/2026-08-12-orb-operator-wiring/requirements.md (100%) rename specs/{ => closed}/2026-08-12-orb-operator-wiring/verification.md (100%) diff --git a/specs/2026-08-12-orb-operator-wiring/README.md b/specs/closed/2026-08-12-orb-operator-wiring/README.md similarity index 99% rename from specs/2026-08-12-orb-operator-wiring/README.md rename to specs/closed/2026-08-12-orb-operator-wiring/README.md index 47c9207d41..c292c9860b 100644 --- a/specs/2026-08-12-orb-operator-wiring/README.md +++ b/specs/closed/2026-08-12-orb-operator-wiring/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator Feature Gate and Wiring Stub diff --git a/specs/2026-08-12-orb-operator-wiring/plan.md b/specs/closed/2026-08-12-orb-operator-wiring/plan.md similarity index 100% rename from specs/2026-08-12-orb-operator-wiring/plan.md rename to specs/closed/2026-08-12-orb-operator-wiring/plan.md diff --git a/specs/2026-08-12-orb-operator-wiring/requirements.md b/specs/closed/2026-08-12-orb-operator-wiring/requirements.md similarity index 100% rename from specs/2026-08-12-orb-operator-wiring/requirements.md rename to specs/closed/2026-08-12-orb-operator-wiring/requirements.md diff --git a/specs/2026-08-12-orb-operator-wiring/verification.md b/specs/closed/2026-08-12-orb-operator-wiring/verification.md similarity index 100% rename from specs/2026-08-12-orb-operator-wiring/verification.md rename to specs/closed/2026-08-12-orb-operator-wiring/verification.md From 802024e9a30569d522e999a873e97cbdf23748fb Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 20:15:08 -0400 Subject: [PATCH 08/26] planning: COD generator spec --- specs/2026-08-12-cod-generator/README.md | 97 +++++++++++++++++-- specs/2026-08-12-cod-generator/plan.md | 41 ++++++++ .../2026-08-12-cod-generator/requirements.md | 26 +++++ .../2026-08-12-cod-generator/verification.md | 36 +++++++ 4 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 specs/2026-08-12-cod-generator/plan.md create mode 100644 specs/2026-08-12-cod-generator/requirements.md create mode 100644 specs/2026-08-12-cod-generator/verification.md diff --git a/specs/2026-08-12-cod-generator/README.md b/specs/2026-08-12-cod-generator/README.md index 33ec81b64b..142ab532b7 100644 --- a/specs/2026-08-12-cod-generator/README.md +++ b/specs/2026-08-12-cod-generator/README.md @@ -1,14 +1,95 @@ --- -status: idea +status: in-progress --- # COD Generator -Implement the `CODGenerator` interface and `RegistryV1CODGenerator` that converts a bundle `fs.FS` and ClusterExtension into an inline `ClusterObjectDeploymentApplyConfiguration`. This is the translation layer between OLM's registry+v1 bundle format and orb-operator's phased object model. +## Summary -## Deliverables +Implement a `CODGenerator` interface and `RegistryV1CODGenerator` that converts a bundle `fs.FS` plus `ClusterExtension` into an inline `ClusterObjectDeploymentApplyConfiguration`. This is the translation layer between OLM's registry+v1 bundle format and orb-operator's phased object model. -- `CODGenerator` interface with `GenerateCOD(ctx, bundleFS, ext, revisionAnnotations) (*orbac.ClusterObjectDeploymentApplyConfiguration, error)` -- `RegistryV1CODGenerator` implementation that uses the existing `ManifestProvider` to render bundle manifests, then organizes them into orb-operator phases with appropriate assertions and collision protection -- Phasing logic: CRDs/namespaces in early phases, workloads in later phases, with progression assertions (e.g., CRD Established=True before proceeding) -- Unit tests covering phase ordering, assertion generation, and edge cases -- Reference: speed-run branch commits `sn` and `xuk` +The COD generator reuses the existing `ManifestProvider` to render manifests, then organizes them into orb-operator phases with per-object assertions and collision protection. It produces an inline-only COD (no externalization to slices) - the caller (the applier, in a later phase) handles externalization. + +## Design + +### Interface + +```go +// CODGenerator produces a ClusterObjectDeployment apply configuration +// from an unpacked bundle filesystem and a ClusterExtension. +type CODGenerator interface { + GenerateCOD( + ctx context.Context, + bundleFS fs.FS, + ext *ocv1.ClusterExtension, + revisionAnnotations map[string]string, + ) (*orbac.ClusterObjectDeploymentApplyConfiguration, error) +} +``` + +The interface lives in `internal/operator-controller/applier/` alongside the existing `ManifestProvider` and `ClusterObjectSetGenerator` interfaces. + +The return is a `*orbac.ClusterObjectDeploymentApplyConfiguration` (from `github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1`). The COD is populated with: +- `WithName(ext.Name)` - COD name matches ClusterExtension name (1:1 mapping) +- `WithSpec(codSpec)` where codSpec has: + - `WithProgressDeadlineMinutes(ext.Spec.ProgressDeadlineMinutes)` if set + - `WithTemplate(template)` where template has: + - `WithMetadata(templateMeta)` with revision annotations (bundle name, version, package, reference) + - `WithSpec(templateSpec)` with collision protection and phased objects + +### RegistryV1CODGenerator + +```go +type RegistryV1CODGenerator struct { + ManifestProvider ManifestProvider +} +``` + +Takes the existing `ManifestProvider` (shared with Boxcutter/Helm paths) to render manifests from the bundle FS, then converts the flat `[]client.Object` into orb-operator phases. + +### Phase sorting and assertions + +Reuse the existing `phase.go` infrastructure (`determinePhase`, `defaultPhaseOrder`, `gkPhaseMap`) to assign each object to a well-known phase. The phase names and ordering are identical to what Boxcutter uses today. + +Objects are serialized to `runtime.RawExtension` (JSON) for the orb-operator `PhaseObject.Object` field. Each object is sanitized (status stripped, metadata limited to name/namespace/labels/annotations) using the existing `sanitizedUnstructured` helper. + +Per-object assertions are set based on GVK, matching the existing `defaultProgressionProbes` logic but expressed as orb-operator assertions on individual `PhaseObject` entries rather than top-level `ProgressionProbe` selectors. The assertion mapping: + +| GVK | Assertion | +|---|---| +| CRD (`apiextensions.k8s.io/CustomResourceDefinition`) | `ConditionEqual{Type: "Established", Status: "True"}` | +| cert-manager Certificate | `ConditionEqual{Type: "Ready", Status: "True"}` | +| cert-manager Issuer | `ConditionEqual{Type: "Ready", Status: "True"}` | +| Namespace | `FieldValue{FieldPath: "status.phase", Value: "Active"}` | +| PersistentVolumeClaim | `FieldValue{FieldPath: "status.phase", Value: "Bound"}` | +| Deployment | `FieldsEqual{FieldA: "status.updatedReplicas", FieldB: "status.replicas"}` + `ConditionEqual{Type: "Available", Status: "True"}` | +| StatefulSet | `FieldsEqual{FieldA: "status.updatedReplicas", FieldB: "status.replicas"}` + `ConditionEqual{Type: "Available", Status: "True"}` | + +Objects without a matching assertion rule get no assertions (available immediately after apply). + +### Collision protection + +Set at the template spec level: `CollisionProtection: "Prevent"` (default for fresh installs). The applier can override per-object if needed in a later phase. + +### Revision annotations on template metadata + +Bundle metadata is propagated via `template.metadata.annotations`: +- `olm.operatorframework.io/bundle-name` +- `olm.operatorframework.io/bundle-version` +- `olm.operatorframework.io/package-name` +- `olm.operatorframework.io/bundle-reference` +- `olm.properties` (OLM properties from bundle annotations, if present) + +These are the same annotations that Boxcutter puts on COS revision annotations. By placing them on the COD template metadata, they propagate to COS revisions created by the orb-operator COD controller. + +### Object labels + +Object labels (e.g., `olm.operatorframework.io/owner-kind`, `olm.operatorframework.io/owner-name`) are NOT set by the COD generator. They are applied by the caller (the applier) on the COD's top-level metadata and template metadata for cache filtering. This keeps the generator focused on content translation. + +### Error handling + +The generator returns errors for: +- Bundle parse failures (via `ManifestProvider.Get`) +- Object serialization failures (JSON marshaling) +- Missing bundle annotations (via `getBundleAnnotations`) + +No terminal vs retryable distinction needed here - the generator is a pure transformation. Error classification is the applier's responsibility. diff --git a/specs/2026-08-12-cod-generator/plan.md b/specs/2026-08-12-cod-generator/plan.md new file mode 100644 index 0000000000..74ceb4a066 --- /dev/null +++ b/specs/2026-08-12-cod-generator/plan.md @@ -0,0 +1,41 @@ +# Implementation Plan + +1. **Define CODGenerator interface and RegistryV1CODGenerator struct** + - Add `CODGenerator` interface to `internal/operator-controller/applier/codgen.go` + - Add `RegistryV1CODGenerator` struct with `ManifestProvider` field + - Import `orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1"` + +2. **Implement object-to-PhaseObject conversion** + - Add a helper that converts a `client.Object` into an `orbac.PhaseObjectApplyConfiguration`: + - Convert to unstructured, sanitize with `sanitizedUnstructured`, strip large annotations with `cache.ApplyStripAnnotationsTransform` + - Marshal to JSON as `runtime.RawExtension` + - Look up GVK-based assertions using a mapping table (same logic as `defaultProgressionProbes` but producing `orbac.AssertionApplyConfiguration` values) + - Add the GVK-to-assertions mapping as a package-level variable, built from the same GVKs as `defaultProgressionProbes` + +3. **Implement GenerateCOD method** + - Call `ManifestProvider.Get(bundleFS, ext)` to get `[]client.Object` + - Get bundle annotations via `getBundleAnnotations(bundleFS)` and extract `olm.properties` if present + - Merge caller-provided `revisionAnnotations` (bundle name, version, package, reference from resolver) with bundle-derived `olm.properties` annotation + - For each object: determine phase via `determinePhase(gvk.GroupKind())`, convert to `PhaseObject` + - Group by phase, sort phases by `defaultPhaseOrder`, sort objects within each phase deterministically + - Build `ClusterObjectDeploymentApplyConfiguration`: + - `WithName(ext.Name)` + - Spec with `WithProgressDeadlineMinutes` (if set) and template + - Template with metadata annotations (revision annotations) and spec (collision protection + phases) + - Return the COD + +4. **Write unit tests** + - Test with a mock `ManifestProvider` that returns controlled sets of objects + - Verify phase ordering matches `defaultPhaseOrder` + - Verify assertions per GVK (CRD, Deployment, StatefulSet, Namespace, PVC, cert-manager types, plain objects) + - Verify deterministic object sorting within phases + - Verify revision annotation propagation + - Verify `progressDeadlineMinutes` passthrough + - Verify error propagation from `ManifestProvider.Get` and `getBundleAnnotations` + +5. **Wire CODGenerator into OrbOperator applier** + - Add `Generator CODGenerator` field to the `OrbOperator` struct in `applier/orboperator.go` + - Update `Apply` to call `o.Generator.GenerateCOD(ctx, contentFS, ext, revisionAnnotations)` and log the result + - The Apply method generates the COD but does not yet apply it to the cluster (externalization, SSA, status reading are the applier spec's job) - return `(false, "", nil)` after successful generation + - Construct `RegistryV1CODGenerator` in the `orbOperatorReconcilerConfigurator.Configure` method in `main.go`, passing the existing `regv1ManifestProvider` + - Pass it to `OrbOperator{Generator: codGen, ...}` diff --git a/specs/2026-08-12-cod-generator/requirements.md b/specs/2026-08-12-cod-generator/requirements.md new file mode 100644 index 0000000000..65a52f8c87 --- /dev/null +++ b/specs/2026-08-12-cod-generator/requirements.md @@ -0,0 +1,26 @@ +# Requirements + +- Define a `CODGenerator` interface in `internal/operator-controller/applier/` +- Implement `RegistryV1CODGenerator` that converts a registry+v1 bundle FS and ClusterExtension into a `ClusterObjectDeploymentApplyConfiguration` +- Reuse `ManifestProvider` for manifest rendering (no duplication of bundle parsing, config validation, or rendering logic) +- Reuse the existing phase sorting infrastructure (`determinePhase`, `defaultPhaseOrder`, `gkPhaseMap` from `phase.go`) +- Map per-GVK assertions onto individual `PhaseObject` entries matching the existing `defaultProgressionProbes` semantics +- Serialize objects as `runtime.RawExtension` JSON with sanitized metadata (existing `sanitizedUnstructured` helper) +- Propagate bundle metadata (name, version, package, reference, OLM properties) as COD template metadata annotations +- Set collision protection at the template spec level (`Prevent`) +- COD name equals `ext.Name` +- Support `progressDeadlineMinutes` from ClusterExtension spec +- Wire `CODGenerator` into `OrbOperator` struct and have `Apply` call `GenerateCOD` +- Construct `RegistryV1CODGenerator` in the `orbOperatorReconcilerConfigurator` in `main.go` + +## Acceptance Criteria + +- `RegistryV1CODGenerator.GenerateCOD` produces a COD with phases matching the same ordering as `PhaseSort` +- Objects within each phase are sorted deterministically (by GVK, namespace, name) matching `compareClusterObjectSetObjectApplyConfigurations` ordering +- CRDs get `ConditionEqual{Established, True}` assertions; Deployments/StatefulSets get `FieldsEqual{updatedReplicas, replicas}` + `ConditionEqual{Available, True}` assertions; Namespaces get `FieldValue{status.phase, Active}` +- Objects without assertion rules have empty assertions (no assertions = immediately available) +- Bundle revision annotations appear on template metadata +- `progressDeadlineMinutes` from ClusterExtension is set on the COD spec when non-zero +- `OrbOperator.Apply` calls `GenerateCOD` with the bundle FS and revision annotations +- `orbOperatorReconcilerConfigurator` constructs `RegistryV1CODGenerator` with the shared `ManifestProvider` +- Unit tests cover: phase ordering, assertion generation per GVK, deterministic object sorting, revision annotation propagation, error cases (bad bundle, serialization failure) diff --git a/specs/2026-08-12-cod-generator/verification.md b/specs/2026-08-12-cod-generator/verification.md new file mode 100644 index 0000000000..5d67a368e1 --- /dev/null +++ b/specs/2026-08-12-cod-generator/verification.md @@ -0,0 +1,36 @@ +# Verification + +## Implementation Correctness + +- [ ] `CODGenerator` interface defined in `internal/operator-controller/applier/codgen.go` +- [ ] `RegistryV1CODGenerator` uses `ManifestProvider.Get` for manifest rendering (no bundle parsing duplication) +- [ ] Phase sorting reuses `determinePhase` and `defaultPhaseOrder` from `phase.go` +- [ ] Object sanitization uses `sanitizedUnstructured` and `cache.ApplyStripAnnotationsTransform` +- [ ] Per-GVK assertions match the semantics of `defaultProgressionProbes`: + - CRDs: Established=True + - Deployments/StatefulSets: updatedReplicas==replicas + Available=True + - Namespaces: status.phase=Active + - PVCs: status.phase=Bound + - cert-manager Certificate/Issuer: Ready=True + - Other objects: no assertions +- [ ] COD name equals `ext.Name` +- [ ] `progressDeadlineMinutes` set on COD spec when `ext.Spec.ProgressDeadlineMinutes > 0` +- [ ] Collision protection set to `Prevent` at template spec level +- [ ] Revision annotations on template metadata include bundle name, version, package, reference, and OLM properties +- [ ] Objects serialized as `runtime.RawExtension` JSON (not YAML) +- [ ] Object labels are NOT set by the generator (applier's responsibility) +- [ ] `OrbOperator` struct has `Generator CODGenerator` field +- [ ] `OrbOperator.Apply` calls `GenerateCOD` and logs the result (does not yet apply to cluster) +- [ ] `orbOperatorReconcilerConfigurator` constructs `RegistryV1CODGenerator` with existing `regv1ManifestProvider` +- [ ] Unit tests pass + +## Project Conventions + +- [ ] No `//nolint` comments added +- [ ] Code formatted with `make fmt` +- [ ] `make lint` passes +- [ ] `make test-unit` passes +- [ ] No unnecessary abstractions or helper functions beyond what the implementation requires +- [ ] Import aliases match project conventions (`orbac` for orb-operator apply configurations, `orbv1alpha1` for orb-operator API types) +- [ ] Mission principle: "do not fight Kubernetes" - using orb-operator's native apply configuration pattern +- [ ] Mission principle: "simple and predictable" - pure transformation, no side effects From ce25578e66751e6df1f537f2db496e8fef98fa54 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 20:49:04 -0400 Subject: [PATCH 09/26] :sparkles: Add CODGenerator interface and RegistryV1CODGenerator implementation --- cmd/operator-controller/main.go | 5 + .../operator-controller/applier/codgen.go | 273 +++++++++ .../applier/codgen_test.go | 560 ++++++++++++++++++ .../applier/orboperator.go | 18 +- internal/operator-controller/scheme/scheme.go | 2 + 5 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 internal/operator-controller/applier/codgen.go create mode 100644 internal/operator-controller/applier/codgen_test.go diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 3bf093b912..f6a03aa993 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -761,9 +761,14 @@ func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers. } fieldOwner := fmt.Sprintf("%s/clusterextension-controller", fieldOwnerPrefix) + codGen := &applier.RegistryV1CODGenerator{ + ManifestProvider: c.regv1ManifestProvider, + Scheme: c.mgr.GetScheme(), + } appl := &applier.OrbOperator{ Client: c.mgr.GetClient(), Scheme: c.mgr.GetScheme(), + Generator: codGen, Preflights: c.preflights, FieldOwner: fieldOwner, } diff --git a/internal/operator-controller/applier/codgen.go b/internal/operator-controller/applier/codgen.go new file mode 100644 index 0000000000..a2ed93ec72 --- /dev/null +++ b/internal/operator-controller/applier/codgen.go @@ -0,0 +1,273 @@ +package applier + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io/fs" + "slices" + + "github.com/cert-manager/cert-manager/pkg/apis/certmanager" + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + "github.com/operator-framework/operator-controller/internal/shared/util/cache" +) + +const maxObjectsPerPhase = 50 + +type CODGenerator interface { + GenerateCOD( + ctx context.Context, + bundleFS fs.FS, + ext *ocv1.ClusterExtension, + objectLabels, revisionAnnotations map[string]string, + ) (*orbac.ClusterObjectDeploymentApplyConfiguration, error) +} + +type RegistryV1CODGenerator struct { + ManifestProvider ManifestProvider + Scheme *runtime.Scheme +} + +func (g *RegistryV1CODGenerator) GenerateCOD( + ctx context.Context, + bundleFS fs.FS, + ext *ocv1.ClusterExtension, + objectLabels, revisionAnnotations map[string]string, +) (*orbac.ClusterObjectDeploymentApplyConfiguration, error) { + objs, err := g.ManifestProvider.Get(bundleFS, ext) + if err != nil { + return nil, fmt.Errorf("getting manifests: %w", err) + } + + templateAnnotations := make(map[string]string, len(revisionAnnotations)+1) + for k, v := range revisionAnnotations { + templateAnnotations[k] = v + } + if bundleFS != nil { + bundleAnnotations, err := getBundleAnnotations(bundleFS) + if err != nil { + return nil, fmt.Errorf("getting bundle annotations: %w", err) + } + if v, ok := bundleAnnotations[source.PropertyOLMProperties]; ok { + if _, exists := templateAnnotations[source.PropertyOLMProperties]; !exists { + templateAnnotations[source.PropertyOLMProperties] = v + } + } + } + + phases, err := g.buildPhases(ctx, objs, objectLabels) + if err != nil { + return nil, fmt.Errorf("building phases: %w", err) + } + + codSpec := orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithMetadata(orbac.ClusterObjectDeploymentTemplateMetadata(). + WithAnnotations(templateAnnotations). + WithLabels(objectLabels)). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithCollisionProtection(orbv1alpha1.CollisionProtectionPrevent). + WithPhases(phases...))) + if p := ext.Spec.ProgressDeadlineMinutes; p > 0 { + codSpec.WithProgressDeadlineMinutes(p) + } + + // Set a controller ownerReference to the ClusterExtension so the COD (and, + // via propagation, its ClusterObjectSlices) is garbage-collected when the + // ClusterExtension is deleted, and so owner-based watches enqueue the CE. + gvk, err := apiutil.GVKForObject(ext, g.Scheme) + if err != nil { + return nil, fmt.Errorf("getting GVK for owner: %w", err) + } + + return orbac.ClusterObjectDeployment(ext.Name). + WithLabels(objectLabels). + WithOwnerReferences(metav1ac.OwnerReference(). + WithAPIVersion(gvk.GroupVersion().String()). + WithKind(gvk.Kind). + WithName(ext.Name). + WithUID(ext.UID). + WithBlockOwnerDeletion(true). + WithController(true)). + WithSpec(codSpec), nil +} + +func (g *RegistryV1CODGenerator) buildPhases(ctx context.Context, objs []client.Object, objectLabels map[string]string) ([]*orbac.PhaseApplyConfiguration, error) { + phaseMap := make(map[Phase][]orbac.PhaseObjectApplyConfiguration) + for _, obj := range objs { + gvk, err := apiutil.GVKForObject(obj, g.Scheme) + if err != nil { + return nil, fmt.Errorf("getting GVK for object %s/%s: %w", obj.GetNamespace(), obj.GetName(), err) + } + + obj.SetLabels(mergeStringMaps(obj.GetLabels(), objectLabels)) + + phaseObj, err := toPhaseObject(ctx, obj, gvk) + if err != nil { + return nil, fmt.Errorf("converting object %s %s/%s: %w", gvk, obj.GetNamespace(), obj.GetName(), err) + } + + phase := determinePhase(gvk.GroupKind()) + phaseMap[phase] = append(phaseMap[phase], *phaseObj) + } + + var phases []*orbac.PhaseApplyConfiguration + for _, phaseName := range defaultPhaseOrder { + phaseObjs, ok := phaseMap[phaseName] + if !ok { + continue + } + slices.SortFunc(phaseObjs, comparePhaseObjectApplyConfigurations) + + chunks := slices.Collect(slices.Chunk(phaseObjs, maxObjectsPerPhase)) + multiChunk := len(chunks) > 1 + for chunkIdx, chunk := range chunks { + name := string(phaseName) + if multiChunk { + name = fmt.Sprintf("%s-%d", name, chunkIdx+1) + } + + ptrs := make([]*orbac.PhaseObjectApplyConfiguration, len(chunk)) + for i := range chunk { + ptrs[i] = &chunk[i] + } + phases = append(phases, orbac.Phase(). + WithName(name). + WithObjects(ptrs...)) + } + } + return phases, nil +} + +func toPhaseObject(ctx context.Context, obj client.Object, gvk schema.GroupVersionKind) (*orbac.PhaseObjectApplyConfiguration, error) { + unstrObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, fmt.Errorf("converting to unstructured: %w", err) + } + unstr := unstructured.Unstructured{Object: unstrObj} + unstr.SetGroupVersionKind(gvk) + + if err := cache.ApplyStripAnnotationsTransform(&unstr); err != nil { + return nil, fmt.Errorf("stripping annotations: %w", err) + } + sanitizedUnstructured(ctx, &unstr) + + raw, err := json.Marshal(unstr.Object) + if err != nil { + return nil, fmt.Errorf("marshaling to JSON: %w", err) + } + + phaseObj := orbac.PhaseObject(). + WithObject(runtime.RawExtension{Raw: raw}) + + if assertions := assertionsForGVK(gvk.GroupKind()); len(assertions) > 0 { + phaseObj.WithAssertions(assertions...) + } + + return phaseObj, nil +} + +func comparePhaseObjectApplyConfigurations(a, b orbac.PhaseObjectApplyConfiguration) int { + aObj := parseRawGVKNameNs(a.Object) + bObj := parseRawGVKNameNs(b.Object) + return cmp.Or( + cmp.Compare(aObj.group, bObj.group), + cmp.Compare(aObj.version, bObj.version), + cmp.Compare(aObj.kind, bObj.kind), + cmp.Compare(aObj.namespace, bObj.namespace), + cmp.Compare(aObj.name, bObj.name), + ) +} + +type rawObjIdentity struct { + group, version, kind, namespace, name string +} + +func parseRawGVKNameNs(raw *runtime.RawExtension) rawObjIdentity { + if raw == nil { + return rawObjIdentity{} + } + var obj unstructured.Unstructured + if err := json.Unmarshal(raw.Raw, &obj.Object); err != nil { + return rawObjIdentity{} + } + gvk := obj.GroupVersionKind() + return rawObjIdentity{ + group: gvk.Group, + version: gvk.Version, + kind: gvk.Kind, + namespace: obj.GetNamespace(), + name: obj.GetName(), + } +} + +var gkAssertions = map[schema.GroupKind][]*orbac.AssertionApplyConfiguration{ + {Group: apiextensions.GroupName, Kind: "CustomResourceDefinition"}: { + orbac.Assertion().WithConditionEqual( + orbac.ConditionEqualAssertion(). + WithType(string(apiextensions.Established)). + WithStatus(string(corev1.ConditionTrue))), + }, + {Group: certmanager.GroupName, Kind: "Certificate"}: { + orbac.Assertion().WithConditionEqual( + orbac.ConditionEqualAssertion(). + WithType("Ready"). + WithStatus("True")), + }, + {Group: certmanager.GroupName, Kind: "Issuer"}: { + orbac.Assertion().WithConditionEqual( + orbac.ConditionEqualAssertion(). + WithType("Ready"). + WithStatus("True")), + }, + {Kind: "Namespace"}: { + orbac.Assertion().WithFieldValue( + orbac.FieldValueAssertion(). + WithFieldPath("status.phase"). + WithValue(string(corev1.NamespaceActive))), + }, + {Kind: "PersistentVolumeClaim"}: { + orbac.Assertion().WithFieldValue( + orbac.FieldValueAssertion(). + WithFieldPath("status.phase"). + WithValue(string(corev1.ClaimBound))), + }, + {Group: appsv1.GroupName, Kind: "Deployment"}: { + orbac.Assertion().WithFieldsEqual( + orbac.FieldsEqualAssertion(). + WithFieldA("status.updatedReplicas"). + WithFieldB("status.replicas")), + orbac.Assertion().WithConditionEqual( + orbac.ConditionEqualAssertion(). + WithType(string(appsv1.DeploymentAvailable)). + WithStatus(string(corev1.ConditionTrue))), + }, + {Group: appsv1.GroupName, Kind: "StatefulSet"}: { + orbac.Assertion().WithFieldsEqual( + orbac.FieldsEqualAssertion(). + WithFieldA("status.updatedReplicas"). + WithFieldB("status.replicas")), + orbac.Assertion().WithConditionEqual( + orbac.ConditionEqualAssertion(). + WithType(string(appsv1.DeploymentAvailable)). + WithStatus(string(corev1.ConditionTrue))), + }, +} + +func assertionsForGVK(gk schema.GroupKind) []*orbac.AssertionApplyConfiguration { + return gkAssertions[gk] +} diff --git a/internal/operator-controller/applier/codgen_test.go b/internal/operator-controller/applier/codgen_test.go new file mode 100644 index 0000000000..f718ebc118 --- /dev/null +++ b/internal/operator-controller/applier/codgen_test.go @@ -0,0 +1,560 @@ +package applier_test + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "testing" + + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + bundlecsv "github.com/operator-framework/operator-controller/internal/testing/bundle/csv" + bundlefs "github.com/operator-framework/operator-controller/internal/testing/bundle/fs" +) + +var testScheme = func() *runtime.Scheme { + s := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(s)) + utilruntime.Must(apiextensionsv1.AddToScheme(s)) + utilruntime.Must(ocv1.AddToScheme(s)) + utilruntime.Must(appsv1.AddToScheme(s)) + utilruntime.Must(corev1.AddToScheme(s)) + return s +}() + +type fakeManifestProvider struct { + objs []client.Object + err error +} + +func (f *fakeManifestProvider) Get(_ fs.FS, _ *ocv1.ClusterExtension) ([]client.Object, error) { + return f.objs, f.err +} + +func TestRegistryV1CODGenerator_PhaseOrdering(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{ + objs: []client.Object{ + deployment("deploy-a", "ns1"), + configMap("cm-a", "ns1"), + serviceAccount("sa-a", "ns1"), + crd("things.example.com"), + }, + }, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + require.NotNil(t, cod) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 4) + assert.Equal(t, "identity", *phases[0].Name) + assert.Equal(t, "configuration", *phases[1].Name) + assert.Equal(t, "crds", *phases[2].Name) + assert.Equal(t, "deploy", *phases[3].Name) +} + +func TestRegistryV1CODGenerator_PhaseChunking(t *testing.T) { + objs := make([]client.Object, 0, 120) + for i := range 120 { + objs = append(objs, configMap(fmt.Sprintf("cm-%03d", i), "ns1")) + } + + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: objs}, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 3) + assert.Equal(t, "configuration-1", *phases[0].Name) + assert.Len(t, phases[0].Objects, 50) + assert.Equal(t, "configuration-2", *phases[1].Name) + assert.Len(t, phases[1].Objects, 50) + assert.Equal(t, "configuration-3", *phases[2].Name) + assert.Len(t, phases[2].Objects, 20) +} + +func TestRegistryV1CODGenerator_DeterministicSorting(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{ + objs: []client.Object{ + configMap("zebra", "ns1"), + configMap("alpha", "ns1"), + configMap("mid", "ns2"), + }, + }, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 1) + require.Len(t, phases[0].Objects, 3) + + names := extractObjectNames(t, phases[0].Objects) + // ns1/alpha, ns1/zebra, ns2/mid - sorted by namespace then name + assert.Equal(t, []string{"alpha", "zebra", "mid"}, names) +} + +func TestRegistryV1CODGenerator_Assertions(t *testing.T) { + tests := []struct { + name string + obj client.Object + wantAssertions int + checkAssertion func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) + }{ + { + name: "CRD gets Established=True assertion", + obj: crd("things.example.com"), + wantAssertions: 1, + checkAssertion: func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) { + require.NotNil(t, assertions[0].ConditionEqual) + assert.Equal(t, "Established", *assertions[0].ConditionEqual.Type) + assert.Equal(t, "True", *assertions[0].ConditionEqual.Status) + }, + }, + { + name: "Deployment gets two assertions", + obj: deployment("app", "ns1"), + wantAssertions: 2, + checkAssertion: func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) { + require.NotNil(t, assertions[0].FieldsEqual) + assert.Equal(t, "status.updatedReplicas", *assertions[0].FieldsEqual.FieldA) + assert.Equal(t, "status.replicas", *assertions[0].FieldsEqual.FieldB) + require.NotNil(t, assertions[1].ConditionEqual) + assert.Equal(t, "Available", *assertions[1].ConditionEqual.Type) + assert.Equal(t, "True", *assertions[1].ConditionEqual.Status) + }, + }, + { + name: "StatefulSet gets two assertions", + obj: statefulSet("app", "ns1"), + wantAssertions: 2, + checkAssertion: func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) { + require.NotNil(t, assertions[0].FieldsEqual) + require.NotNil(t, assertions[1].ConditionEqual) + }, + }, + { + name: "Namespace gets phase=Active assertion", + obj: namespace("test-ns"), + wantAssertions: 1, + checkAssertion: func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) { + require.NotNil(t, assertions[0].FieldValue) + assert.Equal(t, "status.phase", *assertions[0].FieldValue.FieldPath) + assert.Equal(t, "Active", *assertions[0].FieldValue.Value) + }, + }, + { + name: "PVC gets phase=Bound assertion", + obj: pvc("data", "ns1"), + wantAssertions: 1, + checkAssertion: func(t *testing.T, assertions []orbac.AssertionApplyConfiguration) { + require.NotNil(t, assertions[0].FieldValue) + assert.Equal(t, "status.phase", *assertions[0].FieldValue.FieldPath) + assert.Equal(t, "Bound", *assertions[0].FieldValue.Value) + }, + }, + { + name: "ConfigMap gets no assertions", + obj: configMap("cm", "ns1"), + wantAssertions: 0, + }, + { + name: "ServiceAccount gets no assertions", + obj: serviceAccount("sa", "ns1"), + wantAssertions: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{tt.obj}}, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 1) + require.Len(t, phases[0].Objects, 1) + assert.Len(t, phases[0].Objects[0].Assertions, tt.wantAssertions) + if tt.checkAssertion != nil && tt.wantAssertions > 0 { + tt.checkAssertion(t, phases[0].Objects[0].Assertions) + } + }) + } +} + +func TestRegistryV1CODGenerator_RevisionAnnotations(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + revisionAnnotations := map[string]string{ + labels.BundleNameKey: "my-bundle", + labels.PackageNameKey: "my-package", + labels.BundleVersionKey: "1.0.0", + labels.BundleReferenceKey: "quay.io/example/bundle:v1.0.0", + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, revisionAnnotations) + require.NoError(t, err) + + templateAnnotations := cod.Spec.Template.Metadata.Annotations + assert.Equal(t, "my-bundle", templateAnnotations[labels.BundleNameKey]) + assert.Equal(t, "my-package", templateAnnotations[labels.PackageNameKey]) + assert.Equal(t, "1.0.0", templateAnnotations[labels.BundleVersionKey]) + assert.Equal(t, "quay.io/example/bundle:v1.0.0", templateAnnotations[labels.BundleReferenceKey]) +} + +func TestRegistryV1CODGenerator_BundleAnnotations(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + t.Run("olm.properties propagated from bundle", func(t *testing.T) { + bundleFS := bundlefs.Builder(). + WithPackageName("test-package"). + WithBundleProperty("olm.bundle.property", "some-value"). + WithCSV(bundlecsv.Builder().WithName("test-csv").Build()). + Build() + + cod, err := gen.GenerateCOD(context.Background(), bundleFS, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + annotations := cod.Spec.Template.Metadata.Annotations + require.Contains(t, annotations, "olm.properties") + assert.JSONEq(t, `[{"type":"olm.bundle.property","value":"some-value"}]`, annotations["olm.properties"]) + }) + + t.Run("olm.properties not set when bundle has no properties", func(t *testing.T) { + bundleFS := bundlefs.Builder(). + WithPackageName("test-package"). + WithCSV(bundlecsv.Builder().WithName("test-csv").Build()). + Build() + + cod, err := gen.GenerateCOD(context.Background(), bundleFS, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + annotations := cod.Spec.Template.Metadata.Annotations + assert.NotContains(t, annotations, "olm.properties") + }) + + t.Run("caller revisionAnnotations win over bundle olm.properties", func(t *testing.T) { + bundleFS := bundlefs.Builder(). + WithPackageName("test-package"). + WithBundleProperty("olm.bundle.property", "some-value"). + WithCSV(bundlecsv.Builder().WithName("test-csv").Build()). + Build() + + revisionAnnotations := map[string]string{ + "olm.properties": "caller-wins", + } + cod, err := gen.GenerateCOD(context.Background(), bundleFS, testExtension("test-ext"), nil, revisionAnnotations) + require.NoError(t, err) + + annotations := cod.Spec.Template.Metadata.Annotations + assert.Equal(t, "caller-wins", annotations["olm.properties"]) + }) +} + +func TestRegistryV1CODGenerator_CODName(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("my-extension"), nil, nil) + require.NoError(t, err) + assert.Equal(t, "my-extension", *cod.GetName()) +} + +func TestRegistryV1CODGenerator_OwnerReference(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + ext := testExtension("my-extension") + ext.UID = "test-uid" + cod, err := gen.GenerateCOD(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + + require.Len(t, cod.OwnerReferences, 1) + ref := cod.OwnerReferences[0] + assert.Equal(t, ocv1.ClusterExtensionKind, *ref.Kind) + assert.Equal(t, "my-extension", *ref.Name) + assert.Equal(t, ext.UID, *ref.UID) + require.NotNil(t, ref.Controller) + assert.True(t, *ref.Controller) + require.NotNil(t, ref.BlockOwnerDeletion) + assert.True(t, *ref.BlockOwnerDeletion) +} + +func TestRegistryV1CODGenerator_ProgressDeadlineMinutes(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + t.Run("set when non-zero", func(t *testing.T) { + ext := testExtension("test-ext") + ext.Spec.ProgressDeadlineMinutes = 10 + cod, err := gen.GenerateCOD(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + require.NotNil(t, cod.Spec.ProgressDeadlineMinutes) + assert.Equal(t, int32(10), *cod.Spec.ProgressDeadlineMinutes) + }) + + t.Run("not set when zero", func(t *testing.T) { + ext := testExtension("test-ext") + ext.Spec.ProgressDeadlineMinutes = 0 + cod, err := gen.GenerateCOD(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + assert.Nil(t, cod.Spec.ProgressDeadlineMinutes) + }) +} + +func TestRegistryV1CODGenerator_CollisionProtection(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{configMap("cm", "ns1")}}, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + require.NotNil(t, cod.Spec.Template.Spec.CollisionProtection) + assert.Equal(t, orbv1alpha1.CollisionProtectionPrevent, *cod.Spec.Template.Spec.CollisionProtection) +} + +func TestRegistryV1CODGenerator_ObjectsSerialized(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{ + objs: []client.Object{configMap("test-cm", "ns1")}, + }, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 1) + require.Len(t, phases[0].Objects, 1) + + raw := phases[0].Objects[0].Object + require.NotNil(t, raw) + + var obj unstructured.Unstructured + require.NoError(t, json.Unmarshal(raw.Raw, &obj.Object)) + assert.Equal(t, "ConfigMap", obj.GetKind()) + assert.Equal(t, "test-cm", obj.GetName()) + assert.Equal(t, "ns1", obj.GetNamespace()) +} + +func TestRegistryV1CODGenerator_ObjectLabels(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{ + objs: []client.Object{configMap("cm", "ns1")}, + }, + Scheme: testScheme, + } + + t.Run("set on COD metadata, template metadata, and individual objects", func(t *testing.T) { + objLabels := map[string]string{ + labels.OwnerKindKey: "ClusterExtension", + labels.OwnerNameKey: "test-ext", + } + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), objLabels, nil) + require.NoError(t, err) + + assert.Equal(t, "ClusterExtension", cod.Labels[labels.OwnerKindKey]) + assert.Equal(t, "test-ext", cod.Labels[labels.OwnerNameKey]) + + templateLabels := cod.Spec.Template.Metadata.Labels + assert.Equal(t, "ClusterExtension", templateLabels[labels.OwnerKindKey]) + assert.Equal(t, "test-ext", templateLabels[labels.OwnerNameKey]) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 1) + require.Len(t, phases[0].Objects, 1) + var obj unstructured.Unstructured + require.NoError(t, json.Unmarshal(phases[0].Objects[0].Object.Raw, &obj.Object)) + assert.Equal(t, "ClusterExtension", obj.GetLabels()[labels.OwnerKindKey]) + assert.Equal(t, "test-ext", obj.GetLabels()[labels.OwnerNameKey]) + }) + + t.Run("objectLabels override conflicting object labels", func(t *testing.T) { + cm := configMap("cm", "ns1") + cm.SetLabels(map[string]string{ + labels.OwnerKindKey: "should-be-overwritten", + "app": "preserved", + }) + genWithLabeled := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{cm}}, + Scheme: testScheme, + } + objLabels := map[string]string{ + labels.OwnerKindKey: "ClusterExtension", + labels.OwnerNameKey: "test-ext", + } + cod, err := genWithLabeled.GenerateCOD(context.Background(), nil, testExtension("test-ext"), objLabels, nil) + require.NoError(t, err) + + var obj unstructured.Unstructured + require.NoError(t, json.Unmarshal(cod.Spec.Template.Spec.Phases[0].Objects[0].Object.Raw, &obj.Object)) + assert.Equal(t, "ClusterExtension", obj.GetLabels()[labels.OwnerKindKey]) + assert.Equal(t, "test-ext", obj.GetLabels()[labels.OwnerNameKey]) + assert.Equal(t, "preserved", obj.GetLabels()["app"]) + }) + + t.Run("not set when nil", func(t *testing.T) { + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + if cod.ObjectMetaApplyConfiguration != nil { + assert.Empty(t, cod.Labels) + } + assert.Nil(t, cod.Spec.Template.Metadata.Labels) + }) +} + +func TestRegistryV1CODGenerator_ManifestProviderError(t *testing.T) { + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{ + err: assert.AnError, + }, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + assert.Nil(t, cod) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestRegistryV1CODGenerator_SanitizedMetadata(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "ns1", + ResourceVersion: "12345", + UID: "abc-123", + Labels: map[string]string{"app": "test"}, + Annotations: map[string]string{"note": "value"}, + }, + } + + gen := &applier.RegistryV1CODGenerator{ + ManifestProvider: &fakeManifestProvider{objs: []client.Object{cm}}, + Scheme: testScheme, + } + + cod, err := gen.GenerateCOD(context.Background(), nil, testExtension("test-ext"), nil, nil) + require.NoError(t, err) + + phases := cod.Spec.Template.Spec.Phases + require.Len(t, phases, 1) + require.Len(t, phases[0].Objects, 1) + + var obj unstructured.Unstructured + require.NoError(t, json.Unmarshal(phases[0].Objects[0].Object.Raw, &obj.Object)) + + // name, namespace, labels, annotations should be preserved + assert.Equal(t, "test-cm", obj.GetName()) + assert.Equal(t, "ns1", obj.GetNamespace()) + assert.Equal(t, map[string]string{"app": "test"}, obj.GetLabels()) + + // resourceVersion, uid should be stripped by sanitizedUnstructured + assert.Empty(t, obj.GetResourceVersion()) + assert.Empty(t, string(obj.GetUID())) +} + +// helpers + +func testExtension(name string) *ocv1.ClusterExtension { + return &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } +} + +func configMap(name, namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +func serviceAccount(name, namespace string) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +func deployment(name, namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +func statefulSet(name, namespace string) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +func namespace(name string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } +} + +func pvc(name, namespace string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +func crd(name string) *apiextensionsv1.CustomResourceDefinition { + return &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } +} + +func extractObjectNames(t *testing.T, objs []orbac.PhaseObjectApplyConfiguration) []string { + t.Helper() + names := make([]string, 0, len(objs)) + for _, obj := range objs { + var u unstructured.Unstructured + require.NoError(t, json.Unmarshal(obj.Object.Raw, &u.Object)) + names = append(names, u.GetName()) + } + return names +} diff --git a/internal/operator-controller/applier/orboperator.go b/internal/operator-controller/applier/orboperator.go index cda03013f3..7a5e3a039f 100644 --- a/internal/operator-controller/applier/orboperator.go +++ b/internal/operator-controller/applier/orboperator.go @@ -2,11 +2,13 @@ package applier import ( "context" + "fmt" "io/fs" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" ocv1 "github.com/operator-framework/operator-controller/api/v1" ) @@ -14,11 +16,23 @@ import ( type OrbOperator struct { Client client.Client Scheme *runtime.Scheme + Generator CODGenerator Preflights []Preflight FieldOwner string } -func (o *OrbOperator) Apply(ctx context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (bool, string, error) { - log.FromContext(ctx).Info("OrbOperatorRuntime applier not yet implemented") +func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) { + l := log.FromContext(ctx) + + cod, err := o.Generator.GenerateCOD(ctx, contentFS, ext, objectLabels, revisionAnnotations) + if err != nil { + return false, "", fmt.Errorf("generating COD: %w", err) + } + + codYAML, err := yaml.Marshal(cod) + if err != nil { + return false, "", fmt.Errorf("marshaling COD to YAML: %w", err) + } + l.Info("generated ClusterObjectDeployment", "cod", string(codYAML)) return false, "", nil } diff --git a/internal/operator-controller/scheme/scheme.go b/internal/operator-controller/scheme/scheme.go index de29c4799e..468a6adc89 100644 --- a/internal/operator-controller/scheme/scheme.go +++ b/internal/operator-controller/scheme/scheme.go @@ -4,6 +4,7 @@ import ( orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -19,6 +20,7 @@ func init() { utilruntime.Must(ocv1.AddToScheme(Scheme)) utilruntime.Must(appsv1.AddToScheme(Scheme)) utilruntime.Must(corev1.AddToScheme(Scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(Scheme)) utilruntime.Must(orbv1alpha1.AddToScheme(Scheme)) //+kubebuilder:scaffold:scheme } From 45db3be5fa13c2bb4a76fa12cbc936def80b4bd9 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 21:16:10 -0400 Subject: [PATCH 10/26] :seedling: Mark cod-generator spec done --- specs/{ => closed}/2026-08-12-cod-generator/README.md | 10 ++++++++-- specs/{ => closed}/2026-08-12-cod-generator/plan.md | 1 + .../2026-08-12-cod-generator/requirements.md | 2 ++ .../2026-08-12-cod-generator/verification.md | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) rename specs/{ => closed}/2026-08-12-cod-generator/README.md (84%) rename specs/{ => closed}/2026-08-12-cod-generator/plan.md (95%) rename specs/{ => closed}/2026-08-12-cod-generator/requirements.md (88%) rename specs/{ => closed}/2026-08-12-cod-generator/verification.md (93%) diff --git a/specs/2026-08-12-cod-generator/README.md b/specs/closed/2026-08-12-cod-generator/README.md similarity index 84% rename from specs/2026-08-12-cod-generator/README.md rename to specs/closed/2026-08-12-cod-generator/README.md index 142ab532b7..efbbf4679d 100644 --- a/specs/2026-08-12-cod-generator/README.md +++ b/specs/closed/2026-08-12-cod-generator/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # COD Generator @@ -30,6 +30,7 @@ The interface lives in `internal/operator-controller/applier/` alongside the exi The return is a `*orbac.ClusterObjectDeploymentApplyConfiguration` (from `github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1`). The COD is populated with: - `WithName(ext.Name)` - COD name matches ClusterExtension name (1:1 mapping) +- `WithOwnerReferences(...)` - a controller ownerReference to the ClusterExtension (see below) - `WithSpec(codSpec)` where codSpec has: - `WithProgressDeadlineMinutes(ext.Spec.ProgressDeadlineMinutes)` if set - `WithTemplate(template)` where template has: @@ -41,10 +42,11 @@ The return is a `*orbac.ClusterObjectDeploymentApplyConfiguration` (from `github ```go type RegistryV1CODGenerator struct { ManifestProvider ManifestProvider + Scheme *runtime.Scheme } ``` -Takes the existing `ManifestProvider` (shared with Boxcutter/Helm paths) to render manifests from the bundle FS, then converts the flat `[]client.Object` into orb-operator phases. +Takes the existing `ManifestProvider` (shared with Boxcutter/Helm paths) to render manifests from the bundle FS, then converts the flat `[]client.Object` into orb-operator phases. The `Scheme` is used to resolve the ClusterExtension GVK for the owner reference. ### Phase sorting and assertions @@ -85,6 +87,10 @@ These are the same annotations that Boxcutter puts on COS revision annotations. Object labels (e.g., `olm.operatorframework.io/owner-kind`, `olm.operatorframework.io/owner-name`) are NOT set by the COD generator. They are applied by the caller (the applier) on the COD's top-level metadata and template metadata for cache filtering. This keeps the generator focused on content translation. +### Owner reference + +The generator sets a single controller ownerReference on the COD pointing at the ClusterExtension (`APIVersion`/`Kind` resolved from the `Scheme`, `Name`/`UID` from `ext`, `Controller: true`, `BlockOwnerDeletion: true`). Because the COD and the ClusterExtension are both cluster-scoped, this is a valid owner relationship that makes the COD garbage-collected when the ClusterExtension is deleted and lets owner-based watches (`Owns(&ClusterObjectDeployment{})`) enqueue the ClusterExtension on COD changes. The externalizer later copies this owner reference onto the ClusterObjectSlices so they are cleaned up and watched the same way. + ### Error handling The generator returns errors for: diff --git a/specs/2026-08-12-cod-generator/plan.md b/specs/closed/2026-08-12-cod-generator/plan.md similarity index 95% rename from specs/2026-08-12-cod-generator/plan.md rename to specs/closed/2026-08-12-cod-generator/plan.md index 74ceb4a066..6b17431535 100644 --- a/specs/2026-08-12-cod-generator/plan.md +++ b/specs/closed/2026-08-12-cod-generator/plan.md @@ -20,6 +20,7 @@ - Group by phase, sort phases by `defaultPhaseOrder`, sort objects within each phase deterministically - Build `ClusterObjectDeploymentApplyConfiguration`: - `WithName(ext.Name)` + - `WithOwnerReferences(...)` - controller ownerReference to the ClusterExtension (GVK via `apiutil.GVKForObject(ext, g.Scheme)`) - Spec with `WithProgressDeadlineMinutes` (if set) and template - Template with metadata annotations (revision annotations) and spec (collision protection + phases) - Return the COD diff --git a/specs/2026-08-12-cod-generator/requirements.md b/specs/closed/2026-08-12-cod-generator/requirements.md similarity index 88% rename from specs/2026-08-12-cod-generator/requirements.md rename to specs/closed/2026-08-12-cod-generator/requirements.md index 65a52f8c87..cdc7f1fc27 100644 --- a/specs/2026-08-12-cod-generator/requirements.md +++ b/specs/closed/2026-08-12-cod-generator/requirements.md @@ -8,6 +8,7 @@ - Serialize objects as `runtime.RawExtension` JSON with sanitized metadata (existing `sanitizedUnstructured` helper) - Propagate bundle metadata (name, version, package, reference, OLM properties) as COD template metadata annotations - Set collision protection at the template spec level (`Prevent`) +- Set a controller ownerReference on the COD pointing at the ClusterExtension (GVK resolved from the `Scheme`; `Controller`/`BlockOwnerDeletion` true) - COD name equals `ext.Name` - Support `progressDeadlineMinutes` from ClusterExtension spec - Wire `CODGenerator` into `OrbOperator` struct and have `Apply` call `GenerateCOD` @@ -20,6 +21,7 @@ - CRDs get `ConditionEqual{Established, True}` assertions; Deployments/StatefulSets get `FieldsEqual{updatedReplicas, replicas}` + `ConditionEqual{Available, True}` assertions; Namespaces get `FieldValue{status.phase, Active}` - Objects without assertion rules have empty assertions (no assertions = immediately available) - Bundle revision annotations appear on template metadata +- The COD carries a controller ownerReference to the ClusterExtension (correct Kind/Name/UID, `Controller` and `BlockOwnerDeletion` true) - `progressDeadlineMinutes` from ClusterExtension is set on the COD spec when non-zero - `OrbOperator.Apply` calls `GenerateCOD` with the bundle FS and revision annotations - `orbOperatorReconcilerConfigurator` constructs `RegistryV1CODGenerator` with the shared `ManifestProvider` diff --git a/specs/2026-08-12-cod-generator/verification.md b/specs/closed/2026-08-12-cod-generator/verification.md similarity index 93% rename from specs/2026-08-12-cod-generator/verification.md rename to specs/closed/2026-08-12-cod-generator/verification.md index 5d67a368e1..0b7017f7f0 100644 --- a/specs/2026-08-12-cod-generator/verification.md +++ b/specs/closed/2026-08-12-cod-generator/verification.md @@ -14,6 +14,7 @@ - cert-manager Certificate/Issuer: Ready=True - Other objects: no assertions - [ ] COD name equals `ext.Name` +- [ ] COD has a controller ownerReference to the ClusterExtension (Kind/Name/UID correct, `Controller` and `BlockOwnerDeletion` true) - [ ] `progressDeadlineMinutes` set on COD spec when `ext.Spec.ProgressDeadlineMinutes > 0` - [ ] Collision protection set to `Prevent` at template spec level - [ ] Revision annotations on template metadata include bundle name, version, package, reference, and OLM properties From a1e922df2337ca9a37d7febaf68b43f566112e47 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Wed, 12 Aug 2026 22:38:30 -0400 Subject: [PATCH 11/26] planning: orb-operator preflight check --- .../README.md | 41 +++++++++++++++++++ .../plan.md | 10 +++++ .../requirements.md | 15 +++++++ .../verification.md | 18 ++++++++ 4 files changed, 84 insertions(+) create mode 100644 specs/2026-08-13-orb-operator-preflight-check/README.md create mode 100644 specs/2026-08-13-orb-operator-preflight-check/plan.md create mode 100644 specs/2026-08-13-orb-operator-preflight-check/requirements.md create mode 100644 specs/2026-08-13-orb-operator-preflight-check/verification.md diff --git a/specs/2026-08-13-orb-operator-preflight-check/README.md b/specs/2026-08-13-orb-operator-preflight-check/README.md new file mode 100644 index 0000000000..87a544f462 --- /dev/null +++ b/specs/2026-08-13-orb-operator-preflight-check/README.md @@ -0,0 +1,41 @@ +--- +status: in-progress +--- +# orb-operator Preflight Check + +## Summary + +Add a preflight runner for the orb-operator applier path that extracts `[]client.Object` from a `ClusterObjectDeploymentApplyConfiguration`'s phases and runs them through the existing `Preflight` interface. This bridges the COD generator's `runtime.RawExtension`-based output with the CRD upgrade safety checks that Helm and Boxcutter appliers already perform. + +## Design + +### Function signature + +```go +func runPreflights( + ctx context.Context, + ext *ocv1.ClusterExtension, + cod *orbac.ClusterObjectDeploymentApplyConfiguration, + preflights []Preflight, +) error +``` + +Lives in `internal/operator-controller/applier/` alongside the existing `Preflight` interface and `shouldSkipPreflight` helper. + +### Object extraction + +Each phase in the COD contains `PhaseObject` entries with `Object *runtime.RawExtension`. The runner deserializes each into an `*unstructured.Unstructured` via `json.Unmarshal`, collecting all objects across all phases into a flat `[]client.Object` slice. Objects without a `RawExtension` (e.g. ref-only entries, though the COD generator does not produce those) are skipped. + +### Preflight execution + +The runner always calls `preflight.Upgrade(ctx, objs)` for each preflight. The existing CRD upgrade safety implementation handles both install and upgrade scenarios internally - when no existing CRD is found on the cluster, it returns nil (the "nothing to break" case). There is no need to determine install-vs-upgrade state or call `preflight.Install` separately. + +Before calling Upgrade, the runner checks `shouldSkipPreflight(ctx, preflight, ext, "NeedsUpgrade")` and skips the preflight if it returns true. This preserves the existing behavior where CRD upgrade safety enforcement set to `None` bypasses the check. + +### Error handling + +Preflight errors are returned directly to the caller (the orb-operator Apply method). The applier is responsible for classifying errors as terminal vs retryable and mapping them to ClusterExtension status conditions. + +### Why not a shared runner for all appliers + +The Helm and Boxcutter appliers have their own state detection logic interleaved with preflight execution (Helm's server-side dry-run, Boxcutter's SSA patch comparison). Extracting a shared runner would require untangling that state detection, which is out of scope and unnecessary - the orb-operator path simplifies by always calling Upgrade. diff --git a/specs/2026-08-13-orb-operator-preflight-check/plan.md b/specs/2026-08-13-orb-operator-preflight-check/plan.md new file mode 100644 index 0000000000..bc84fda91e --- /dev/null +++ b/specs/2026-08-13-orb-operator-preflight-check/plan.md @@ -0,0 +1,10 @@ +# Implementation Plan + +1. Add `runPreflights` and `extractObjectsFromCOD` in `internal/operator-controller/applier/orboperator.go` + - `extractObjectsFromCOD(*orbac.ClusterObjectDeploymentApplyConfiguration) ([]client.Object, error)` iterates phases and deserializes each `PhaseObject.Object` RawExtension into `*unstructured.Unstructured` + - `runPreflights(ctx, ext, cod, preflights) error` calls `extractObjectsFromCOD`, then loops over preflights calling `shouldSkipPreflight` and `preflight.Upgrade` + - Wire up `runPreflights` in `OrbOperator.Apply` between COD generation and COD application + +2. Add unit tests in `internal/operator-controller/applier/orboperator_test.go` + - Test `extractObjectsFromCOD` with multi-phase COD, empty COD, nil RawExtension entries, invalid JSON + - Test `runPreflights` with mock preflights: skip logic, error propagation, empty object list diff --git a/specs/2026-08-13-orb-operator-preflight-check/requirements.md b/specs/2026-08-13-orb-operator-preflight-check/requirements.md new file mode 100644 index 0000000000..278f2f5880 --- /dev/null +++ b/specs/2026-08-13-orb-operator-preflight-check/requirements.md @@ -0,0 +1,15 @@ +# Requirements + +- Extract all objects from a COD apply configuration's phases, deserializing `runtime.RawExtension` entries to `*unstructured.Unstructured` +- Run each configured preflight's `Upgrade` method with the extracted objects +- Respect `shouldSkipPreflight` logic (skip CRD upgrade safety when enforcement is `None`) +- Return preflight errors without classifying them (caller's responsibility) +- Skip phase objects that have no `RawExtension` data + +## Acceptance Criteria + +- `runPreflights` with a COD containing CRDs and a strict CRD upgrade safety preflight calls the preflight's `Upgrade` method with the full object list +- `runPreflights` with enforcement set to `None` skips the CRD upgrade safety preflight +- `runPreflights` with an empty COD (no phases or no objects) returns nil +- `runPreflights` with a COD containing an object with invalid JSON in the `RawExtension` returns an error +- `runPreflights` with multiple preflights runs all non-skipped preflights and returns all errors joined diff --git a/specs/2026-08-13-orb-operator-preflight-check/verification.md b/specs/2026-08-13-orb-operator-preflight-check/verification.md new file mode 100644 index 0000000000..0edb7ad5a0 --- /dev/null +++ b/specs/2026-08-13-orb-operator-preflight-check/verification.md @@ -0,0 +1,18 @@ +# Verification + +## Implementation Correctness + +- [ ] `extractObjectsFromCOD` correctly deserializes RawExtension JSON into Unstructured objects with GVK preserved +- [ ] `runPreflights` calls `shouldSkipPreflight` with state `"NeedsUpgrade"` for each preflight +- [ ] `runPreflights` calls `preflight.Upgrade` (not Install) for non-skipped preflights +- [ ] Phase objects with nil RawExtension are skipped without error +- [ ] Invalid JSON in RawExtension produces a descriptive error + +## Project Conventions + +- [ ] Function placed in `internal/operator-controller/applier/preflight.go` alongside existing preflight code +- [ ] Tests use testify assertions and table-driven patterns consistent with existing tests +- [ ] No new interfaces introduced - uses existing `Preflight` interface +- [ ] `make test-unit` passes +- [ ] `make lint` passes +- [ ] Commit message uses `:sparkles:` prefix From 75bcacdf6f73ad800d444ca7b550250a74e26f88 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 08:24:00 -0400 Subject: [PATCH 12/26] :sparkles: Add runPreflights for orb-operator applier --- .../applier/orboperator.go | 57 ++++- .../applier/orboperator_test.go | 208 ++++++++++++++++++ 2 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 internal/operator-controller/applier/orboperator_test.go diff --git a/internal/operator-controller/applier/orboperator.go b/internal/operator-controller/applier/orboperator.go index 7a5e3a039f..7246585c75 100644 --- a/internal/operator-controller/applier/orboperator.go +++ b/internal/operator-controller/applier/orboperator.go @@ -2,13 +2,16 @@ package applier import ( "context" + "encoding/json" + "errors" "fmt" "io/fs" + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/yaml" ocv1 "github.com/operator-framework/operator-controller/api/v1" ) @@ -21,6 +24,49 @@ type OrbOperator struct { FieldOwner string } +func runPreflights(ctx context.Context, ext *ocv1.ClusterExtension, cod *orbac.ClusterObjectDeploymentApplyConfiguration, preflights []Preflight) error { + objs, err := extractObjectsFromCOD(cod) + if err != nil { + return fmt.Errorf("extracting objects from COD: %w", err) + } + + var errs []error + for _, pf := range preflights { + if shouldSkipPreflight(ctx, pf, ext, StateNeedsUpgrade) { + continue + } + if err := pf.Upgrade(ctx, objs); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func extractObjectsFromCOD(cod *orbac.ClusterObjectDeploymentApplyConfiguration) ([]client.Object, error) { + if cod == nil || cod.Spec == nil || cod.Spec.Template == nil || cod.Spec.Template.Spec == nil { + return nil, nil + } + + var objs []client.Object + for _, phase := range cod.Spec.Template.Spec.Phases { + for i, po := range phase.Objects { + if po.Object == nil || len(po.Object.Raw) == 0 { + continue + } + var obj unstructured.Unstructured + if err := json.Unmarshal(po.Object.Raw, &obj.Object); err != nil { + phaseName := "<unnamed>" + if phase.Name != nil { + phaseName = *phase.Name + } + return nil, fmt.Errorf("phase %q object %d: %w", phaseName, i, err) + } + objs = append(objs, &obj) + } + } + return objs, nil +} + func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) { l := log.FromContext(ctx) @@ -29,10 +75,11 @@ func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clus return false, "", fmt.Errorf("generating COD: %w", err) } - codYAML, err := yaml.Marshal(cod) - if err != nil { - return false, "", fmt.Errorf("marshaling COD to YAML: %w", err) + if err := runPreflights(ctx, ext, cod, o.Preflights); err != nil { + l.Info("preflight checks failed", "error", err) + return false, "", err } - l.Info("generated ClusterObjectDeployment", "cod", string(codYAML)) + l.Info("preflight checks passed") + return false, "", nil } diff --git a/internal/operator-controller/applier/orboperator_test.go b/internal/operator-controller/applier/orboperator_test.go new file mode 100644 index 0000000000..e8181a3181 --- /dev/null +++ b/internal/operator-controller/applier/orboperator_test.go @@ -0,0 +1,208 @@ +package applier + +import ( + "context" + "errors" + "testing" + + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +func TestExtractObjectsFromCOD(t *testing.T) { + crdJSON := `{"apiVersion":"apiextensions.k8s.io/v1","kind":"CustomResourceDefinition","metadata":{"name":"things.example.com"}}` + deployJSON := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"my-deploy","namespace":"ns1"}}` + + tests := []struct { + name string + cod *orbac.ClusterObjectDeploymentApplyConfiguration + wantCount int + wantErr string + }{ + { + name: "nil COD", + cod: nil, + wantCount: 0, + }, + { + name: "nil spec", + cod: orbac.ClusterObjectDeployment("test"). + WithSpec(nil), + wantCount: 0, + }, + { + name: "empty phases", + cod: orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec()))), + wantCount: 0, + }, + { + name: "multi-phase with objects", + cod: orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("crds").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(crdJSON)}), + ), + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(deployJSON)}), + ), + )))), + wantCount: 2, + }, + { + name: "nil RawExtension skipped", + cod: orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("p").WithObjects( + orbac.PhaseObject(), + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(crdJSON)}), + ), + )))), + wantCount: 1, + }, + { + name: "invalid JSON", + cod: orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("bad").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(`{not valid`)}), + ), + )))), + wantErr: `phase "bad" object 0`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + objs, err := extractObjectsFromCOD(tc.cod) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Len(t, objs, tc.wantCount) + }) + } +} + +func TestExtractObjectsFromCOD_PreservesGVK(t *testing.T) { + crdJSON := `{"apiVersion":"apiextensions.k8s.io/v1","kind":"CustomResourceDefinition","metadata":{"name":"things.example.com"}}` + cod := orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("crds").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(crdJSON)}), + ), + )))) + + objs, err := extractObjectsFromCOD(cod) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "apiextensions.k8s.io/v1", objs[0].GetObjectKind().GroupVersionKind().GroupVersion().String()) + assert.Equal(t, "CustomResourceDefinition", objs[0].GetObjectKind().GroupVersionKind().Kind) + assert.Equal(t, "things.example.com", objs[0].GetName()) +} + +type fakePreflight struct { + upgradeErr error + called bool +} + +func (f *fakePreflight) Install(_ context.Context, _ []client.Object) error { return nil } +func (f *fakePreflight) Upgrade(_ context.Context, _ []client.Object) error { + f.called = true + return f.upgradeErr +} + +func TestRunPreflights(t *testing.T) { + deployJSON := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"d","namespace":"ns"}}` + codWithObj := orbac.ClusterObjectDeployment("test"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(deployJSON)}), + ), + )))) + + t.Run("calls Upgrade on each preflight", func(t *testing.T) { + pf1 := &fakePreflight{} + pf2 := &fakePreflight{} + ext := &ocv1.ClusterExtension{} + + err := runPreflights(context.Background(), ext, codWithObj, []Preflight{pf1, pf2}) + require.NoError(t, err) + assert.True(t, pf1.called) + assert.True(t, pf2.called) + }) + + t.Run("collects all errors", func(t *testing.T) { + pf1 := &fakePreflight{upgradeErr: errors.New("pf1 failed")} + pf2 := &fakePreflight{upgradeErr: errors.New("pf2 failed")} + ext := &ocv1.ClusterExtension{} + + err := runPreflights(context.Background(), ext, codWithObj, []Preflight{pf1, pf2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "pf1 failed") + assert.Contains(t, err.Error(), "pf2 failed") + }) + + t.Run("nil preflights returns nil", func(t *testing.T) { + ext := &ocv1.ClusterExtension{} + err := runPreflights(context.Background(), ext, codWithObj, nil) + require.NoError(t, err) + }) + + t.Run("empty COD returns nil", func(t *testing.T) { + pf := &fakePreflight{} + ext := &ocv1.ClusterExtension{} + emptyCOD := orbac.ClusterObjectDeployment("test") + + err := runPreflights(context.Background(), ext, emptyCOD, []Preflight{pf}) + require.NoError(t, err) + assert.True(t, pf.called) + }) + + t.Run("skips CRDUpgradeSafety when enforcement is None", func(t *testing.T) { + pf := &fakePreflight{upgradeErr: errors.New("should not be called")} + ext := &ocv1.ClusterExtension{ + Spec: ocv1.ClusterExtensionSpec{ + Install: &ocv1.ClusterExtensionInstallConfig{ + Preflight: &ocv1.PreflightConfig{ + CRDUpgradeSafety: &ocv1.CRDUpgradeSafetyPreflightConfig{ + Enforcement: ocv1.CRDUpgradeSafetyEnforcementNone, + }, + }, + }, + }, + } + + // shouldSkipPreflight only skips *crdupgradesafety.Preflight instances, not + // our fakePreflight, so a fakePreflight will still run even with enforcement None. + // This test verifies the call path works; the skip type assertion is tested + // elsewhere. + err := runPreflights(context.Background(), ext, codWithObj, []Preflight{pf}) + require.Error(t, err) + assert.True(t, pf.called) + }) +} From a36dda8be958ff40e07dee37e2cdea70c39d94cf Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 08:26:09 -0400 Subject: [PATCH 13/26] :seedling: Mark orb-operator-preflight-check spec done --- .../2026-08-13-orb-operator-preflight-check/README.md | 2 +- .../2026-08-13-orb-operator-preflight-check/plan.md | 0 .../2026-08-13-orb-operator-preflight-check/requirements.md | 0 .../2026-08-13-orb-operator-preflight-check/verification.md | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename specs/{ => closed}/2026-08-13-orb-operator-preflight-check/README.md (99%) rename specs/{ => closed}/2026-08-13-orb-operator-preflight-check/plan.md (100%) rename specs/{ => closed}/2026-08-13-orb-operator-preflight-check/requirements.md (100%) rename specs/{ => closed}/2026-08-13-orb-operator-preflight-check/verification.md (100%) diff --git a/specs/2026-08-13-orb-operator-preflight-check/README.md b/specs/closed/2026-08-13-orb-operator-preflight-check/README.md similarity index 99% rename from specs/2026-08-13-orb-operator-preflight-check/README.md rename to specs/closed/2026-08-13-orb-operator-preflight-check/README.md index 87a544f462..af4e13d14f 100644 --- a/specs/2026-08-13-orb-operator-preflight-check/README.md +++ b/specs/closed/2026-08-13-orb-operator-preflight-check/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator Preflight Check diff --git a/specs/2026-08-13-orb-operator-preflight-check/plan.md b/specs/closed/2026-08-13-orb-operator-preflight-check/plan.md similarity index 100% rename from specs/2026-08-13-orb-operator-preflight-check/plan.md rename to specs/closed/2026-08-13-orb-operator-preflight-check/plan.md diff --git a/specs/2026-08-13-orb-operator-preflight-check/requirements.md b/specs/closed/2026-08-13-orb-operator-preflight-check/requirements.md similarity index 100% rename from specs/2026-08-13-orb-operator-preflight-check/requirements.md rename to specs/closed/2026-08-13-orb-operator-preflight-check/requirements.md diff --git a/specs/2026-08-13-orb-operator-preflight-check/verification.md b/specs/closed/2026-08-13-orb-operator-preflight-check/verification.md similarity index 100% rename from specs/2026-08-13-orb-operator-preflight-check/verification.md rename to specs/closed/2026-08-13-orb-operator-preflight-check/verification.md From 032cc086c3e26ad8f9185e3c247deec0d2574327 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 09:04:25 -0400 Subject: [PATCH 14/26] planning: orb-operator externalizer --- .../README.md | 58 +++++++++++++++++++ .../plan.md | 23 ++++++++ .../requirements.md | 25 ++++++++ .../verification.md | 27 +++++++++ 4 files changed, 133 insertions(+) create mode 100644 specs/2026-08-13-orb-operator-externalizer/README.md create mode 100644 specs/2026-08-13-orb-operator-externalizer/plan.md create mode 100644 specs/2026-08-13-orb-operator-externalizer/requirements.md create mode 100644 specs/2026-08-13-orb-operator-externalizer/verification.md diff --git a/specs/2026-08-13-orb-operator-externalizer/README.md b/specs/2026-08-13-orb-operator-externalizer/README.md new file mode 100644 index 0000000000..fb493a9031 --- /dev/null +++ b/specs/2026-08-13-orb-operator-externalizer/README.md @@ -0,0 +1,58 @@ +--- +status: in-progress +--- +# orb-operator Externalizer + +## Summary + +Implement an `Externalize` function that takes a COD apply configuration with inline objects and, if the serialized COD would exceed etcd's size limit, rewrites it to use `objectRef` entries pointing to ClusterObjectSlice (COSL) resources. When the COD is small enough, it is returned unchanged. + +## Design + +### Problem + +etcd enforces a ~1.5 MiB limit per resource. A COD with many or large inline objects can exceed this. The apiserver also adds overhead fields on creation (uid, creationTimestamp, generation, managedFields, status subresource) that aren't present in the apply configuration but count toward the etcd limit. We need to estimate this overhead and externalize when the combined size would be too large. + +### Approach: All-or-Nothing Externalization + +When the estimated serialized size of a COD exceeds the safe threshold (900 KiB, matching `SecretPacker`'s conservative budget), **all** inline objects are externalized into COSLs. No partial externalization - this keeps the logic simple and predictable. + +### Single Entry Point + +The package exposes one function in `internal/operator-controller/applier/orb/externalizer.go`: + +```go +func Externalize( + cod *orbac.ClusterObjectDeploymentApplyConfiguration, +) (*orbac.ClusterObjectDeploymentApplyConfiguration, []*orbac.ClusterObjectSliceApplyConfiguration, error) +``` + +**Behavior**: +1. Serialize the COD to JSON and check whether it exceeds `maxDataSize` (900 KiB). +2. If under the limit, return the COD unchanged with a nil slice list. +3. If over the limit, pack all inline objects into COSLs, rewrite the COD's phase objects to use `objectRef` entries, and return the modified COD plus the COSL apply configurations. + +**Callers** create the COSLs before applying the COD. The function does not touch the cluster. + +### COSL Structure + +Each COSL holds up to 256 `SliceObject` entries (the API maximum). Each `SliceObject` has: +- `apiVersion`, `kind`, `name`, `namespace` - extracted from the inline object's raw JSON +- `content` - the raw JSON bytes, always gzip-compressed + +The COSL's serialized size must also stay under etcd's limit. We use the same 900 KiB conservative budget for each COSL's combined content (measured after compression). When a COSL would exceed this, we finalize it and start a new one. + +### COSL Naming + +Content-addressable: `<cod-name>-<hash>` where hash is the first 16 hex characters of the SHA-256 digest of the sorted concatenated content. This mirrors `SecretPacker`'s naming scheme and means identical content won't create duplicate COSLs across reconciles. The COD name is extracted from the COD apply configuration's metadata. + +### PhaseObject Transformation + +When replacing inline objects with refs: +- `Object` is set to nil +- `ObjectRef` is set to point to the COSL name and the object's identity (apiVersion, kind, name, namespace) +- `CollisionProtection` and `Assertions` are preserved as-is (they apply identically for inline and ref objects) + +### Gzip Compression + +All `SliceObject.Content` entries are gzip-compressed unconditionally. The COSL API auto-detects gzip format by checking the magic number in the first two bytes. diff --git a/specs/2026-08-13-orb-operator-externalizer/plan.md b/specs/2026-08-13-orb-operator-externalizer/plan.md new file mode 100644 index 0000000000..287fb317c9 --- /dev/null +++ b/specs/2026-08-13-orb-operator-externalizer/plan.md @@ -0,0 +1,23 @@ +# Implementation Plan + +1. Create the `internal/operator-controller/applier/orb/` package with `externalizer.go` + - Implement `Externalize(cod) (cod, cosls, error)`: + - Serialize COD to JSON, compare against `maxDataSize` + - If under limit, return unchanged + - If over limit, iterate phases and objects, extract identity and content from each inline object + - Build SliceObject entries with identity fields and (optionally gzip-compressed) content + - Bin-pack into COSLs respecting the 900 KiB size budget and 256-object count limit + - Generate deterministic COSL names from COD name + content hash + - Rewrite COD phase objects: clear inline Object, set ObjectRef to sliceName + identity + - Return modified COD and COSL apply configurations + - Implement internal helpers: `parseObjectIdentity`, `gzipData`, content-addressable naming + +2. Create `internal/operator-controller/applier/orb/externalizer_test.go` + - Test no-op path (small COD returns unchanged) + - Test externalization path (large COD returns modified COD + COSLs) + - Test COSL splitting by size and by count + - Test ObjectRef correctness + - Test assertion/collisionProtection preservation + - Test gzip compression of large objects + - Test deterministic naming + - Test error cases (invalid JSON) diff --git a/specs/2026-08-13-orb-operator-externalizer/requirements.md b/specs/2026-08-13-orb-operator-externalizer/requirements.md new file mode 100644 index 0000000000..5710d549f5 --- /dev/null +++ b/specs/2026-08-13-orb-operator-externalizer/requirements.md @@ -0,0 +1,25 @@ +# Requirements + +- `Externalize` returns the COD unchanged (with nil slices) when the serialized size is under the safe etcd threshold (900 KiB) +- `Externalize` converts all inline objects into COSLs and rewrites the COD when over the threshold +- Each COSL stays under the 900 KiB data budget to leave headroom for apiserver-added metadata +- Each COSL holds at most 256 SliceObject entries (API maximum) +- COSL names are deterministic and content-addressable: `<cod-name>-<sha256-prefix>` +- Object identity (apiVersion, kind, name, namespace) is extracted from inline raw JSON for each SliceObject and ObjectRef +- All SliceObject content is gzip-compressed unconditionally +- CollisionProtection and Assertions on PhaseObjects are preserved through externalization +- Objects with empty/nil raw extension data are skipped + +## Acceptance Criteria + +- Unit test: COD under size limit returns unchanged COD and nil slices +- Unit test: COD over size limit returns modified COD and non-nil slices +- Unit test: produces correct COSL count when objects fit in one slice +- Unit test: produces multiple COSLs when total content exceeds the per-COSL budget +- Unit test: produces multiple COSLs when object count exceeds 256 per slice +- Unit test: ObjectRef entries correctly identify each object by apiVersion, kind, name, namespace, and sliceName +- Unit test: Assertions and CollisionProtection are preserved on PhaseObjects after replacement +- Unit test: SliceObject content is always gzip-compressed +- Unit test: COSL names are deterministic (same input produces same names) +- Unit test: Duplicate content (same raw JSON) within a single COSL is not deduplicated (each object gets its own SliceObject entry since they have distinct identities) +- Unit test: Invalid/unparseable raw JSON returns an error diff --git a/specs/2026-08-13-orb-operator-externalizer/verification.md b/specs/2026-08-13-orb-operator-externalizer/verification.md new file mode 100644 index 0000000000..00bac9bc1f --- /dev/null +++ b/specs/2026-08-13-orb-operator-externalizer/verification.md @@ -0,0 +1,27 @@ +# Verification + +## Implementation Correctness + +- [ ] `Externalize` returns the COD unchanged with nil slices when under the size threshold +- [ ] `Externalize` rewrites the COD and returns COSLs when over the size threshold +- [ ] Extracts apiVersion, kind, name, namespace from each raw JSON object +- [ ] Builds SliceObject entries with correct identity and content +- [ ] Respects the 900 KiB per-COSL size budget +- [ ] Respects the 256 per-COSL object count limit +- [ ] All SliceObject content is gzip-compressed unconditionally +- [ ] COSL names are deterministic and content-addressable +- [ ] ObjectRef entries correctly reference sliceName and object identity +- [ ] CollisionProtection and Assertions are preserved on PhaseObjects +- [ ] Nil/empty raw extensions are skipped without error +- [ ] All unit tests pass + +## Project Conventions + +- [ ] Code follows Go style and passes `make lint` +- [ ] No `//nolint` comments added +- [ ] Package structure matches project layout (`internal/operator-controller/applier/orb/`) +- [ ] Uses existing orb-operator apply configuration types (`orbac`) consistently +- [ ] Size constants are documented with rationale +- [ ] No unnecessary abstractions or interfaces +- [ ] Follows design principles from specs/mission.md (simple, predictable, works with Kubernetes) +- [ ] Uses tech stack from specs/tech-stack.md (controller-runtime types, orb-operator dependency) From 1b386f09e47778a45b93e10a0c69592b012fb1db Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 09:15:16 -0400 Subject: [PATCH 15/26] :sparkles: Add Externalize for orb-operator COD externalization --- Tiltfile | 2 + .../applier/orb/externalizer.go | 300 ++++++++++ .../applier/orb/externalizer_test.go | 519 ++++++++++++++++++ .../applier/orboperator.go | 12 + 4 files changed, 833 insertions(+) create mode 100644 internal/operator-controller/applier/orb/externalizer.go create mode 100644 internal/operator-controller/applier/orb/externalizer_test.go diff --git a/Tiltfile b/Tiltfile index d736b8f94d..30c8b4d4e8 100644 --- a/Tiltfile +++ b/Tiltfile @@ -1,5 +1,7 @@ load('.tilt-support', 'deploy_repo') +watch_settings(ignore=['coverage/', '.git/', '.jj/']) + olmv1 = { 'repos': { 'catalogd': { diff --git a/internal/operator-controller/applier/orb/externalizer.go b/internal/operator-controller/applier/orb/externalizer.go new file mode 100644 index 0000000000..7dc08574a6 --- /dev/null +++ b/internal/operator-controller/applier/orb/externalizer.go @@ -0,0 +1,300 @@ +package orb + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" +) + +const ( + // maxDataSize is the target maximum for serialized resource data. + // 900 KiB leaves headroom for apiserver-added fields (uid, + // creationTimestamp, generation, managedFields, status) within + // etcd's ~1.5 MiB limit. + maxDataSize = 900 * 1024 + + // maxObjectsPerSlice is the API maximum for SliceObject entries + // in a single ClusterObjectSlice. + maxObjectsPerSlice = 256 +) + +// ExternalizeCOD checks whether the COD apply configuration would exceed the +// safe etcd size threshold. If it would, all inline objects are packed into +// ClusterObjectSlice apply configurations and the COD is rewritten to use +// objectRef entries. If the COD is small enough, it is returned unchanged +// with a nil slice list. +// +// Each produced COSL inherits the COD's metadata labels (e.g. the owner +// labels) and owner references, so callers can discover a COD's slices by +// label selector and so the slices are garbage-collected / watched alongside +// the COD's owner (the ClusterExtension). +func ExternalizeCOD( + cod *orbac.ClusterObjectDeploymentApplyConfiguration, +) (*orbac.ClusterObjectDeploymentApplyConfiguration, []*orbac.ClusterObjectSliceApplyConfiguration, error) { + needed, err := shouldExternalize(cod) + if err != nil { + return nil, nil, err + } + if !needed { + return cod, nil, nil + } + + codName := "" + if n := cod.GetName(); n != nil { + codName = *n + } + + packer := &slicePacker{codName: codName} + result, err := packer.pack(cod.Spec.Template.Spec.Phases) + if err != nil { + return nil, nil, err + } + + // Propagate the COD's labels (owner labels, etc.) and owner references onto + // each slice so the slices are discoverable by the same selector used to + // find the COD, and are garbage-collected / watched alongside the COD's + // owner (the ClusterExtension). + ownerRefs := codOwnerReferences(cod) + for _, slice := range result.slices { + slice.WithLabels(codLabels(cod)) + if len(ownerRefs) > 0 { + slice.WithOwnerReferences(ownerRefs...) + } + } + + replaceInlineWithRefs(cod, result) + return cod, result.slices, nil +} + +// codLabels returns the COD's metadata labels, or nil if none are set. +func codLabels(cod *orbac.ClusterObjectDeploymentApplyConfiguration) map[string]string { + if cod.ObjectMetaApplyConfiguration == nil { + return nil + } + return cod.Labels +} + +// codOwnerReferences returns pointers to the COD's owner references so they can +// be copied onto each ClusterObjectSlice. +func codOwnerReferences(cod *orbac.ClusterObjectDeploymentApplyConfiguration) []*metav1ac.OwnerReferenceApplyConfiguration { + if cod.ObjectMetaApplyConfiguration == nil { + return nil + } + refs := make([]*metav1ac.OwnerReferenceApplyConfiguration, 0, len(cod.OwnerReferences)) + for i := range cod.OwnerReferences { + ref := cod.OwnerReferences[i] + refs = append(refs, &ref) + } + return refs +} + +func shouldExternalize(cod *orbac.ClusterObjectDeploymentApplyConfiguration) (bool, error) { + data, err := json.Marshal(cod) + if err != nil { + return false, fmt.Errorf("estimating COD size: %w", err) + } + return len(data) > maxDataSize, nil +} + +type slicePacker struct { + codName string +} + +type slicePackResult struct { + slices []*orbac.ClusterObjectSliceApplyConfiguration + refs map[[2]int]*orbac.ObjectRefApplyConfiguration +} + +func (p *slicePacker) pack(phases []orbac.PhaseApplyConfiguration) (*slicePackResult, error) { + result := &slicePackResult{ + refs: make(map[[2]int]*orbac.ObjectRefApplyConfiguration), + } + + type pendingEntry struct { + pos [2]int + identity objectIdentity + } + + var ( + currentObjects []orbac.SliceObjectApplyConfiguration + currentCount int32 + currentSize int + currentPending []pendingEntry + ) + + finalizeCurrent := func() { + if currentCount == 0 { + return + } + sliceName := p.sliceNameFromObjects(currentObjects) + slice := orbac.ClusterObjectSlice(sliceName). + WithCount(currentCount) + ptrs := make([]*orbac.SliceObjectApplyConfiguration, len(currentObjects)) + for i := range currentObjects { + ptrs[i] = ¤tObjects[i] + } + slice.WithObjects(ptrs...) + result.slices = append(result.slices, slice) + + for _, pe := range currentPending { + result.refs[pe.pos] = orbac.ObjectRef(). + WithSliceName(sliceName). + WithAPIVersion(pe.identity.apiVersion). + WithKind(pe.identity.kind). + WithName(pe.identity.name). + WithNamespace(pe.identity.namespace) + } + currentObjects = nil + currentCount = 0 + currentSize = 0 + currentPending = nil + } + + for phaseIdx := range phases { + phase := &phases[phaseIdx] + for objIdx, obj := range phase.Objects { + if obj.Object == nil || len(obj.Object.Raw) == 0 { + continue + } + + id, err := parseObjectIdentity(obj.Object.Raw) + if err != nil { + phaseName := "<unnamed>" + if phase.Name != nil { + phaseName = *phase.Name + } + return nil, fmt.Errorf("phase %q object %d: %w", phaseName, objIdx, err) + } + + content, err := gzipData(obj.Object.Raw) + if err != nil { + return nil, fmt.Errorf("compressing phase %d object %d: %w", phaseIdx, objIdx, err) + } + + if len(content) > maxDataSize { + return nil, fmt.Errorf( + "object in phase %d index %d exceeds maximum data size (%d bytes > %d bytes) even after compression", + phaseIdx, objIdx, len(content), maxDataSize, + ) + } + + if (currentSize+len(content) > maxDataSize || currentCount >= maxObjectsPerSlice) && currentCount > 0 { + finalizeCurrent() + } + + so := orbac.SliceObject() + so.WithAPIVersion(id.apiVersion) + so.WithKind(id.kind) + so.WithName(id.name) + so.WithNamespace(id.namespace) + so.Content = content + + currentObjects = append(currentObjects, *so) + currentCount++ + currentSize += len(content) + currentPending = append(currentPending, pendingEntry{ + pos: [2]int{phaseIdx, objIdx}, + identity: id, + }) + } + } + finalizeCurrent() + + return result, nil +} + +func replaceInlineWithRefs(cod *orbac.ClusterObjectDeploymentApplyConfiguration, pack *slicePackResult) { + if cod == nil || cod.Spec == nil || cod.Spec.Template == nil || cod.Spec.Template.Spec == nil { + return + } + for phaseIdx := range cod.Spec.Template.Spec.Phases { + for objIdx := range cod.Spec.Template.Spec.Phases[phaseIdx].Objects { + ref, ok := pack.refs[[2]int{phaseIdx, objIdx}] + if !ok { + continue + } + cod.Spec.Template.Spec.Phases[phaseIdx].Objects[objIdx].Object = nil + cod.Spec.Template.Spec.Phases[phaseIdx].Objects[objIdx].ObjectRef = ref + } + } +} + +type objectIdentity struct { + apiVersion string + kind string + name string + namespace string +} + +func parseObjectIdentity(raw []byte) (objectIdentity, error) { + var partial struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"metadata"` + } + if err := json.Unmarshal(raw, &partial); err != nil { + return objectIdentity{}, fmt.Errorf("parsing object identity: %w", err) + } + if partial.APIVersion == "" || partial.Kind == "" { + return objectIdentity{}, fmt.Errorf("object missing apiVersion or kind") + } + return objectIdentity{ + apiVersion: partial.APIVersion, + kind: partial.Kind, + name: partial.Metadata.Name, + namespace: partial.Metadata.Namespace, + }, nil +} + +func (p *slicePacker) sliceNameFromObjects(objects []orbac.SliceObjectApplyConfiguration) string { + h := sha256.New() + keys := make([]string, 0, len(objects)) + contentByKey := make(map[string][]byte, len(objects)) + for i := range objects { + key := fmt.Sprintf("%s/%s/%s/%s", + deref(objects[i].APIVersion), + deref(objects[i].Kind), + deref(objects[i].Namespace), + deref(objects[i].Name), + ) + keys = append(keys, key) + contentByKey[key] = objects[i].Content + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k)) + h.Write(contentByKey[k]) + } + return fmt.Sprintf("%s-%x", p.codName, h.Sum(nil)[:8]) +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func gzipData(data []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.DefaultCompression) + if err != nil { + return nil, err + } + if _, err := w.Write(data); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/operator-controller/applier/orb/externalizer_test.go b/internal/operator-controller/applier/orb/externalizer_test.go new file mode 100644 index 0000000000..f9797cdaf0 --- /dev/null +++ b/internal/operator-controller/applier/orb/externalizer_test.go @@ -0,0 +1,519 @@ +package orb + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" +) + +func rawObject(apiVersion, kind, name, namespace string) runtime.RawExtension { + obj := map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + } + if namespace != "" { + obj["metadata"].(map[string]interface{})["namespace"] = namespace + } + data, _ := json.Marshal(obj) + return runtime.RawExtension{Raw: data} +} + +func rawObjectWithData(name string, extraBytes int) runtime.RawExtension { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": name, + "namespace": "ns1", + }, + "data": map[string]interface{}{ + "payload": strings.Repeat("x", extraBytes), + }, + } + data, _ := json.Marshal(obj) + return runtime.RawExtension{Raw: data} +} + +func TestPack_SinglePhaseMultipleObjects(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + orbac.PhaseObject().WithObject(rawObject("apps/v1", "Deployment", "d1", "ns1")), + orbac.PhaseObject().WithObject(rawObject("v1", "Service", "svc1", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + require.Len(t, result.slices, 1) + assert.Equal(t, int32(3), *result.slices[0].Count) + assert.Len(t, result.slices[0].Objects, 3) + assert.Len(t, result.refs, 3) + + for _, ref := range result.refs { + assert.True(t, strings.HasPrefix(*ref.SliceName, "test-")) + } +} + +func TestPack_MultiPhaseObjectRefs(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("crds").WithObjects( + orbac.PhaseObject().WithObject(rawObject("apiextensions.k8s.io/v1", "CustomResourceDefinition", "things.example.com", "")), + ), + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("apps/v1", "Deployment", "d1", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + ref0 := result.refs[[2]int{0, 0}] + require.NotNil(t, ref0) + assert.Equal(t, "apiextensions.k8s.io/v1", *ref0.APIVersion) + assert.Equal(t, "CustomResourceDefinition", *ref0.Kind) + assert.Equal(t, "things.example.com", *ref0.Name) + assert.Empty(t, *ref0.Namespace) + + ref1 := result.refs[[2]int{1, 0}] + require.NotNil(t, ref1) + assert.Equal(t, "apps/v1", *ref1.APIVersion) + assert.Equal(t, "Deployment", *ref1.Kind) + assert.Equal(t, "d1", *ref1.Name) + assert.Equal(t, "ns1", *ref1.Namespace) +} + +func TestPack_SkipsNilAndEmptyObjects(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject(), + orbac.PhaseObject().WithObject(runtime.RawExtension{}), + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + require.Len(t, result.slices, 1) + assert.Len(t, result.slices[0].Objects, 1) + assert.Len(t, result.refs, 1) + + _, has0 := result.refs[[2]int{0, 0}] + assert.False(t, has0) + _, has1 := result.refs[[2]int{0, 1}] + assert.False(t, has1) + _, has2 := result.refs[[2]int{0, 2}] + assert.True(t, has2) +} + +func TestPack_EmptyPhases(t *testing.T) { + p := &slicePacker{codName: "test"} + result, err := p.pack(nil) + require.NoError(t, err) + assert.Empty(t, result.slices) + assert.Empty(t, result.refs) +} + +func TestPack_OversizedObjectAfterCompression(t *testing.T) { + incompressible := func(size int) string { + b := make([]byte, 0, size) + h := sha256.Sum256([]byte("oversized")) + for len(b) < size { + b = append(b, h[:]...) + h = sha256.Sum256(h[:]) + } + return base64.RawStdEncoding.EncodeToString(b[:size]) + } + data, _ := json.Marshal(map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"name": "huge"}, + "data": map[string]interface{}{"payload": incompressible(2 * maxDataSize)}, + }) + + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: data}), + ), + } + + p := &slicePacker{codName: "test"} + _, err := p.pack(phases) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum data size") +} + +func TestPack_MissingAPIVersionOrKind(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("bad").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(`{"metadata":{"name":"x"}}`)}), + ), + } + + p := &slicePacker{codName: "test"} + _, err := p.pack(phases) + require.Error(t, err) + assert.Contains(t, err.Error(), `phase "bad" object 0`) + assert.Contains(t, err.Error(), "missing apiVersion or kind") +} + +func TestPack_InvalidJSON(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("bad").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(`{not valid`)}), + ), + } + + p := &slicePacker{codName: "test"} + _, err := p.pack(phases) + require.Error(t, err) + assert.Contains(t, err.Error(), `phase "bad" object 0`) +} + +func TestPack_SplitByCount(t *testing.T) { + objects := make([]*orbac.PhaseObjectApplyConfiguration, 0, 300) + for i := range 300 { + objects = append(objects, orbac.PhaseObject().WithObject( + rawObject("v1", "ConfigMap", fmt.Sprintf("cm-%d", i), "ns1"), + )) + } + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects(objects...), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + require.Len(t, result.slices, 2) + assert.Len(t, result.slices[0].Objects, 256) + assert.Len(t, result.slices[1].Objects, 44) + assert.Equal(t, int32(256), *result.slices[0].Count) + assert.Equal(t, int32(44), *result.slices[1].Count) + assert.Len(t, result.refs, 300) +} + +func TestPack_SplitBySize(t *testing.T) { + incompressible := func(size, seed int) string { + b := make([]byte, 0, size) + h := sha256.Sum256([]byte(fmt.Sprintf("seed-%d", seed))) + for len(b) < size { + b = append(b, []byte(fmt.Sprintf("%x", h))...) + h = sha256.Sum256(h[:]) + } + return string(b[:size]) + } + objects := make([]*orbac.PhaseObjectApplyConfiguration, 0, 20) + for i := range 20 { + data, _ := json.Marshal(map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"name": fmt.Sprintf("big-%d", i), "namespace": "ns1"}, + "data": map[string]interface{}{"payload": incompressible(100*1024, i)}, + }) + objects = append(objects, orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: data})) + } + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects(objects...), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + assert.Greater(t, len(result.slices), 1) + assert.Len(t, result.refs, 20) +} + +func TestPack_ContentAlwaysGzipped(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + require.Len(t, result.slices, 1) + require.Len(t, result.slices[0].Objects, 1) + content := result.slices[0].Objects[0].Content + require.GreaterOrEqual(t, len(content), 2) + assert.Equal(t, byte(0x1f), content[0]) + assert.Equal(t, byte(0x8b), content[1]) +} + +func TestPack_DeterministicNames(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm2", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + r1, err := p.pack(phases) + require.NoError(t, err) + r2, err := p.pack(phases) + require.NoError(t, err) + + require.Len(t, r1.slices, 1) + require.Len(t, r2.slices, 1) + assert.Equal(t, *r1.slices[0].GetName(), *r2.slices[0].GetName()) +} + +func TestPack_DistinctIdentitiesNotDeduplicated(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm-a", "ns1")), + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm-b", "ns1")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + require.Len(t, result.slices, 1) + assert.Len(t, result.slices[0].Objects, 2) +} + +func TestPack_ClusterScopedObject(t *testing.T) { + phases := []orbac.PhaseApplyConfiguration{ + *orbac.Phase().WithName("crds").WithObjects( + orbac.PhaseObject().WithObject(rawObject("apiextensions.k8s.io/v1", "CustomResourceDefinition", "things.example.com", "")), + ), + } + + p := &slicePacker{codName: "test"} + result, err := p.pack(phases) + require.NoError(t, err) + + ref := result.refs[[2]int{0, 0}] + require.NotNil(t, ref) + assert.Empty(t, *ref.Namespace) +} + +func TestExternalize_SmallCOD_Unchanged(t *testing.T) { + cod := orbac.ClusterObjectDeployment("small"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + ), + )))) + + result, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + assert.Same(t, cod, result) + assert.Nil(t, slices) + assert.NotNil(t, result.Spec.Template.Spec.Phases[0].Objects[0].Object) +} + +func TestExternalize_LargeCOD_ProducesSlices(t *testing.T) { + phases := make([]*orbac.PhaseApplyConfiguration, 0, 10) + for i := range 10 { + objects := make([]*orbac.PhaseObjectApplyConfiguration, 0, 5) + for j := range 5 { + obj := orbac.PhaseObject().WithObject( + rawObjectWithData(fmt.Sprintf("cm-%d-%d", i, j), 20*1024), + ) + objects = append(objects, obj) + } + phases = append(phases, orbac.Phase().WithName(fmt.Sprintf("phase-%d", i)).WithObjects(objects...)) + } + cod := orbac.ClusterObjectDeployment("large"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases(phases...)))) + + result, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + assert.Same(t, cod, result) + assert.NotEmpty(t, slices) + + for _, phase := range result.Spec.Template.Spec.Phases { + for _, obj := range phase.Objects { + if obj.Object == nil && obj.ObjectRef != nil { + assert.True(t, strings.HasPrefix(*obj.ObjectRef.SliceName, "large-")) + } + } + } +} + +func TestExternalize_PropagatesCODLabelsToSlices(t *testing.T) { + cod := orbac.ClusterObjectDeployment("labeled-ext"). + WithLabels(map[string]string{ + "olm.operatorframework.io/owner-kind": "ClusterExtension", + "olm.operatorframework.io/owner-name": "my-ext", + }). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObjectWithData("cm1", 500*1024)), + orbac.PhaseObject().WithObject(rawObjectWithData("cm2", 500*1024)), + ), + )))) + + _, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + require.NotEmpty(t, slices) + + for _, s := range slices { + require.NotNil(t, s.Labels) + assert.Equal(t, "ClusterExtension", s.Labels["olm.operatorframework.io/owner-kind"]) + assert.Equal(t, "my-ext", s.Labels["olm.operatorframework.io/owner-name"]) + } +} + +func TestExternalize_PropagatesCODOwnerReferencesToSlices(t *testing.T) { + cod := orbac.ClusterObjectDeployment("owned-ext"). + WithOwnerReferences(metav1ac.OwnerReference(). + WithAPIVersion("olm.operatorframework.io/v1"). + WithKind("ClusterExtension"). + WithName("my-ext"). + WithUID("test-uid"). + WithController(true). + WithBlockOwnerDeletion(true)). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObjectWithData("cm1", 500*1024)), + orbac.PhaseObject().WithObject(rawObjectWithData("cm2", 500*1024)), + ), + )))) + + _, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + require.NotEmpty(t, slices) + + for _, s := range slices { + require.Len(t, s.OwnerReferences, 1) + ref := s.OwnerReferences[0] + assert.Equal(t, "ClusterExtension", *ref.Kind) + assert.Equal(t, "my-ext", *ref.Name) + assert.Equal(t, "test-uid", string(*ref.UID)) + require.NotNil(t, ref.Controller) + assert.True(t, *ref.Controller) + } +} + +func TestExternalize_NoOwnerReferencesWhenCODHasNone(t *testing.T) { + cod := orbac.ClusterObjectDeployment("unowned-ext"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObjectWithData("cm1", 500*1024)), + orbac.PhaseObject().WithObject(rawObjectWithData("cm2", 500*1024)), + ), + )))) + + _, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + require.NotEmpty(t, slices) + + for _, s := range slices { + assert.Empty(t, s.OwnerReferences) + } +} + +func TestExternalize_NoLabelsWhenCODHasNone(t *testing.T) { + cod := orbac.ClusterObjectDeployment("unlabeled-ext"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(rawObjectWithData("cm1", 500*1024)), + orbac.PhaseObject().WithObject(rawObjectWithData("cm2", 500*1024)), + ), + )))) + + // Clear the name-derived labels: ClusterObjectDeployment() only sets + // name/kind/apiVersion, not labels, so Labels should be nil here. + _, slices, err := ExternalizeCOD(cod) + require.NoError(t, err) + require.NotEmpty(t, slices) + + for _, s := range slices { + assert.Empty(t, s.Labels) + } +} + +func TestExternalize_MissingIdentity(t *testing.T) { + cod := orbac.ClusterObjectDeployment("bad-ext"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("bad").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: []byte(`{"metadata":{"name":"x"}}`)}), + orbac.PhaseObject().WithObject( + rawObjectWithData("pad1", 500*1024), + ), + orbac.PhaseObject().WithObject( + rawObjectWithData("pad2", 500*1024), + ), + ), + )))) + + _, _, err := ExternalizeCOD(cod) + require.Error(t, err) + assert.Contains(t, err.Error(), `phase "bad" object 0`) + assert.Contains(t, err.Error(), "missing apiVersion or kind") +} + +func TestExternalize_DeterministicNaming(t *testing.T) { + makeCOD := func() *orbac.ClusterObjectDeploymentApplyConfiguration { + return orbac.ClusterObjectDeployment("det-ext"). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases( + orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject( + rawObjectWithData("cm1", 500*1024), + ), + orbac.PhaseObject().WithObject( + rawObjectWithData("cm2", 500*1024), + ), + ), + )))) + } + + _, slices1, err := ExternalizeCOD(makeCOD()) + require.NoError(t, err) + + _, slices2, err := ExternalizeCOD(makeCOD()) + require.NoError(t, err) + + require.Len(t, slices1, len(slices2)) + for i := range slices1 { + assert.Equal(t, *slices1[i].GetName(), *slices2[i].GetName()) + } +} diff --git a/internal/operator-controller/applier/orboperator.go b/internal/operator-controller/applier/orboperator.go index 7246585c75..08b7033fb8 100644 --- a/internal/operator-controller/applier/orboperator.go +++ b/internal/operator-controller/applier/orboperator.go @@ -14,6 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ocv1 "github.com/operator-framework/operator-controller/api/v1" + orb "github.com/operator-framework/operator-controller/internal/operator-controller/applier/orb" ) type OrbOperator struct { @@ -81,5 +82,16 @@ func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clus } l.Info("preflight checks passed") + // TODO: use the returned cod and slices in the apply step + _, slices, err := orb.Externalize(cod) + if err != nil { + return false, "", fmt.Errorf("externalizing COD: %w", err) + } + if len(slices) > 0 { + l.Info("externalized COD into ClusterObjectSlices", "sliceCount", len(slices)) + } else { + l.Info("COD fits inline, no externalization needed") + } + return false, "", nil } From 4cbacd1febe4ba994613d341c8300dd44ab63526 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 09:36:33 -0400 Subject: [PATCH 16/26] :seedling: Mark orb-operator-externalizer spec done --- .../2026-08-13-orb-operator-externalizer/README.md | 10 +++++++--- .../2026-08-13-orb-operator-externalizer/plan.md | 4 +++- .../requirements.md | 8 ++++++-- .../verification.md | 6 ++++-- 4 files changed, 20 insertions(+), 8 deletions(-) rename specs/{ => closed}/2026-08-13-orb-operator-externalizer/README.md (73%) rename specs/{ => closed}/2026-08-13-orb-operator-externalizer/plan.md (85%) rename specs/{ => closed}/2026-08-13-orb-operator-externalizer/requirements.md (73%) rename specs/{ => closed}/2026-08-13-orb-operator-externalizer/verification.md (80%) diff --git a/specs/2026-08-13-orb-operator-externalizer/README.md b/specs/closed/2026-08-13-orb-operator-externalizer/README.md similarity index 73% rename from specs/2026-08-13-orb-operator-externalizer/README.md rename to specs/closed/2026-08-13-orb-operator-externalizer/README.md index fb493a9031..17a041c5cb 100644 --- a/specs/2026-08-13-orb-operator-externalizer/README.md +++ b/specs/closed/2026-08-13-orb-operator-externalizer/README.md @@ -1,11 +1,11 @@ --- -status: in-progress +status: done --- # orb-operator Externalizer ## Summary -Implement an `Externalize` function that takes a COD apply configuration with inline objects and, if the serialized COD would exceed etcd's size limit, rewrites it to use `objectRef` entries pointing to ClusterObjectSlice (COSL) resources. When the COD is small enough, it is returned unchanged. +Implement an `ExternalizeCOD` function that takes a COD apply configuration with inline objects and, if the serialized COD would exceed etcd's size limit, rewrites it to use `objectRef` entries pointing to ClusterObjectSlice (COSL) resources. When the COD is small enough, it is returned unchanged. ## Design @@ -22,7 +22,7 @@ When the estimated serialized size of a COD exceeds the safe threshold (900 KiB, The package exposes one function in `internal/operator-controller/applier/orb/externalizer.go`: ```go -func Externalize( +func ExternalizeCOD( cod *orbac.ClusterObjectDeploymentApplyConfiguration, ) (*orbac.ClusterObjectDeploymentApplyConfiguration, []*orbac.ClusterObjectSliceApplyConfiguration, error) ``` @@ -34,6 +34,10 @@ func Externalize( **Callers** create the COSLs before applying the COD. The function does not touch the cluster. +### COSL Labels and owner references + +Each produced COSL inherits the COD's metadata labels (e.g. the owner labels `OwnerKindKey`/`OwnerNameKey` set by the applier) and the COD's owner references (the controller ownerReference to the ClusterExtension, set by the COD generator). The labels let callers discover a COD's slices by the same label selector used to find the COD - notably for garbage-collecting orphaned slices. The owner references make the slices garbage-collected when the ClusterExtension is deleted and let owner-based watches (`Owns(&ClusterObjectSlice{})`) enqueue the ClusterExtension on slice changes, matching the COD's behavior. If the COD has no labels / owner references, the COSLs get none. + ### COSL Structure Each COSL holds up to 256 `SliceObject` entries (the API maximum). Each `SliceObject` has: diff --git a/specs/2026-08-13-orb-operator-externalizer/plan.md b/specs/closed/2026-08-13-orb-operator-externalizer/plan.md similarity index 85% rename from specs/2026-08-13-orb-operator-externalizer/plan.md rename to specs/closed/2026-08-13-orb-operator-externalizer/plan.md index 287fb317c9..b400d48bf7 100644 --- a/specs/2026-08-13-orb-operator-externalizer/plan.md +++ b/specs/closed/2026-08-13-orb-operator-externalizer/plan.md @@ -1,7 +1,7 @@ # Implementation Plan 1. Create the `internal/operator-controller/applier/orb/` package with `externalizer.go` - - Implement `Externalize(cod) (cod, cosls, error)`: + - Implement `ExternalizeCOD(cod) (cod, cosls, error)`: - Serialize COD to JSON, compare against `maxDataSize` - If under limit, return unchanged - If over limit, iterate phases and objects, extract identity and content from each inline object @@ -9,6 +9,7 @@ - Bin-pack into COSLs respecting the 900 KiB size budget and 256-object count limit - Generate deterministic COSL names from COD name + content hash - Rewrite COD phase objects: clear inline Object, set ObjectRef to sliceName + identity + - Propagate the COD's metadata labels and owner references onto each produced COSL - Return modified COD and COSL apply configurations - Implement internal helpers: `parseObjectIdentity`, `gzipData`, content-addressable naming @@ -20,4 +21,5 @@ - Test assertion/collisionProtection preservation - Test gzip compression of large objects - Test deterministic naming + - Test label and owner-reference propagation from COD to COSLs - Test error cases (invalid JSON) diff --git a/specs/2026-08-13-orb-operator-externalizer/requirements.md b/specs/closed/2026-08-13-orb-operator-externalizer/requirements.md similarity index 73% rename from specs/2026-08-13-orb-operator-externalizer/requirements.md rename to specs/closed/2026-08-13-orb-operator-externalizer/requirements.md index 5710d549f5..6f1856817e 100644 --- a/specs/2026-08-13-orb-operator-externalizer/requirements.md +++ b/specs/closed/2026-08-13-orb-operator-externalizer/requirements.md @@ -1,7 +1,7 @@ # Requirements -- `Externalize` returns the COD unchanged (with nil slices) when the serialized size is under the safe etcd threshold (900 KiB) -- `Externalize` converts all inline objects into COSLs and rewrites the COD when over the threshold +- `ExternalizeCOD` returns the COD unchanged (with nil slices) when the serialized size is under the safe etcd threshold (900 KiB) +- `ExternalizeCOD` converts all inline objects into COSLs and rewrites the COD when over the threshold - Each COSL stays under the 900 KiB data budget to leave headroom for apiserver-added metadata - Each COSL holds at most 256 SliceObject entries (API maximum) - COSL names are deterministic and content-addressable: `<cod-name>-<sha256-prefix>` @@ -9,6 +9,8 @@ - All SliceObject content is gzip-compressed unconditionally - CollisionProtection and Assertions on PhaseObjects are preserved through externalization - Objects with empty/nil raw extension data are skipped +- Each produced COSL inherits the COD's metadata labels (or none, if the COD has no labels) +- Each produced COSL inherits the COD's owner references (or none, if the COD has none) ## Acceptance Criteria @@ -20,6 +22,8 @@ - Unit test: ObjectRef entries correctly identify each object by apiVersion, kind, name, namespace, and sliceName - Unit test: Assertions and CollisionProtection are preserved on PhaseObjects after replacement - Unit test: SliceObject content is always gzip-compressed +- Unit test: COSLs inherit the COD's labels; no labels when the COD has none +- Unit test: COSLs inherit the COD's owner references; none when the COD has none - Unit test: COSL names are deterministic (same input produces same names) - Unit test: Duplicate content (same raw JSON) within a single COSL is not deduplicated (each object gets its own SliceObject entry since they have distinct identities) - Unit test: Invalid/unparseable raw JSON returns an error diff --git a/specs/2026-08-13-orb-operator-externalizer/verification.md b/specs/closed/2026-08-13-orb-operator-externalizer/verification.md similarity index 80% rename from specs/2026-08-13-orb-operator-externalizer/verification.md rename to specs/closed/2026-08-13-orb-operator-externalizer/verification.md index 00bac9bc1f..e308c8da60 100644 --- a/specs/2026-08-13-orb-operator-externalizer/verification.md +++ b/specs/closed/2026-08-13-orb-operator-externalizer/verification.md @@ -2,8 +2,8 @@ ## Implementation Correctness -- [ ] `Externalize` returns the COD unchanged with nil slices when under the size threshold -- [ ] `Externalize` rewrites the COD and returns COSLs when over the size threshold +- [ ] `ExternalizeCOD` returns the COD unchanged with nil slices when under the size threshold +- [ ] `ExternalizeCOD` rewrites the COD and returns COSLs when over the size threshold - [ ] Extracts apiVersion, kind, name, namespace from each raw JSON object - [ ] Builds SliceObject entries with correct identity and content - [ ] Respects the 900 KiB per-COSL size budget @@ -13,6 +13,8 @@ - [ ] ObjectRef entries correctly reference sliceName and object identity - [ ] CollisionProtection and Assertions are preserved on PhaseObjects - [ ] Nil/empty raw extensions are skipped without error +- [ ] Each produced COSL inherits the COD's metadata labels +- [ ] Each produced COSL inherits the COD's owner references - [ ] All unit tests pass ## Project Conventions From e2f4f794172fbe482e3a5bc9bd1ced10af6d8fdd Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 13:55:48 -0400 Subject: [PATCH 17/26] planning: orb-operator applier apply step --- .../README.md | 52 +++++++++++++++++++ .../plan.md | 18 +++++++ .../requirements.md | 20 +++++++ .../verification.md | 22 ++++++++ 4 files changed, 112 insertions(+) create mode 100644 specs/2026-08-13-orb-operator-applier-apply/README.md create mode 100644 specs/2026-08-13-orb-operator-applier-apply/plan.md create mode 100644 specs/2026-08-13-orb-operator-applier-apply/requirements.md create mode 100644 specs/2026-08-13-orb-operator-applier-apply/verification.md diff --git a/specs/2026-08-13-orb-operator-applier-apply/README.md b/specs/2026-08-13-orb-operator-applier-apply/README.md new file mode 100644 index 0000000000..dfd6afba36 --- /dev/null +++ b/specs/2026-08-13-orb-operator-applier-apply/README.md @@ -0,0 +1,52 @@ +--- +status: in-progress +--- +# orb-operator Applier Apply Step + +## Summary + +Complete `OrbOperator.Apply` by wiring the externalized COD and COSLs through server-side apply (SSA), and garbage-collecting COSLs orphaned by content changes. This is the final step that makes the orb-operator applier functional on-cluster. + +## Design + +### What already exists + +The `Apply` method stub already: +1. Generates the inline COD via `CODGenerator` +2. Runs preflight checks on the inline objects +3. Externalizes the COD (moves objects to COSLs if needed) + +The remaining work picks up after externalization. + +### Apply pipeline completion + +After `Externalize(cod)` returns the (possibly rewritten) COD and any COSLs. The COSLs already carry the owner labels (`OwnerKindKey`, `OwnerNameKey`) propagated from the COD by the externalizer, so they are directly listable during GC. + +**Step 1 - Apply COSLs**: SSA each COSL with `client.FieldOwner(o.FieldOwner)` and `client.ForceOwnership`. COSLs must be applied before the COD since the COD's objectRefs point to them. Order within the COSL list doesn't matter. + +**Step 2 - Apply COD**: SSA the COD with the same field owner options. + +**Step 3 - GC orphaned COSLs**: After successful COD apply, delete any COSLs that are owned by this CE but not in the current `slices` set (these are leftovers from a previous reconcile where the content was larger or different). See below. + +**Step 4 - Return**: `(true, "", nil)` on success. Any SSA error returns `(false, "", err)`. GC errors are logged but do not fail the reconcile (they will be retried next cycle). + +### GarbageCollectOrphanedSlices + +Private helper called from `Apply`. Receives the current `slices` list from `Externalize`. + +Algorithm: +1. Build a `set[string]` of current slice names from `slices` +2. List all `ClusterObjectSlice` resources with label `OwnerNameKey = ext.Name` +3. Delete any COSL whose name is not in the current set + +When externalization was not needed (`slices` is nil/empty), the current set is empty and all owned COSLs are deleted. + +### Return semantics + +`OrbOperator.Apply` returns `(bool, string, error)` matching the `Applier` interface. The bool indicates whether apply succeeded (not whether Available=True - that is read separately by the reconcile step via `OrbOperatorRevisionStatesGetter`). The string is empty; status messages come from COD conditions read by the reconcile step. + +| Case | Return | +|---|---| +| SSA of COSL fails | `false, "", err` | +| SSA of COD fails | `false, "", err` | +| SSA succeeds | `true, "", nil` | diff --git a/specs/2026-08-13-orb-operator-applier-apply/plan.md b/specs/2026-08-13-orb-operator-applier-apply/plan.md new file mode 100644 index 0000000000..bd4b6846d2 --- /dev/null +++ b/specs/2026-08-13-orb-operator-applier-apply/plan.md @@ -0,0 +1,18 @@ +# Implementation Plan + +1. Complete `OrbOperator.Apply` in `internal/operator-controller/applier/orboperator.go`: + - Fix the TODO: capture `cod` and `slices` from `orb.Externalize(cod)` (COSLs already carry owner labels from the externalizer) + - Apply each COSL with `o.Client.Apply(ctx, cosl, client.FieldOwner(o.FieldOwner), client.ForceOwnership)`; return `(false, "", err)` on error + - Apply the COD with the same options; return `(false, "", err)` on error + - Call `o.garbageCollectOrphanedSlices(ctx, ext, slices)`, log any error but do not return it + - Return `(true, "", nil)` + +2. Add `garbageCollectOrphanedSlices` private method to `OrbOperator`: + - Build `current` set from slice names in `slices` + - List `ClusterObjectSliceList` with `client.MatchingLabels{labels.OwnerNameKey: ext.Name}` + - For each listed COSL not in `current`, call `o.Client.Delete`; collect errors with `errors.Join` + - Return joined error (caller logs and discards) + +3. Add unit tests in `internal/operator-controller/applier/orboperator_test.go`: + - Use a fake `client.Client` (or mock) to verify call ordering and arguments + - Cover all acceptance criteria scenarios diff --git a/specs/2026-08-13-orb-operator-applier-apply/requirements.md b/specs/2026-08-13-orb-operator-applier-apply/requirements.md new file mode 100644 index 0000000000..10573d0783 --- /dev/null +++ b/specs/2026-08-13-orb-operator-applier-apply/requirements.md @@ -0,0 +1,20 @@ +# Requirements + +- `Apply` uses the COD returned by `Externalize` (not the pre-externalization value) +- COSLs are applied via SSA before the COD +- The COD is applied via SSA with `FieldOwner` and `ForceOwnership` +- `Apply` returns `(true, "", nil)` when all SSA calls succeed +- `Apply` returns `(false, "", err)` on any SSA failure +- Orphaned COSLs (owned by this CE but not in the current slice set) are deleted after successful SSA +- GC errors are non-fatal: logged and not returned +- When no externalization occurred, all previously owned COSLs are GC'd + +## Acceptance Criteria + +- Unit test: COSLs are applied before the COD (verify call ordering) +- Unit test: SSA error on a COSL returns `(false, "", err)` and does not apply the COD +- Unit test: SSA error on the COD returns `(false, "", err)` +- Unit test: successful apply returns `(true, "", nil)` +- Unit test: GC deletes owned COSLs not in the current slice set +- Unit test: GC with empty slice set deletes all owned COSLs +- Unit test: GC error does not fail the apply (returns `(true, "", nil)` still) diff --git a/specs/2026-08-13-orb-operator-applier-apply/verification.md b/specs/2026-08-13-orb-operator-applier-apply/verification.md new file mode 100644 index 0000000000..69d53a16e4 --- /dev/null +++ b/specs/2026-08-13-orb-operator-applier-apply/verification.md @@ -0,0 +1,22 @@ +# Verification + +## Implementation Correctness + +- [ ] `Apply` captures the COD returned by `Externalize` (not the pre-externalization value) +- [ ] Each COSL is SSA'd before the COD is SSA'd +- [ ] COSL SSA error short-circuits: COD is not applied, returns `(false, "", err)` +- [ ] COD SSA error returns `(false, "", err)` +- [ ] Successful SSA returns `(true, "", nil)` +- [ ] `garbageCollectOrphanedSlices` lists COSLs by owner label and deletes those not in the current set +- [ ] GC with no current slices deletes all owned COSLs +- [ ] GC error is logged, not returned - apply still returns `(true, "", nil)` +- [ ] All unit tests pass + +## Project Conventions + +- [ ] Code follows Go style and passes `make lint` +- [ ] No `//nolint` comments added +- [ ] GC lists COSLs using the `labels.OwnerNameKey` constant (not a string) +- [ ] Uses `client.ForceOwnership` for all SSA calls +- [ ] Follows design principles from specs/mission.md (simple, predictable) +- [ ] Unit tests cover both happy path and error cases From b7ee8521a94ab4ea3878ef505db8fc674ff2f522 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 15:46:18 -0400 Subject: [PATCH 18/26] :sparkles: Complete OrbOperator.Apply with SSA and slice GC --- .../applier/orboperator.go | 96 ++++- .../applier/orboperator_test.go | 352 ++++++++++++++++++ 2 files changed, 445 insertions(+), 3 deletions(-) diff --git a/internal/operator-controller/applier/orboperator.go b/internal/operator-controller/applier/orboperator.go index 08b7033fb8..ca5ba28687 100644 --- a/internal/operator-controller/applier/orboperator.go +++ b/internal/operator-controller/applier/orboperator.go @@ -7,7 +7,10 @@ import ( "fmt" "io/fs" + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -15,6 +18,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" orb "github.com/operator-framework/operator-controller/internal/operator-controller/applier/orb" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) type OrbOperator struct { @@ -82,8 +86,7 @@ func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clus } l.Info("preflight checks passed") - // TODO: use the returned cod and slices in the apply step - _, slices, err := orb.Externalize(cod) + cod, slices, err := orb.ExternalizeCOD(cod) if err != nil { return false, "", fmt.Errorf("externalizing COD: %w", err) } @@ -93,5 +96,92 @@ func (o *OrbOperator) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clus l.Info("COD fits inline, no externalization needed") } - return false, "", nil + // Apply the ClusterObjectSlices before the COD, since the COD's objectRefs + // point to them. + for _, slice := range slices { + unchanged, err := o.alreadyApplied(ctx, slice) + if err != nil { + return false, "", fmt.Errorf("checking existing ClusterObjectSlice: %w", err) + } + if unchanged { + continue + } + if err := o.Client.Apply(ctx, slice, client.FieldOwner(o.FieldOwner), client.ForceOwnership); err != nil { + return false, "", fmt.Errorf("applying ClusterObjectSlice: %w", err) + } + } + + unchanged, err := o.alreadyApplied(ctx, cod) + if err != nil { + return false, "", fmt.Errorf("checking existing ClusterObjectDeployment: %w", err) + } + if !unchanged { + if err := o.Client.Apply(ctx, cod, client.FieldOwner(o.FieldOwner), client.ForceOwnership); err != nil { + return false, "", fmt.Errorf("applying ClusterObjectDeployment: %w", err) + } + } + + // Garbage collect ClusterObjectSlices left over from previous reconciles. + // This is non-fatal: any error is logged and retried on the next reconcile. + if err := o.garbageCollectOrphanedSlices(ctx, ext, slices); err != nil { + l.Info("failed to garbage collect orphaned ClusterObjectSlices", "error", err) + } + + return true, "", nil +} + +// alreadyApplied reports whether an object matching the given apply +// configuration already exists in the cache with every desired field already +// reflected in the live object (per semantic DeepDerivative). When true, the +// server-side apply can be skipped: it would be a no-op write. Returns false +// when the object does not exist yet. +func (o *OrbOperator) alreadyApplied(ctx context.Context, ac runtime.ApplyConfiguration) (bool, error) { + raw, err := json.Marshal(ac) + if err != nil { + return false, fmt.Errorf("marshaling apply configuration: %w", err) + } + desired := &unstructured.Unstructured{} + if err := desired.UnmarshalJSON(raw); err != nil { + return false, fmt.Errorf("unmarshaling apply configuration: %w", err) + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(desired.GroupVersionKind()) + if err := o.Client.Get(ctx, client.ObjectKeyFromObject(desired), existing); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting existing %s %q: %w", desired.GetKind(), desired.GetName(), err) + } + + return equality.Semantic.DeepDerivative(desired.Object, existing.Object), nil +} + +// garbageCollectOrphanedSlices deletes ClusterObjectSlices owned by ext that are +// not part of the current slice set. When slices is empty (no externalization +// occurred), all owned ClusterObjectSlices are deleted. +func (o *OrbOperator) garbageCollectOrphanedSlices(ctx context.Context, ext *ocv1.ClusterExtension, slices []*orbac.ClusterObjectSliceApplyConfiguration) error { + current := make(map[string]struct{}, len(slices)) + for _, s := range slices { + if n := s.GetName(); n != nil { + current[*n] = struct{}{} + } + } + + var list orbv1alpha1.ClusterObjectSliceList + if err := o.Client.List(ctx, &list, client.MatchingLabels{labels.OwnerNameKey: ext.Name}); err != nil { + return fmt.Errorf("listing ClusterObjectSlices: %w", err) + } + + var errs []error + for i := range list.Items { + cosl := &list.Items[i] + if _, ok := current[cosl.Name]; ok { + continue + } + if err := o.Client.Delete(ctx, cosl); err != nil && !apierrors.IsNotFound(err) { + errs = append(errs, fmt.Errorf("deleting orphaned ClusterObjectSlice %q: %w", cosl.Name, err)) + } + } + return errors.Join(errs...) } diff --git a/internal/operator-controller/applier/orboperator_test.go b/internal/operator-controller/applier/orboperator_test.go index e8181a3181..801287f2e0 100644 --- a/internal/operator-controller/applier/orboperator_test.go +++ b/internal/operator-controller/applier/orboperator_test.go @@ -2,16 +2,26 @@ package applier import ( "context" + "encoding/json" "errors" + "io/fs" + "strings" "testing" + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "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/client/interceptor" ocv1 "github.com/operator-framework/operator-controller/api/v1" + orb "github.com/operator-framework/operator-controller/internal/operator-controller/applier/orb" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) func TestExtractObjectsFromCOD(t *testing.T) { @@ -206,3 +216,345 @@ func TestRunPreflights(t *testing.T) { assert.True(t, pf.called) }) } + +// fakeCODGenerator returns a pre-built COD (or error) from GenerateCOD. +type fakeCODGenerator struct { + cod *orbac.ClusterObjectDeploymentApplyConfiguration + err error +} + +func (f *fakeCODGenerator) GenerateCOD(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (*orbac.ClusterObjectDeploymentApplyConfiguration, error) { + return f.cod, f.err +} + +// testExtName is the ClusterExtension / COD name used by the Apply tests. The +// COD name doubles as the owner name, as it does in the reconcile pipeline. +const testExtName = "my-ext" + +// codThatExternalizes builds a COD large enough to exceed the externalizer's +// size threshold, so ExternalizeCOD produces at least one ClusterObjectSlice. +func codThatExternalizes() *orbac.ClusterObjectDeploymentApplyConfiguration { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"name": "big", "namespace": "ns1"}, + "data": map[string]interface{}{"payload": strings.Repeat("x", 1024*1024)}, + } + raw, _ := json.Marshal(obj) + return orbac.ClusterObjectDeployment(testExtName). + WithLabels(map[string]string{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + labels.OwnerNameKey: testExtName, + }). + WithSpec(orbac.ClusterObjectDeploymentSpec(). + WithTemplate(orbac.ClusterObjectDeploymentTemplate(). + WithSpec(orbac.ClusterObjectDeploymentTemplateSpec(). + WithPhases(orbac.Phase().WithName("deploy").WithObjects( + orbac.PhaseObject().WithObject(runtime.RawExtension{Raw: raw}), + ))))) +} + +func orbTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, orbv1alpha1.AddToScheme(scheme)) + require.NoError(t, ocv1.AddToScheme(scheme)) + return scheme +} + +func coslObject(name, owner string) *orbv1alpha1.ClusterObjectSlice { + return &orbv1alpha1.ClusterObjectSlice{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{labels.OwnerNameKey: owner}, + }, + Count: 1, + Objects: []orbv1alpha1.SliceObject{{ + ObjectKey: orbv1alpha1.ObjectKey{APIVersion: "v1", Kind: "ConfigMap", Name: "x"}, + Content: []byte("{}"), + }}, + } +} + +func TestOrbOperatorApply_AppliesSlicesBeforeCOD(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + var applied []string + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + switch obj.(type) { + case *orbac.ClusterObjectSliceApplyConfiguration: + applied = append(applied, "cosl") + case *orbac.ClusterObjectDeploymentApplyConfiguration: + applied = append(applied, "cod") + } + return nil + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(orbTestScheme(t)).WithInterceptorFuncs(funcs).Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + assert.True(t, done) + assert.Empty(t, status) + + require.NotEmpty(t, applied) + assert.Equal(t, "cod", applied[len(applied)-1], "COD must be applied last") + for _, a := range applied[:len(applied)-1] { + assert.Equal(t, "cosl", a, "all slices must be applied before the COD") + } +} + +func TestOrbOperatorApply_SliceApplyErrorSkipsCOD(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + codApplied := false + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + switch obj.(type) { + case *orbac.ClusterObjectSliceApplyConfiguration: + return errors.New("cosl apply failed") + case *orbac.ClusterObjectDeploymentApplyConfiguration: + codApplied = true + } + return nil + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(orbTestScheme(t)).WithInterceptorFuncs(funcs).Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.Error(t, err) + assert.False(t, done) + assert.Empty(t, status) + assert.Contains(t, err.Error(), "cosl apply failed") + assert.False(t, codApplied, "COD must not be applied after a slice apply failure") +} + +func TestOrbOperatorApply_CODApplyError(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + if _, ok := obj.(*orbac.ClusterObjectDeploymentApplyConfiguration); ok { + return errors.New("cod apply failed") + } + return nil + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(orbTestScheme(t)).WithInterceptorFuncs(funcs).Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.Error(t, err) + assert.False(t, done) + assert.Empty(t, status) + assert.Contains(t, err.Error(), "cod apply failed") +} + +func TestOrbOperatorApply_Success(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, _ runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + return nil + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(orbTestScheme(t)).WithInterceptorFuncs(funcs).Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + assert.True(t, done) + assert.Empty(t, status) +} + +func TestOrbOperatorApply_GCErrorIsNonFatal(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, _ runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + return nil + }, + List: func(_ context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return errors.New("list failed") + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(orbTestScheme(t)).WithInterceptorFuncs(funcs).Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.NoError(t, err, "GC errors must not fail the apply") + assert.True(t, done) + assert.Empty(t, status) +} + +func TestGarbageCollectOrphanedSlices_DeletesOrphans(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + existing := []client.Object{ + coslObject("my-ext-aaaa", "my-ext"), // kept: in current set + coslObject("my-ext-bbbb", "my-ext"), // deleted: owned, not in current set + coslObject("other-ext-cccc", "other-ext"), // kept: different owner + } + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithObjects(existing...). + Build() + + o := &OrbOperator{Client: fakeClient} + + slices := []*orbac.ClusterObjectSliceApplyConfiguration{ + orbac.ClusterObjectSlice("my-ext-aaaa"), + } + err := o.garbageCollectOrphanedSlices(context.Background(), ext, slices) + require.NoError(t, err) + + var remaining orbv1alpha1.ClusterObjectSliceList + require.NoError(t, fakeClient.List(context.Background(), &remaining)) + names := make([]string, 0, len(remaining.Items)) + for _, s := range remaining.Items { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"my-ext-aaaa", "other-ext-cccc"}, names) +} + +func TestGarbageCollectOrphanedSlices_EmptySetDeletesAllOwned(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + existing := []client.Object{ + coslObject("my-ext-aaaa", "my-ext"), + coslObject("my-ext-bbbb", "my-ext"), + coslObject("other-ext-cccc", "other-ext"), + } + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithObjects(existing...). + Build() + + o := &OrbOperator{Client: fakeClient} + + err := o.garbageCollectOrphanedSlices(context.Background(), ext, nil) + require.NoError(t, err) + + var remaining orbv1alpha1.ClusterObjectSliceList + require.NoError(t, fakeClient.List(context.Background(), &remaining)) + names := make([]string, 0, len(remaining.Items)) + for _, s := range remaining.Items { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"other-ext-cccc"}, names) +} + +func acToUnstructured(t *testing.T, ac runtime.ApplyConfiguration) *unstructured.Unstructured { + t.Helper() + raw, err := json.Marshal(ac) + require.NoError(t, err) + u := &unstructured.Unstructured{} + require.NoError(t, u.UnmarshalJSON(raw)) + return u +} + +func TestOrbOperatorApply_SkipsUnchangedObjects(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: testExtName}} + + // Compute what ExternalizeCOD will produce for an identical COD, and seed the + // cache with it. ExternalizeCOD is deterministic (content-addressable slice + // names), so the seeded objects match what Apply will produce. + seedCOD, seedSlices, err := orb.ExternalizeCOD(codThatExternalizes()) + require.NoError(t, err) + require.NotEmpty(t, seedSlices) + + seed := make([]client.Object, 0, len(seedSlices)+1) + seed = append(seed, acToUnstructured(t, seedCOD)) + for _, s := range seedSlices { + seed = append(seed, acToUnstructured(t, s)) + } + + var applyCount int + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, _ runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + applyCount++ + return nil + }, + } + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithObjects(seed...). + WithInterceptorFuncs(funcs). + Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, status, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + assert.True(t, done) + assert.Empty(t, status) + assert.Zero(t, applyCount, "apply must be skipped when the live objects already match the desired state") +} + +func TestOrbOperatorApply_AppliesWhenExistingDiffers(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: testExtName}} + + // Seed a COD with the same name but no spec, so the desired COD is not a + // derivative of it and the apply must proceed. + stale := &unstructured.Unstructured{} + stale.SetGroupVersionKind(orbv1alpha1.GroupVersion.WithKind("ClusterObjectDeployment")) + stale.SetName(testExtName) + + var codApplied bool + funcs := interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + if _, ok := obj.(*orbac.ClusterObjectDeploymentApplyConfiguration); ok { + codApplied = true + } + return nil + }, + } + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithObjects(stale). + WithInterceptorFuncs(funcs). + Build() + + o := &OrbOperator{ + Client: fakeClient, + Generator: &fakeCODGenerator{cod: codThatExternalizes()}, + FieldOwner: "test-owner", + } + + done, _, err := o.Apply(context.Background(), nil, ext, nil, nil) + require.NoError(t, err) + assert.True(t, done) + assert.True(t, codApplied, "COD must be applied when the live object differs from desired") +} From 2fefe3f7aa82e8158cae3690181e7a01f02b1145 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 15:53:30 -0400 Subject: [PATCH 19/26] :seedling: Mark orb-operator-applier-apply spec done --- .../plan.md | 18 --------------- .../README.md | 8 ++++--- .../plan.md | 23 +++++++++++++++++++ .../requirements.md | 5 +++- .../verification.md | 3 ++- 5 files changed, 34 insertions(+), 23 deletions(-) delete mode 100644 specs/2026-08-13-orb-operator-applier-apply/plan.md rename specs/{ => closed}/2026-08-13-orb-operator-applier-apply/README.md (69%) create mode 100644 specs/closed/2026-08-13-orb-operator-applier-apply/plan.md rename specs/{ => closed}/2026-08-13-orb-operator-applier-apply/requirements.md (68%) rename specs/{ => closed}/2026-08-13-orb-operator-applier-apply/verification.md (79%) diff --git a/specs/2026-08-13-orb-operator-applier-apply/plan.md b/specs/2026-08-13-orb-operator-applier-apply/plan.md deleted file mode 100644 index bd4b6846d2..0000000000 --- a/specs/2026-08-13-orb-operator-applier-apply/plan.md +++ /dev/null @@ -1,18 +0,0 @@ -# Implementation Plan - -1. Complete `OrbOperator.Apply` in `internal/operator-controller/applier/orboperator.go`: - - Fix the TODO: capture `cod` and `slices` from `orb.Externalize(cod)` (COSLs already carry owner labels from the externalizer) - - Apply each COSL with `o.Client.Apply(ctx, cosl, client.FieldOwner(o.FieldOwner), client.ForceOwnership)`; return `(false, "", err)` on error - - Apply the COD with the same options; return `(false, "", err)` on error - - Call `o.garbageCollectOrphanedSlices(ctx, ext, slices)`, log any error but do not return it - - Return `(true, "", nil)` - -2. Add `garbageCollectOrphanedSlices` private method to `OrbOperator`: - - Build `current` set from slice names in `slices` - - List `ClusterObjectSliceList` with `client.MatchingLabels{labels.OwnerNameKey: ext.Name}` - - For each listed COSL not in `current`, call `o.Client.Delete`; collect errors with `errors.Join` - - Return joined error (caller logs and discards) - -3. Add unit tests in `internal/operator-controller/applier/orboperator_test.go`: - - Use a fake `client.Client` (or mock) to verify call ordering and arguments - - Cover all acceptance criteria scenarios diff --git a/specs/2026-08-13-orb-operator-applier-apply/README.md b/specs/closed/2026-08-13-orb-operator-applier-apply/README.md similarity index 69% rename from specs/2026-08-13-orb-operator-applier-apply/README.md rename to specs/closed/2026-08-13-orb-operator-applier-apply/README.md index dfd6afba36..df4037569b 100644 --- a/specs/2026-08-13-orb-operator-applier-apply/README.md +++ b/specs/closed/2026-08-13-orb-operator-applier-apply/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator Applier Apply Step @@ -20,19 +20,21 @@ The remaining work picks up after externalization. ### Apply pipeline completion -After `Externalize(cod)` returns the (possibly rewritten) COD and any COSLs. The COSLs already carry the owner labels (`OwnerKindKey`, `OwnerNameKey`) propagated from the COD by the externalizer, so they are directly listable during GC. +After `ExternalizeCOD(cod)` returns the (possibly rewritten) COD and any COSLs. The COSLs already carry the owner labels (`OwnerKindKey`, `OwnerNameKey`) propagated from the COD by the externalizer, so they are directly listable during GC. **Step 1 - Apply COSLs**: SSA each COSL with `client.FieldOwner(o.FieldOwner)` and `client.ForceOwnership`. COSLs must be applied before the COD since the COD's objectRefs point to them. Order within the COSL list doesn't matter. **Step 2 - Apply COD**: SSA the COD with the same field owner options. +**Apply gating (DeepDerivative)**: Before each SSA (both COSLs and the COD), the desired object is compared against the live object read from the cache using `equality.Semantic.DeepDerivative(desired, existing)`. When the live object already reflects every field the desired object sets, the SSA is skipped - it would be a no-op write. Objects that don't exist yet (`IsNotFound`) are always applied. This avoids needless writes and resourceVersion churn on steady-state reconciles. The comparison is done on the unstructured form so it is robust to server-side serialization differences and apiserver-defaulted fields (which appear only on `existing` and are ignored by DeepDerivative). + **Step 3 - GC orphaned COSLs**: After successful COD apply, delete any COSLs that are owned by this CE but not in the current `slices` set (these are leftovers from a previous reconcile where the content was larger or different). See below. **Step 4 - Return**: `(true, "", nil)` on success. Any SSA error returns `(false, "", err)`. GC errors are logged but do not fail the reconcile (they will be retried next cycle). ### GarbageCollectOrphanedSlices -Private helper called from `Apply`. Receives the current `slices` list from `Externalize`. +Private helper called from `Apply`. Receives the current `slices` list from `ExternalizeCOD`. Algorithm: 1. Build a `set[string]` of current slice names from `slices` diff --git a/specs/closed/2026-08-13-orb-operator-applier-apply/plan.md b/specs/closed/2026-08-13-orb-operator-applier-apply/plan.md new file mode 100644 index 0000000000..0eae68b01c --- /dev/null +++ b/specs/closed/2026-08-13-orb-operator-applier-apply/plan.md @@ -0,0 +1,23 @@ +# Implementation Plan + +1. Complete `OrbOperator.Apply` in `internal/operator-controller/applier/orboperator.go`: + - Fix the TODO: capture `cod` and `slices` from `orb.ExternalizeCOD(cod)` (COSLs already carry owner labels from the externalizer) + - For each COSL, skip the apply when `o.alreadyApplied` reports the cached object already matches; otherwise apply with `o.Client.Apply(ctx, cosl, client.FieldOwner(o.FieldOwner), client.ForceOwnership)`; return `(false, "", err)` on error + - Apply the COD with the same gating and options; return `(false, "", err)` on error + - Call `o.garbageCollectOrphanedSlices(ctx, ext, slices)`, log any error but do not return it + - Return `(true, "", nil)` + +2. Add `alreadyApplied` private method to `OrbOperator`: + - Marshal the apply configuration to unstructured + - Get the live object from the cache by GVK+name; return false on `IsNotFound` + - Return `equality.Semantic.DeepDerivative(desired.Object, existing.Object)` + +3. Add `garbageCollectOrphanedSlices` private method to `OrbOperator`: + - Build `current` set from slice names in `slices` + - List `ClusterObjectSliceList` with `client.MatchingLabels{labels.OwnerNameKey: ext.Name}` + - For each listed COSL not in `current`, call `o.Client.Delete`; collect errors with `errors.Join` + - Return joined error (caller logs and discards) + +4. Add unit tests in `internal/operator-controller/applier/orboperator_test.go`: + - Use a fake `client.Client` (or mock) to verify call ordering and arguments + - Cover all acceptance criteria scenarios, including DeepDerivative gating (skip when unchanged, apply when differing) diff --git a/specs/2026-08-13-orb-operator-applier-apply/requirements.md b/specs/closed/2026-08-13-orb-operator-applier-apply/requirements.md similarity index 68% rename from specs/2026-08-13-orb-operator-applier-apply/requirements.md rename to specs/closed/2026-08-13-orb-operator-applier-apply/requirements.md index 10573d0783..36f922f0dd 100644 --- a/specs/2026-08-13-orb-operator-applier-apply/requirements.md +++ b/specs/closed/2026-08-13-orb-operator-applier-apply/requirements.md @@ -1,8 +1,9 @@ # Requirements -- `Apply` uses the COD returned by `Externalize` (not the pre-externalization value) +- `Apply` uses the COD returned by `ExternalizeCOD` (not the pre-externalization value) - COSLs are applied via SSA before the COD - The COD is applied via SSA with `FieldOwner` and `ForceOwnership` +- Each SSA is skipped when the live object (from cache) already reflects the desired state per `equality.Semantic.DeepDerivative`; objects that don't exist are always applied - `Apply` returns `(true, "", nil)` when all SSA calls succeed - `Apply` returns `(false, "", err)` on any SSA failure - Orphaned COSLs (owned by this CE but not in the current slice set) are deleted after successful SSA @@ -15,6 +16,8 @@ - Unit test: SSA error on a COSL returns `(false, "", err)` and does not apply the COD - Unit test: SSA error on the COD returns `(false, "", err)` - Unit test: successful apply returns `(true, "", nil)` +- Unit test: SSA is skipped when the live COD/COSLs already match the desired state (DeepDerivative) +- Unit test: SSA proceeds when the live object differs from the desired state - Unit test: GC deletes owned COSLs not in the current slice set - Unit test: GC with empty slice set deletes all owned COSLs - Unit test: GC error does not fail the apply (returns `(true, "", nil)` still) diff --git a/specs/2026-08-13-orb-operator-applier-apply/verification.md b/specs/closed/2026-08-13-orb-operator-applier-apply/verification.md similarity index 79% rename from specs/2026-08-13-orb-operator-applier-apply/verification.md rename to specs/closed/2026-08-13-orb-operator-applier-apply/verification.md index 69d53a16e4..03f1eeb4db 100644 --- a/specs/2026-08-13-orb-operator-applier-apply/verification.md +++ b/specs/closed/2026-08-13-orb-operator-applier-apply/verification.md @@ -2,8 +2,9 @@ ## Implementation Correctness -- [ ] `Apply` captures the COD returned by `Externalize` (not the pre-externalization value) +- [ ] `Apply` captures the COD returned by `ExternalizeCOD` (not the pre-externalization value) - [ ] Each COSL is SSA'd before the COD is SSA'd +- [ ] SSA is gated by `equality.Semantic.DeepDerivative`: skipped when the cached object already matches, performed when it differs or is absent - [ ] COSL SSA error short-circuits: COD is not applied, returns `(false, "", err)` - [ ] COD SSA error returns `(false, "", err)` - [ ] Successful SSA returns `(true, "", nil)` From 2b3cd1271864e6bff2216b107605dab1029ed495 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 17:09:57 -0400 Subject: [PATCH 20/26] planning: orb-operator revision states getter --- .../README.md | 188 ++++++++++++++++++ .../plan.md | 71 +++++++ .../requirements.md | 90 +++++++++ .../verification.md | 57 ++++++ 4 files changed, 406 insertions(+) create mode 100644 specs/2026-08-13-orb-operator-revision-states-getter/README.md create mode 100644 specs/2026-08-13-orb-operator-revision-states-getter/plan.md create mode 100644 specs/2026-08-13-orb-operator-revision-states-getter/requirements.md create mode 100644 specs/2026-08-13-orb-operator-revision-states-getter/verification.md diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/README.md b/specs/2026-08-13-orb-operator-revision-states-getter/README.md new file mode 100644 index 0000000000..cf489b3a2d --- /dev/null +++ b/specs/2026-08-13-orb-operator-revision-states-getter/README.md @@ -0,0 +1,188 @@ +--- +status: in-progress +--- +# orb-operator RevisionStatesGetter + +## Summary + +Replace the `OrbOperatorRevisionStatesGetter` stub (which returns an empty +`RevisionStates{}`) with real logic that both (a) reports the installed and +rolling-out revisions and (b) drives the `ClusterExtension`'s `Installed`, +`Progressing`, and `Available` conditions from the true orb rollout state. The +getter queries orb-operator's `ClusterObjectSet` (COS) and +`ClusterObjectDeployment` (COD) APIs and synthesizes OLM-vocabulary conditions, +mirroring how the Boxcutter runtime already works. + +This fixes two coupled bugs: + +1. **Reconcile loop.** The empty stub makes `ResolveBundle` and `ApplyBundle` + disagree on the installed bundle name, flapping the `BundleDeprecated` + condition's `lastTransitionTime` every reconcile and causing an unbounded + ClusterExtension write / reconcile loop. +2. **Premature success.** Under the `OrbOperatorRuntime` feature gate, a + `ClusterExtension` reports `Installed=True/Succeeded` and + `Progressing=True/Succeeded` the moment its COD is created, even when the + revision is wedged and its objects were never applied - the CE lies about + being installed. It surfaces in `test/e2e/features/recover.feature:55` + ("Install ClusterExtension after conflicting resource is removed"), which + waits for `Progressing=True/Retrying` on a resource collision and instead + sees `Succeeded`. + +All status derivation lives in one testable place, `OrbOperatorRevisionStatesGetter`; +the orb applier stays "dumb" (its `(bool, string)` return is ignored, while a +non-nil `error` still surfaces as `Retrying`). + +## Background: how Boxcutter already does this + +Status mapping is not done in the applier. For Boxcutter it is a three-layer flow: + +1. The in-tree `ClusterObjectSet` controller sets `.status.conditions` + (`Progressing`, `Available`, `Succeeded`) on each revision. +2. `BoxcutterRevisionStatesGetter.GetRevisionStates` lists the revisions, decides + Installed vs RollingOut, and copies `rev.Status.Conditions` into + `RevisionMetadata.Conditions`. +3. The revision-state-driven apply step discards the applier's `(bool, string)` + return and mirrors each revision's `ocv1.ClusterObjectSetTypeAvailable` / + `...TypeProgressing` conditions onto the CE, using the latest rolling-out + revision's `Progressing` as the CE's `Progressing`. + +The orb getter mirrors this, with two differences: orb's COS has no +OLM-vocabulary `Progressing`/`Succeeded` conditions, so the getter synthesizes +them from COS `observedPhases` plus the COD `Progressing` condition; and +completion is keyed off COS `completedAt` rather than a `Succeeded` condition. + +## Design + +### Root cause being fixed (reconcile loop) + +Per reconcile, `ResolveBundle` sets `BundleDeprecated` from +`state.revisionStates.Installed` (nil -> `Unknown/Absent`), then `ApplyBundle` +sets it again from the resolved bundle name (-> `False`). The status flap +rewrites `lastTransitionTime` each cycle, so +`DeepEqual(existing.Status, reconciled.Status)` is always false -> a status write +-> a watch event -> another reconcile. Populating `Installed` stably makes both +call sites agree, so the condition stops flapping. + +### Getter shape + +Give `OrbOperatorRevisionStatesGetter` a `Reader client.Reader` field (like +`BoxcutterRevisionStatesGetter`) and wire it from the manager client in `main.go`: + +```go +type OrbOperatorRevisionStatesGetter struct { + Reader client.Reader +} +``` + +`main.go` currently constructs `&controllers.OrbOperatorRevisionStatesGetter{}`; +change it to `{Reader: c.mgr.GetClient()}`. + +### Querying revisions and the COD + +orb-operator's model (verified against `github.com/joelanford/orb-operator@v0.0.3`): +- The applier creates one `ClusterObjectDeployment` (COD) named `ext.Name`. The + orb COD controller stamps out `ClusterObjectSet` revisions named + `<cod.Name>-<revision>` with `spec.group == cod.Name` (i.e. `== ext.Name`). +- Each COS inherits the COD template metadata: the template labels (owner labels) + and annotations (the bundle metadata the applier set). So bundle identity is + readable directly off each COS's annotations. + +List COS via the existing `spec.group` field indexer (registered in `main.go`) +for a cache-served lookup, and `Get` the COD by name for its deployment-level +`Progressing` signal: + +```go +list := &orbv1alpha1.ClusterObjectSetList{} +r.Reader.List(ctx, list, client.MatchingFields{"spec.group": ext.Name}) + +cod := &orbv1alpha1.ClusterObjectDeployment{} +r.Reader.Get(ctx, client.ObjectKey{Name: ext.Name}, cod) // may not exist yet +``` + +### Building RevisionStates + +Mirror `BoxcutterRevisionStatesGetter.GetRevisionStates`: + +1. List COS by `spec.group == ext.Name`. +2. Sort ascending by `Spec.Revision`. +3. Skip revisions whose `Spec.LifecycleState == LifecycleStateArchived`. +4. Build a `RevisionMetadata` per live revision from the COS annotations: + + | RevisionMetadata field | Source | + |---|---| + | `RevisionName` | COS `metadata.name` | + | `Package` | `labels.PackageNameKey` annotation | + | `Image` | `labels.BundleReferenceKey` annotation | + | `BundleMetadata.Name` | `labels.BundleNameKey` annotation | + | `BundleMetadata.Version` | `labels.BundleVersionKey` annotation | + | `Release` | `labels.BundleReleaseKey` annotation, only if the key is present | + | `Conditions` | **synthesized** OLM-vocabulary `Available` + `Progressing` (see below) | + +5. Classify installed vs rolling-out using COS `status.completedAt` (set once when + all phases first complete, never cleared): + - `completedAt != nil` -> `Installed` (last one wins in ascending order) + - otherwise -> append to `RollingOut` + +6. Return the `RevisionStates`. + +### Synthesizing the CE conditions + +`RevisionStates` is the deliberate interface for telling the CE what to display; +it does not need to be a faithful projection of orb's model. The getter populates +`RevisionMetadata.Conditions` with: + +**Available**: the COS `Available` condition passes through as +`ocv1.ClusterObjectSetTypeAvailable` (orb and OLM share the +`Available`/`Unavailable` reason strings). + +**Progressing**: synthesized as `ocv1.ClusterObjectSetTypeProgressing` from the +active revision's COS `status` (primarily `observedPhases`) plus the COD +`Progressing` condition, in priority order: + +| Signal | CE status | CE reason (`ocv1`) | +|---|---|---| +| revision completed (`completedAt != nil`) | `True` | `ReasonSucceeded` | +| COD `Progressing` reason `ProgressDeadlineExceeded` | `False` | `ReasonProgressDeadlineExceeded` (reconcile continues) | +| a phase `Status == Invalid`, OR a phase with `synced < total` and non-empty `objectDetails` | `True` | `ClusterObjectSetReasonRetrying` | +| COD `Progressing` reason in {`ReconcileError`, `InternalError`, `InvalidRevision`, `TeardownError`} | `True` | `ClusterObjectSetReasonRetrying` | +| otherwise in progress (`WaitingForAssertions`, or clean `Reconciling`) | `True` | `ReasonRollingOut` | + +Attach the synthesized conditions to the `RevisionMetadata` that drives CE +`Progressing`: the latest rolling-out revision when one exists, otherwise the +installed revision. + +Terminology: "terminal" means the reconcile sense (a `reconcile.TerminalError` +that stops requeue/backoff). None of the reasons above are reconcile-terminal; +`ProgressDeadlineExceeded` sets `Progressing=False` but the controller keeps +retrying. + +The discriminator between `RollingOut` and `Retrying` is structured, not +free-text: phase `Status == Invalid` and the `synced < total` count are the cues; +`objectDetails` presence confirms the not-synced object is genuinely blocked (as +opposed to `synced == total` with `available < total`, which is a probe/assertion +still pending -> `RollingOut`). The `recover.feature:55` collision (immutable +`Deployment.spec.selector`) is caught by orb's per-object preflight dry-run, which +reports the `deploy` phase as `Status == Invalid` with `objectCounts {total:1, +synced:0}` and an `objectDetails` entry naming the immutable-selector error. That +`Invalid` is retryable (it clears when the conflicting object is removed), so it +maps to `Retrying`, letting `recover.feature:55` pass unchanged. + +### Rename the revision-state-driven apply step (runtime-neutral) + +Rename `ApplyBundleWithBoxcutter` to a runtime-neutral name +(`ApplyBundleWithRevisions`) and reuse it for both Boxcutter and orb. It has no +Boxcutter-specific coupling; it is generic over `RevisionStates` and only looks +for conditions of type `ocv1.ClusterObjectSetTypeAvailable` / `...TypeProgressing`. +Update the Boxcutter and orb configurators in `cmd/operator-controller/main.go` to +use it; the Helm configurator keeps the generic bool-driven `ApplyBundle`. + +### Type note + +orb's COS/COD (`orbv1alpha1`) are distinct types from Boxcutter's `ocv1` +equivalents; the logic is parallel but operates on the orb API types, reads +`CompletedAt` instead of a `Succeeded` condition, and synthesizes `Progressing` +from `observedPhases`. + +### Out of scope + +- Controller watches/predicates for COD/COS - existing wiring is unchanged. diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/plan.md b/specs/2026-08-13-orb-operator-revision-states-getter/plan.md new file mode 100644 index 0000000000..e1bc4a268e --- /dev/null +++ b/specs/2026-08-13-orb-operator-revision-states-getter/plan.md @@ -0,0 +1,71 @@ +# Implementation Plan + +Prerequisite: the orb-runtime resource-gathering fix for the e2e harness +(`:seedling: Make e2e resource-gathering helpers orb-runtime aware`) so the +experimental e2e suite reaches these scenarios instead of panicking. + +## 1. Rename the revision-state-driven apply step (pure refactor) + +- Rename `ApplyBundleWithBoxcutter` -> `ApplyBundleWithRevisions` in + `internal/operator-controller/controllers/boxcutter_reconcile_steps.go` + (no logic change). +- Update the Boxcutter configurator in `cmd/operator-controller/main.go` to call + the renamed function; update any references in tests/mocks. +- Confirm no behavior change: `make test-unit` for the controllers package. + +## 2. Implement the getter in `internal/operator-controller/controllers/orboperator_reconcile_steps.go` + +- Add `Reader client.Reader` to `OrbOperatorRevisionStatesGetter`. +- `GetRevisionStates`: + - List `orbv1alpha1.ClusterObjectSetList` with + `client.MatchingFields{"spec.group": ext.Name}`; `Get` the COD named + `ext.Name` (tolerate NotFound). + - Sort ascending by `Spec.Revision`; skip `LifecycleStateArchived`. + - Build `RevisionMetadata` from COS annotations (using the `labels.*` key + constants); set `Release` only when the key exists. + - `completedAt != nil` -> `Installed` (last wins); else append to `RollingOut`. + - Synthesize `RevisionMetadata.Conditions`: + - Pass through the COS `Available` condition as + `ocv1.ClusterObjectSetTypeAvailable`. + - Produce `ocv1.ClusterObjectSetTypeProgressing` in the README priority order + (completed / deadline / blocked-phase / COD-error / rolling-out), reading + the COS `status.observedPhases` and the COD `Progressing` condition. +- Wire the reader in `cmd/operator-controller/main.go`: + `&controllers.OrbOperatorRevisionStatesGetter{Reader: c.mgr.GetClient()}`. + +## 3. Wire the orb runtime to the revision-state-driven step + +- In the orb configurator in `cmd/operator-controller/main.go`, replace + `controllers.ApplyBundle(appl)` with + `controllers.ApplyBundleWithRevisions(appl.Apply)`. +- Leave the orb applier (`applier/orboperator.go`) unchanged; add a brief comment + noting its `(bool, string)` return is intentionally ignored by this step, while + a non-nil `error` still drives `Retrying`. + +## 4. Unit tests in `orboperator_reconcile_steps_test.go` + +- Seed a fake `client.Reader` with `orbv1alpha1.ClusterObjectSet` / + `ClusterObjectDeployment` objects (register the orb scheme and the `spec.group` + index on the fake client via `WithIndex` so `MatchingFields` works). +- Cover the getter classification (installed, rolling-out, mixed, + multiple-completed, archived-skip, release-present/absent, empty, list-error) + and the full Progressing reason mapping (each row, using crafted COD/COS objects). + +## 5. Empirical validation on the live experimental cluster + +- Confirm a healthy install goes `Progressing=RollingOut -> Succeeded` with + `status.install.bundle` and `status.activeRevisions` populated. +- Reproduce the `recover.feature:55` collision and confirm the CE reaches + `Progressing=True/Retrying`. +- Confirm a stuck revision (no `completedAt`) never shows premature `Succeeded`, + and that after a completed install the CE `resourceVersion` stops incrementing + and `BundleDeprecated.lastTransitionTime` is stable (no flap). + +## 6. Confirm `recover.feature:55` passes unchanged + +- The collision maps to `Progressing=True/Retrying` via the phase-`Invalid` cue, + matching the existing Helm/Boxcutter assertion, so no test edit is expected. + +## 7. Finalize + +- `make lint`, `make test-unit`, `make verify`. diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/requirements.md b/specs/2026-08-13-orb-operator-revision-states-getter/requirements.md new file mode 100644 index 0000000000..0ac67ef057 --- /dev/null +++ b/specs/2026-08-13-orb-operator-revision-states-getter/requirements.md @@ -0,0 +1,90 @@ +# Requirements + +## Getter / RevisionStates + +- `OrbOperatorRevisionStatesGetter` has a `Reader client.Reader` field, wired from + the manager client in `main.go` +- `GetRevisionStates` lists `orbv1alpha1.ClusterObjectSet` via + `client.MatchingFields{"spec.group": ext.Name}` (using the existing `spec.group` + indexer) and `Get`s the `ClusterObjectDeployment` named `ext.Name` (tolerating + NotFound on the first reconcile) +- Revisions are sorted ascending by `Spec.Revision` +- Revisions with `Spec.LifecycleState == Archived` are skipped +- Each live revision yields a `RevisionMetadata` populated from the COS annotations + (bundle name, version, package, image); `Release` is set only when the + `BundleReleaseKey` annotation is present +- A revision with `status.completedAt != nil` is recorded as `Installed` (last wins + in ascending order); others are appended to `RollingOut` +- A List/Get error is returned wrapped; no partial results on error +- When no revisions exist, an empty (non-nil) `RevisionStates` is returned with no + error + +## Status mapping + +- Under `OrbOperatorRuntime`, a `ClusterExtension` MUST NOT report + `Installed=True/Succeeded` or `Progressing=True/Succeeded` while its active orb + revision has not completed (`completedAt` unset). Completion is keyed solely off + COS `status.completedAt` (current per-phase `Available` is orthogonal). +- `RevisionMetadata.Conditions` carries synthesized + `ocv1.ClusterObjectSetTypeAvailable` (passed through from the COS `Available` + condition) and `ocv1.ClusterObjectSetTypeProgressing` conditions, attached to the + CE-Progressing-driving revision (latest rolling-out, else installed) +- Progressing classification, in priority order: + - completed -> `True/Succeeded` + - COD `ProgressDeadlineExceeded` -> `False/ProgressDeadlineExceeded` (status only; + the reconciler keeps retrying, NOT a `reconcile.TerminalError`) + - phase `Status == Invalid`, OR a phase with `synced < total` and non-empty + `objectDetails` -> `True/Retrying` + - COD reason in {`ReconcileError`, `InternalError`, `InvalidRevision`, + `TeardownError`} -> `True/Retrying` + - otherwise (`WaitingForAssertions` / clean `Reconciling`) -> `True/RollingOut` +- The CE `Available` condition MUST reflect the orb COS `Available` condition + (`Available` / `Unavailable`) +- The orb applier's `Apply` return `bool`/`string` MUST NOT be relied on for CE + status; a non-nil `error` MUST still surface as `Progressing=Retrying` via the + apply step's existing error handling + +## Apply step + +- `ApplyBundleWithBoxcutter` is renamed to a runtime-neutral + `ApplyBundleWithRevisions`; the Boxcutter and orb configurators in + `cmd/operator-controller/main.go` both use it; the Helm configurator keeps the + bool-driven `ApplyBundle` +- The Boxcutter runtime's status behavior is unchanged (the rename is a pure + refactor for it) + +## Constraints + +- All status mapping logic lives in `OrbOperatorRevisionStatesGetter` (single, + unit-testable location); the applier stays free of CE-status concerns +- No new API types or CRD changes; consumes existing `ocv1` condition vocabulary + and `github.com/joelanford/orb-operator/api/v1alpha1` constants +- No `//nolint` suppressions; fix underlying issues +- Follows `specs/mission.md` principle 1 (work with Kubernetes condition patterns) + and principle 3 (simple, predictable, eventually-consistent status) + +## Acceptance Criteria + +- Unit test: single completed revision -> `Installed` set with correct bundle + metadata, `Progressing=True/Succeeded`, `RollingOut` empty +- Unit test: single revision with nil `completedAt` -> `RollingOut` has it, + `Installed` nil +- Unit test: mixed revisions (older completed, newer not) -> `Installed` is the + completed one, newer is in `RollingOut` +- Unit test: two completed revisions -> the higher-revision one wins as `Installed` +- Unit test: archived revisions are skipped +- Unit test: `Release` populated only when the release annotation key is present +- Unit test: no revisions -> empty `RevisionStates`, no error +- Unit test: List error is propagated +- Unit test: Progressing classification covers every row: phase `Invalid` -> + Retrying; `synced < total` + `objectDetails` -> Retrying; `WaitingForAssertions` + / clean progress -> RollingOut; COD `ProgressDeadlineExceeded` -> + False/ProgressDeadlineExceeded; COD error reasons -> Retrying; completed -> + Succeeded; `Available` passthrough +- e2e: `test/e2e/features/recover.feature:55` passes under + `make test-experimental-e2e` **unchanged** (collision -> `Progressing=True/Retrying` + via the phase-`Invalid` cue, matching the Helm/Boxcutter assertion) +- e2e: `install.feature` and `update.feature` happy-path scenarios still pass +- Live/e2e: after install completes, repeated reconciles do not rewrite the CE + status (no `BundleDeprecated` flap), and a stuck revision (COS + `completedAt == nil`) never shows premature `Succeeded` diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/verification.md b/specs/2026-08-13-orb-operator-revision-states-getter/verification.md new file mode 100644 index 0000000000..52120eff3a --- /dev/null +++ b/specs/2026-08-13-orb-operator-revision-states-getter/verification.md @@ -0,0 +1,57 @@ +# Verification + +## Implementation Correctness + +- [ ] `OrbOperatorRevisionStatesGetter` has a `Reader client.Reader` field, wired + from `c.mgr.GetClient()` in `main.go` +- [ ] `GetRevisionStates` lists COS via `client.MatchingFields{"spec.group": ext.Name}` + and `Get`s the COD named `ext.Name` (NotFound tolerated) +- [ ] Revisions are sorted ascending by `Spec.Revision`; archived revisions skipped +- [ ] `RevisionMetadata` is built from COS annotations (name/version/package/image); + `Release` set only when the key is present +- [ ] `completedAt != nil` classifies a revision as `Installed` (last wins); others + go to `RollingOut`; a revision with `completedAt == nil` is never `Installed` +- [ ] List/Get errors are returned wrapped; empty list yields empty non-nil + `RevisionStates` +- [ ] `RevisionMetadata.Conditions` carries a synthesized + `ClusterObjectSetTypeAvailable` (passthrough) and `ClusterObjectSetTypeProgressing`, + attached to the CE-Progressing-driving revision +- [ ] Progressing classification matches the README priority table exactly: + completed -> Succeeded; COD `ProgressDeadlineExceeded` -> + False/ProgressDeadlineExceeded (wins); phase `Invalid` OR (`synced < total` + + `objectDetails`) -> Retrying; COD error reasons -> Retrying; + `WaitingForAssertions` / clean progress -> RollingOut +- [ ] `ApplyBundleWithBoxcutter` renamed to `ApplyBundleWithRevisions`; Boxcutter + and orb configurators in `cmd/operator-controller/main.go` both call it; Helm + still calls `ApplyBundle` +- [ ] The orb applier is unchanged except for a clarifying comment; its bool is not + consulted for status; a non-nil apply error still yields `Retrying` +- [ ] All unit tests pass + +## Behavioral / e2e (live experimental cluster) + +- [ ] `recover.feature:55` passes under `make test-experimental-e2e` **unchanged** + - the collision maps to `Progressing=True/Retrying` via the phase-`Invalid` + cue (confirmed on the live cluster: `deploy` phase `status: Invalid`, + `synced 0/total 1`, immutable-selector message in `objectDetails`) +- [ ] Fresh install with a stuck revision (COS `completedAt == nil`): CE shows + `Installed != True` and `Progressing != Succeeded` (premature-success gone) +- [ ] Healthy install: CE goes `Progressing=RollingOut -> Succeeded`, with + `status.install.bundle` and `status.activeRevisions` populated +- [ ] After install completes, the CE status stops churning (no `BundleDeprecated` + flap) +- [ ] `install.feature` and `update.feature` happy-path scenarios still pass +- [ ] Boxcutter runtime status behavior unchanged (rely on existing e2e/unit + coverage) + +## Project Conventions + +- [ ] Code follows Go style and passes `make lint` +- [ ] No `//nolint` comments added +- [ ] Uses the `labels.*` key constants (not string literals) for annotation lookups +- [ ] Mirrors `BoxcutterRevisionStatesGetter` structure for consistency (per + specs/mission.md: simple, predictable) +- [ ] Uses orb-operator API types from tech-stack + (`github.com/joelanford/orb-operator@v0.0.3`); no new dependencies +- [ ] `make test-unit` passes; `make verify` shows no unintended generated-code + changes From 6de8ff391a52fb034a4db2a98955f6acd52419c5 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 17:18:24 -0400 Subject: [PATCH 21/26] :sparkles: Implement OrbOperatorRevisionStatesGetter with status mapping --- cmd/operator-controller/main.go | 8 +- .../controllers/boxcutter_reconcile_steps.go | 10 +- .../boxcutter_reconcile_steps_apply_test.go | 4 +- .../orboperator_reconcile_steps.go | 191 +++++++- .../orboperator_reconcile_steps_test.go | 416 ++++++++++++++++++ 5 files changed, 620 insertions(+), 9 deletions(-) create mode 100644 internal/operator-controller/controllers/orboperator_reconcile_steps_test.go diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index f6a03aa993..83be397bd6 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -706,7 +706,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl controllers.RetrieveRevisionStates(revisionStatesGetter), controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), controllers.UnpackBundle(c.imagePuller, c.imageCache), - controllers.ApplyBundleWithBoxcutter(appl.Apply), + controllers.ApplyBundleWithRevisions(appl.Apply), } baseDiscoveryClient, err := discovery.NewDiscoveryClientForConfig(c.mgr.GetConfig()) @@ -772,7 +772,7 @@ func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers. Preflights: c.preflights, FieldOwner: fieldOwner, } - revisionStatesGetter := &controllers.OrbOperatorRevisionStatesGetter{} + revisionStatesGetter := &controllers.OrbOperatorRevisionStatesGetter{Reader: c.mgr.GetClient()} ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{ controllers.HandleFinalizers(c.finalizers), controllers.ValidateClusterExtension( @@ -781,7 +781,9 @@ func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers. controllers.RetrieveRevisionStates(revisionStatesGetter), controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), controllers.UnpackBundle(c.imagePuller, c.imageCache), - controllers.ApplyBundle(appl), + // Revision-state-driven apply: CE status is derived from the orb + // COD/COS state via the getter above, not the applier's return bool. + controllers.ApplyBundleWithRevisions(appl.Apply), } return nil diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go index f340520fc7..97d57a3ccd 100644 --- a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go @@ -100,7 +100,15 @@ func MigrateStorage(m StorageMigrator) ReconcileStepFunc { } } -func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error)) ReconcileStepFunc { +// ApplyBundleWithRevisions is a revision-state-driven apply step shared by the +// Boxcutter and orb runtimes. It calls the applier only to create/update the +// revision resources and deliberately discards the applier's (bool, string) +// return: the ClusterExtension's status is derived entirely from +// state.revisionStates (populated by the RevisionStatesGetter), whose +// RevisionMetadata carry ocv1.ClusterObjectSetType{Available,Progressing} +// conditions. A non-nil apply error is still surfaced via the Progressing +// condition. (Helm uses the bool-driven ApplyBundle instead.) +func ApplyBundleWithRevisions(apply func(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error)) ReconcileStepFunc { return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { l := log.FromContext(ctx) revisionAnnotations := map[string]string{ diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go index 78adb70090..fede463dd3 100644 --- a/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go @@ -28,7 +28,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ) -func TestApplyBundleWithBoxcutter(t *testing.T) { +func TestApplyBundleWithRevisions(t *testing.T) { type args struct { activeRevisions []ocv1.RevisionStatus revisionStates *RevisionStates @@ -133,7 +133,7 @@ func TestApplyBundleWithBoxcutter(t *testing.T) { imageFS: fstest.MapFS{}, } - stepFunc := ApplyBundleWithBoxcutter(func(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (bool, string, error) { + stepFunc := ApplyBundleWithRevisions(func(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (bool, string, error) { return true, "", nil }) result, err := stepFunc(ctx, state, ext) diff --git a/internal/operator-controller/controllers/orboperator_reconcile_steps.go b/internal/operator-controller/controllers/orboperator_reconcile_steps.go index 16ad6892ad..b8eacad435 100644 --- a/internal/operator-controller/controllers/orboperator_reconcile_steps.go +++ b/internal/operator-controller/controllers/orboperator_reconcile_steps.go @@ -1,13 +1,198 @@ package controllers import ( + "cmp" "context" + "fmt" + "slices" + + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) -type OrbOperatorRevisionStatesGetter struct{} +type OrbOperatorRevisionStatesGetter struct { + Reader client.Reader +} + +func (o *OrbOperatorRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *ocv1.ClusterExtension) (*RevisionStates, error) { + // The orb ClusterObjectDeployment is named after the ClusterExtension, and + // the orb controller stamps out ClusterObjectSet revisions whose spec.group + // equals that name. Query them via the spec.group field indexer. + existingRevisionList := &orbv1alpha1.ClusterObjectSetList{} + if err := o.Reader.List(ctx, existingRevisionList, client.MatchingFields{ + "spec.group": ext.Name, + }); err != nil { + return nil, fmt.Errorf("listing revisions: %w", err) + } + slices.SortFunc(existingRevisionList.Items, func(a, b orbv1alpha1.ClusterObjectSet) int { + return cmp.Compare(a.Spec.Revision, b.Spec.Revision) + }) + + // The ClusterObjectDeployment is named after the ClusterExtension. Its + // Progressing condition carries the deployment-level rollout signal + // (ProgressDeadlineExceeded, ReconcileError, ...) used to classify the active + // revision. It may not exist yet on the first reconcile. + var codProgressing *metav1.Condition + cod := &orbv1alpha1.ClusterObjectDeployment{} + if err := o.Reader.Get(ctx, client.ObjectKey{Name: ext.Name}, cod); err != nil { + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("getting ClusterObjectDeployment: %w", err) + } + } else { + codProgressing = apimeta.FindStatusCondition(cod.Status.Conditions, orbv1alpha1.ConditionTypeProgressing) + } + + rs := &RevisionStates{} + for i := range existingRevisionList.Items { + rev := &existingRevisionList.Items[i] + if rev.Spec.LifecycleState == orbv1alpha1.LifecycleStateArchived { + continue + } + + // completedAt is set once (when all phases first become Available) and is + // never cleared, so it is the signal that a revision is installed. The + // current per-phase Available state is orthogonal and not consulted here. + completed := rev.Status.CompletedAt != nil + + // The bundle metadata annotations are set by the applier on the COD + // template metadata and propagated onto each revision by the orb + // controller. + rm := &RevisionMetadata{ + RevisionName: rev.Name, + Package: rev.Annotations[labels.PackageNameKey], + Image: rev.Annotations[labels.BundleReferenceKey], + // Synthesize OLM-vocabulary Available/Progressing conditions so the + // revision-state-driven apply step can mirror them onto the CE. + Conditions: orbRevisionConditions(rev, codProgressing, completed), + BundleMetadata: ocv1.BundleMetadata{ + Name: rev.Annotations[labels.BundleNameKey], + Version: rev.Annotations[labels.BundleVersionKey], + }, + } + // Only set Release if the annotation key exists (to distinguish "not set" from "explicitly empty") + if releaseValue, ok := rev.Annotations[labels.BundleReleaseKey]; ok { + rm.Release = &releaseValue + } + + if completed { + rs.Installed = rm + } else { + rs.RollingOut = append(rs.RollingOut, rm) + } + } + + return rs, nil +} + +// orbErrorProgressingReasons are orb COD Progressing reasons that indicate a +// retryable rollout error; they map onto the ClusterExtension's Retrying reason. +var orbErrorProgressingReasons = map[string]struct{}{ + orbv1alpha1.ReasonReconcileError: {}, + orbv1alpha1.ReasonInternalError: {}, + orbv1alpha1.ReasonInvalidRevision: {}, + orbv1alpha1.ReasonTeardownError: {}, +} + +// orbRevisionConditions synthesizes the ocv1 Available and Progressing conditions +// that drive ClusterExtension status for a single orb revision. The apply step +// (ApplyBundleWithRevisions) mirrors these onto the CE. +func orbRevisionConditions(rev *orbv1alpha1.ClusterObjectSet, codProgressing *metav1.Condition, completed bool) []metav1.Condition { + conds := make([]metav1.Condition, 0, 2) + + // Available passes through unchanged: orb and OLM share the + // "Available"/"Unavailable" condition type and reason strings. + if avail := apimeta.FindStatusCondition(rev.Status.Conditions, orbv1alpha1.ConditionTypeAvailable); avail != nil { + conds = append(conds, metav1.Condition{ + Type: ocv1.ClusterObjectSetTypeAvailable, + Status: avail.Status, + Reason: avail.Reason, + Message: avail.Message, + }) + } + + conds = append(conds, orbProgressingCondition(rev, codProgressing, completed)) + return conds +} + +// orbProgressingCondition maps orb's rollout state onto the CE Progressing +// condition, in priority order (see +// specs/2026-08-13-orb-operator-status-mapping): +// 1. COD ProgressDeadlineExceeded -> Progressing=False/ProgressDeadlineExceeded +// (a status signal only; the controller keeps reconciling - not terminal). +// 2. A blocked phase (Invalid, or synced<total with objectDetails) -> Retrying. +// 3. COD error reason (ReconcileError/InternalError/InvalidRevision/ +// TeardownError) -> Retrying. +// 4. Otherwise -> RollingOut. A completed revision -> Succeeded. +func orbProgressingCondition(rev *orbv1alpha1.ClusterObjectSet, codProgressing *metav1.Condition, completed bool) metav1.Condition { + cond := metav1.Condition{Type: ocv1.ClusterObjectSetTypeProgressing, Status: metav1.ConditionTrue} + blockedMsg := orbBlockedPhaseMessage(rev) + + switch { + case completed: + cond.Reason = ocv1.ReasonSucceeded + cond.Message = "Desired state reached" + case codProgressing != nil && codProgressing.Reason == orbv1alpha1.ReasonProgressDeadlineExceeded: + cond.Status = metav1.ConditionFalse + cond.Reason = ocv1.ReasonProgressDeadlineExceeded + cond.Message = codProgressing.Message + case blockedMsg != "": + cond.Reason = ocv1.ClusterObjectSetReasonRetrying + cond.Message = blockedMsg + case codProgressing != nil && isOrbErrorReason(codProgressing.Reason): + cond.Reason = ocv1.ClusterObjectSetReasonRetrying + cond.Message = codProgressing.Message + default: + cond.Reason = ocv1.ReasonRollingOut + cond.Message = "Revision is rolling out" + if codProgressing != nil && codProgressing.Message != "" { + cond.Message = codProgressing.Message + } + } + return cond +} + +func isOrbErrorReason(reason string) bool { + _, ok := orbErrorProgressingReasons[reason] + return ok +} + +// orbBlockedPhaseMessage returns a non-empty message when a phase of the revision +// reports an object that cannot be applied: a phase Status of Invalid (caught by +// orb's per-object preflight dry-run, e.g. an immutable-field collision), or a +// phase with synced<total and object-level failure details. This is the +// "legitimate problem" signal, distinct from WaitingForAssertions +// (synced==total, probes/assertions pending), which is a healthy rollout. +func orbBlockedPhaseMessage(rev *orbv1alpha1.ClusterObjectSet) string { + for i := range rev.Status.ObservedPhases { + phase := &rev.Status.ObservedPhases[i] + blocked := phase.Status == orbv1alpha1.PhaseStatusInvalid || + (phase.ObjectCounts.Synced < phase.ObjectCounts.Total && len(phase.ObjectDetails) > 0) + if !blocked { + continue + } + if msg := firstObjectDetailMessage(phase); msg != "" { + return msg + } + if phase.Message != "" { + return phase.Message + } + return fmt.Sprintf("phase %q is not progressing", phase.Name) + } + return "" +} -func (o *OrbOperatorRevisionStatesGetter) GetRevisionStates(_ context.Context, _ *ocv1.ClusterExtension) (*RevisionStates, error) { - return &RevisionStates{}, nil +func firstObjectDetailMessage(phase *orbv1alpha1.ObservedPhase) string { + for i := range phase.ObjectDetails { + od := &phase.ObjectDetails[i] + if len(od.Messages) > 0 { + return fmt.Sprintf("%s %s/%s: %s", od.Kind, od.Namespace, od.Name, od.Messages[0]) + } + } + return "" } diff --git a/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go b/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go new file mode 100644 index 0000000000..f604360c94 --- /dev/null +++ b/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go @@ -0,0 +1,416 @@ +package controllers_test + +import ( + "context" + "errors" + "testing" + + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apimeta "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" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" +) + +func orbTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, orbv1alpha1.AddToScheme(scheme)) + return scheme +} + +// cosRevision builds a ClusterObjectSet revision for the given group. +func cosRevision(name, group string, revision uint32, lifecycle orbv1alpha1.LifecycleState, completed bool, annotations map[string]string) *orbv1alpha1.ClusterObjectSet { + cos := &orbv1alpha1.ClusterObjectSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Annotations: annotations, + }, + Spec: orbv1alpha1.ClusterObjectSetSpec{ + Group: group, + Revision: revision, + LifecycleState: lifecycle, + }, + } + if completed { + now := metav1.Now() + cos.Status.CompletedAt = &now + } + return cos +} + +func bundleAnnotations(name, version, pkg, ref string) map[string]string { + return map[string]string{ + labels.BundleNameKey: name, + labels.BundleVersionKey: version, + labels.PackageNameKey: pkg, + labels.BundleReferenceKey: ref, + } +} + +func newOrbGetter(t *testing.T, objs ...client.Object) *controllers.OrbOperatorRevisionStatesGetter { + t.Helper() + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithObjects(objs...). + WithIndex(&orbv1alpha1.ClusterObjectSet{}, "spec.group", func(o client.Object) []string { + return []string{o.(*orbv1alpha1.ClusterObjectSet).Spec.Group} + }). + Build() + return &controllers.OrbOperatorRevisionStatesGetter{Reader: fakeClient} +} + +func TestOrbGetRevisionStates_SingleCompleted(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "quay.io/argocd@sha256:abc")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + assert.Empty(t, rs.RollingOut) + assert.Equal(t, "argocd-1", rs.Installed.RevisionName) + assert.Equal(t, "argocd-operator.v0.13.0", rs.Installed.Name) + assert.Equal(t, "0.13.0", rs.Installed.Version) + assert.Equal(t, "argocd-operator", rs.Installed.Package) + assert.Equal(t, "quay.io/argocd@sha256:abc", rs.Installed.Image) + assert.Nil(t, rs.Installed.Release) +} + +func TestOrbGetRevisionStates_SingleRollingOut(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, false, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + assert.Nil(t, rs.Installed) + require.Len(t, rs.RollingOut, 1) + assert.Equal(t, "argocd-1", rs.RollingOut[0].RevisionName) +} + +func TestOrbGetRevisionStates_MixedInstalledAndRollingOut(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // Seed out of order to also exercise the ascending sort. + getter := newOrbGetter(t, + cosRevision("argocd-2", "argocd", 2, orbv1alpha1.LifecycleStateActive, false, + bundleAnnotations("argocd-operator.v0.14.0", "0.14.0", "argocd-operator", "ref2")), + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref1")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + assert.Equal(t, "argocd-1", rs.Installed.RevisionName) + require.Len(t, rs.RollingOut, 1) + assert.Equal(t, "argocd-2", rs.RollingOut[0].RevisionName) +} + +func TestOrbGetRevisionStates_MultipleCompletedHighestWins(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref1")), + cosRevision("argocd-2", "argocd", 2, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.14.0", "0.14.0", "argocd-operator", "ref2")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + assert.Equal(t, "argocd-2", rs.Installed.RevisionName) + assert.Equal(t, "0.14.0", rs.Installed.Version) + assert.Empty(t, rs.RollingOut) +} + +func TestOrbGetRevisionStates_SkipsArchived(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateArchived, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref1")), + cosRevision("argocd-2", "argocd", 2, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.14.0", "0.14.0", "argocd-operator", "ref2")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + assert.Equal(t, "argocd-2", rs.Installed.RevisionName) + assert.Empty(t, rs.RollingOut) +} + +func TestOrbGetRevisionStates_ReleaseOnlyWhenPresent(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + + withRelease := bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref") + withRelease[labels.BundleReleaseKey] = "3" + + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, withRelease), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + require.NotNil(t, rs.Installed.Release) + assert.Equal(t, "3", *rs.Installed.Release) +} + +func TestOrbGetRevisionStates_FiltersByGroup(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref")), + cosRevision("other-1", "other", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("other.v1", "1.0.0", "other", "ref")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs.Installed) + assert.Equal(t, "argocd-1", rs.Installed.RevisionName) + assert.Empty(t, rs.RollingOut) +} + +func TestOrbGetRevisionStates_NoRevisions(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.NotNil(t, rs) + assert.Nil(t, rs.Installed) + assert.Empty(t, rs.RollingOut) +} + +func TestOrbGetRevisionStates_ListError(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + fakeClient := fake.NewClientBuilder(). + WithScheme(orbTestScheme(t)). + WithIndex(&orbv1alpha1.ClusterObjectSet{}, "spec.group", func(o client.Object) []string { + return []string{o.(*orbv1alpha1.ClusterObjectSet).Spec.Group} + }). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(_ context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return errors.New("boom") + }, + }). + Build() + getter := &controllers.OrbOperatorRevisionStatesGetter{Reader: fakeClient} + + _, err := getter.GetRevisionStates(context.Background(), ext) + require.Error(t, err) + assert.Contains(t, err.Error(), "listing revisions") +} + +// rollingCOSWithPhase builds a rolling-out (not completed) revision of group +// "argocd" carrying a single observed phase. +func rollingCOSWithPhase(phase orbv1alpha1.ObservedPhase) *orbv1alpha1.ClusterObjectSet { + cos := cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, false, + bundleAnnotations("pkg.v1", "1.0.0", "pkg", "ref")) + cos.Status.ObservedPhases = []orbv1alpha1.ObservedPhase{phase} + return cos +} + +// orbCOD builds a ClusterObjectDeployment with a single Progressing condition. +func orbCOD(name, reason string, status metav1.ConditionStatus) *orbv1alpha1.ClusterObjectDeployment { + return &orbv1alpha1.ClusterObjectDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: orbv1alpha1.ClusterObjectDeploymentStatus{ + Conditions: []metav1.Condition{{ + Type: orbv1alpha1.ConditionTypeProgressing, + Status: status, + Reason: reason, + Message: reason, + LastTransitionTime: metav1.Now(), + }}, + }, + } +} + +func requireProgressing(t *testing.T, rm *controllers.RevisionMetadata) *metav1.Condition { + t.Helper() + require.NotNil(t, rm) + c := apimeta.FindStatusCondition(rm.Conditions, ocv1.ClusterObjectSetTypeProgressing) + require.NotNil(t, c, "expected a Progressing condition") + return c +} + +func TestOrbGetRevisionStates_CompletedReportsSucceeded(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + getter := newOrbGetter(t, + cosRevision("argocd-1", "argocd", 1, orbv1alpha1.LifecycleStateActive, true, + bundleAnnotations("argocd-operator.v0.13.0", "0.13.0", "argocd-operator", "ref")), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + cond := requireProgressing(t, rs.Installed) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ReasonSucceeded, cond.Reason) +} + +func TestOrbGetRevisionStates_HealthyRolloutReportsRollingOut(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // All objects synced, assertions/probes still pending: WaitingForAssertions. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusWaitingForAssertions, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 1, Synced: 1, Available: 0}, + }), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ReasonRollingOut, cond.Reason) +} + +func TestOrbGetRevisionStates_InvalidPhaseReportsRetrying(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // The recover.feature collision: orb's preflight dry-run rejects the + // immutable selector, marking the phase Invalid with synced<total. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusInvalid, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 1, Synced: 0}, + ObjectDetails: []orbv1alpha1.ObjectStatus{{ + Kind: "Deployment", Name: "test-operator", Messages: []string{"spec.selector: field is immutable"}, + }}, + }), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ClusterObjectSetReasonRetrying, cond.Reason) + assert.Contains(t, cond.Message, "immutable") +} + +func TestOrbGetRevisionStates_UnsyncedObjectReportsRetrying(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // Not Invalid, but an object cannot be synced and carries a failure message. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusReconciling, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 2, Synced: 1}, + ObjectDetails: []orbv1alpha1.ObjectStatus{{ + Kind: "Deployment", Name: "x", Messages: []string{"apply error"}, + }}, + }), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ClusterObjectSetReasonRetrying, cond.Reason) +} + +func TestOrbGetRevisionStates_ReconcilingWithoutErrorReportsRollingOut(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // Mid-apply: synced<total but no object-level failure details -> healthy. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusReconciling, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 2, Synced: 1}, + }), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ReasonRollingOut, cond.Reason) +} + +func TestOrbGetRevisionStates_ProgressDeadlineExceededReportsFalse(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // Deadline wins over a blocked phase. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusInvalid, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 1, Synced: 0}, + ObjectDetails: []orbv1alpha1.ObjectStatus{{ + Kind: "Deployment", Name: "x", Messages: []string{"boom"}, + }}, + }), + orbCOD("argocd", orbv1alpha1.ReasonProgressDeadlineExceeded, metav1.ConditionFalse), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, ocv1.ReasonProgressDeadlineExceeded, cond.Reason) +} + +func TestOrbGetRevisionStates_CODErrorReasonReportsRetrying(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + // No blocked phase, but the COD reports a reconcile error. + getter := newOrbGetter(t, + rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusReconciling, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 1, Synced: 1}, + }), + orbCOD("argocd", orbv1alpha1.ReasonReconcileError, metav1.ConditionTrue), + ) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + cond := requireProgressing(t, rs.RollingOut[0]) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, ocv1.ClusterObjectSetReasonRetrying, cond.Reason) +} + +func TestOrbGetRevisionStates_AvailablePassesThrough(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + cos := rollingCOSWithPhase(orbv1alpha1.ObservedPhase{ + Name: "deploy", + Status: orbv1alpha1.PhaseStatusReconciling, + ObjectCounts: orbv1alpha1.ObjectCounts{Total: 1, Synced: 1}, + }) + cos.Status.Conditions = []metav1.Condition{{ + Type: orbv1alpha1.ConditionTypeAvailable, + Status: metav1.ConditionFalse, + Reason: orbv1alpha1.ReasonUnavailable, + Message: "phases not yet complete", + LastTransitionTime: metav1.Now(), + }} + getter := newOrbGetter(t, cos) + + rs, err := getter.GetRevisionStates(context.Background(), ext) + require.NoError(t, err) + require.Len(t, rs.RollingOut, 1) + avail := apimeta.FindStatusCondition(rs.RollingOut[0].Conditions, ocv1.ClusterObjectSetTypeAvailable) + require.NotNil(t, avail) + assert.Equal(t, metav1.ConditionFalse, avail.Status) + assert.Equal(t, orbv1alpha1.ReasonUnavailable, avail.Reason) +} From d5f7c072810f31ff06363e892d8c462a84214e7c Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 17:24:28 -0400 Subject: [PATCH 22/26] :seedling: Mark orb-operator-revision-states-getter spec done --- .../2026-08-13-orb-operator-revision-states-getter/README.md | 2 +- .../2026-08-13-orb-operator-revision-states-getter/plan.md | 0 .../requirements.md | 0 .../verification.md | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename specs/{ => closed}/2026-08-13-orb-operator-revision-states-getter/README.md (99%) rename specs/{ => closed}/2026-08-13-orb-operator-revision-states-getter/plan.md (100%) rename specs/{ => closed}/2026-08-13-orb-operator-revision-states-getter/requirements.md (100%) rename specs/{ => closed}/2026-08-13-orb-operator-revision-states-getter/verification.md (100%) diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/README.md b/specs/closed/2026-08-13-orb-operator-revision-states-getter/README.md similarity index 99% rename from specs/2026-08-13-orb-operator-revision-states-getter/README.md rename to specs/closed/2026-08-13-orb-operator-revision-states-getter/README.md index cf489b3a2d..6f2e125392 100644 --- a/specs/2026-08-13-orb-operator-revision-states-getter/README.md +++ b/specs/closed/2026-08-13-orb-operator-revision-states-getter/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator RevisionStatesGetter diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/plan.md b/specs/closed/2026-08-13-orb-operator-revision-states-getter/plan.md similarity index 100% rename from specs/2026-08-13-orb-operator-revision-states-getter/plan.md rename to specs/closed/2026-08-13-orb-operator-revision-states-getter/plan.md diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/requirements.md b/specs/closed/2026-08-13-orb-operator-revision-states-getter/requirements.md similarity index 100% rename from specs/2026-08-13-orb-operator-revision-states-getter/requirements.md rename to specs/closed/2026-08-13-orb-operator-revision-states-getter/requirements.md diff --git a/specs/2026-08-13-orb-operator-revision-states-getter/verification.md b/specs/closed/2026-08-13-orb-operator-revision-states-getter/verification.md similarity index 100% rename from specs/2026-08-13-orb-operator-revision-states-getter/verification.md rename to specs/closed/2026-08-13-orb-operator-revision-states-getter/verification.md From 37393b03e0433ab305a7bdd4afb613a7c4637f76 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 17:58:49 -0400 Subject: [PATCH 23/26] planning: orb-operator helm migration spec --- .../README.md | 63 +++++++++++++++++++ .../plan.md | 31 +++++++++ .../requirements.md | 33 ++++++++++ .../verification.md | 30 +++++++++ 4 files changed, 157 insertions(+) create mode 100644 specs/2026-08-13-orb-operator-helm-migration/README.md create mode 100644 specs/2026-08-13-orb-operator-helm-migration/plan.md create mode 100644 specs/2026-08-13-orb-operator-helm-migration/requirements.md create mode 100644 specs/2026-08-13-orb-operator-helm-migration/verification.md diff --git a/specs/2026-08-13-orb-operator-helm-migration/README.md b/specs/2026-08-13-orb-operator-helm-migration/README.md new file mode 100644 index 0000000000..c8848e1f54 --- /dev/null +++ b/specs/2026-08-13-orb-operator-helm-migration/README.md @@ -0,0 +1,63 @@ +--- +status: in-progress +--- +# orb-operator Helm Storage Migration + +## Summary + +Enable ClusterExtensions installed under the legacy Helm runtime to move to the orb-operator runtime without an uninstall/reinstall. A new migration reconcile step generates a first `ClusterObjectSet` (COS) revision directly from the deployed Helm release, with `collisionProtection: None`, so the orb COS controller **adopts** the existing Helm-managed objects. Once that revision completes, the normal apply path creates the `ClusterObjectDeployment` (COD) with `collisionProtection: Prevent`, and the orb COD controller stamps a second revision that takes over the adopted objects via orb's sibling handoff. This mirrors the role `BoxcutterStorageMigrator` plays for the Boxcutter runtime. + +## Design + +### Why adoption needs a hand-built first COS + +orb's COD controller keeps every COS equal to its COD template: on a template-hash match it runs `ensureFieldOwnership`, which force-applies the COD template onto the COS; on a mismatch it stamps a new revision. `collisionProtection` is part of the template hash. Consequences: + +- You **cannot** hold a `None` COS under a `Prevent` COD - the controller would either force the COS back to `Prevent` (hash match) or stamp a competing `Prevent` revision (hash mismatch). Either breaks adoption of still-unowned Helm objects. +- `None` is only needed for the **first** adoption of the externally-owned (Helm) objects. Once a revision owns them, a later revision takes them over via orb's group sibling handoff (not gated by collision protection), so `Prevent` is safe for every subsequent revision. + +Therefore the migrator creates the first COS **directly** (standalone, no COD yet) with `None`, lets it adopt and reach `completedAt`, and only then allows the COD (`Prevent`) to be created. + +### The deployed Helm release is the migration trigger + +Rather than keying idempotency off "does a COD/COS exist," the migrator keys off the **deployed Helm release**: its presence means migration has not yet completed, its absence means there is nothing to migrate (fresh install, or migration already finished). Crucially, the Helm release secret is **not deleted until the adopting COS (revision 1) reaches `completedAt`**. This makes migration resumable: if the controller restarts mid-adoption, the release is still present, so the migration step keeps running - re-ensuring revision 1 and holding the pipeline gated - until adoption genuinely completes. + +### Sequence + +1. **Skip when nothing to migrate**: if there is no deployed Helm release, return and let the normal pipeline run. +2. **Phase 1 - adopt.** When a deployed Helm release is found, build the desired adopting COS from the release and reconcile it into a revision: + - `spec.group = ext.Name`, `lifecycleState: Active`, `spec.collisionProtection: None` + - **all objects in a single phase, with no assertions/availability probes** - this mimics Helm's semantics (apply everything at once, no ordering, no readiness gating) in orb syntax. Since the objects are already running (Helm deployed them), the goal is fast, low-risk ownership takeover, not a fresh phased rollout: no probes means the revision reaches `completedAt` as soon as the objects are synced rather than waiting on per-object conditions that a phased/asserted layout (like the COD generator's) could stall on. Objects are still sanitized (status stripped, metadata trimmed) the same way. If a release exceeds the orb per-phase cap (50 objects), split into additional no-assertion phases purely to satisfy the limit - not for ordering. + - **externalize** the built revision through the externalizer: pack large object sets into ClusterObjectSlices and rewrite phases to `objectRef`s, so an oversized Helm release does not exceed etcd limits (same treatment the COD apply path gets) + - owner labels (`objectLabels`) and bundle-metadata annotations (bundle name/version, package, reference) so the `OrbOperatorRevisionStatesGetter` reports it correctly + - a **non-controller** ownerReference to the ClusterExtension (for GC during the pre-COD window). It must NOT be a controller ref: orb's `adoptOrphans` only adopts COSs that have no controller owner (`GetControllerOf(cos) == nil`), so a non-controller ref lets orb later set the COD as controller. + - **no `LabelTemplateHash`** (leave it unset). The label only matters for COD-owned revisions; an unset value guarantees a mismatch against the COD's `Prevent` template hash, ensuring the COD controller stamps the `Prevent` revision rather than running `ensureFieldOwnership` on the adopting revision. + - **revision numbering (COS spec is immutable).** Compare the desired COS against the latest existing revision for the group using `equality.Semantic.DeepDerivative(desiredSpec, existingSpec)` (same pattern as the applier's `alreadyApplied`): if the desired is already reflected, reuse it (idempotent no-op); otherwise create a **new** revision with the next revision number rather than updating in place. First migration produces `<ext.Name>-1`; a changed desired spec produces `<ext.Name>-2`, etc. (orb continues numbering from the highest existing revision.) +3. **Gate the pipeline.** While the adopting (`None`) revision exists but `status.completedAt` is nil, the migration step returns a non-nil `ctrl.Result` (requeue) so the pipeline stops before `ApplyBundle` - preventing the COD from being created while adoption is still in progress (which would stamp a premature `Prevent` revision that collides with the unowned objects). +4. **Release the trigger.** Once the adopting revision has `completedAt`, adoption has succeeded: delete the Helm release **storage** for the extension. This deletes only the Helm bookkeeping secrets (`helm.sh/release.v1`), **not** a Helm uninstall - the managed resources are now orb-adopted and must not be torn down. Delete the history secrets **oldest to newest** (ascending release version): the newest deployed release - the one the migrator keys off - is removed last, so a partial-delete failure leaves it present and the next reconcile resumes against the same release rather than "rewinding" to an older one. From here the migration step becomes a no-op on subsequent reconciles (no deployed release), and the normal pipeline runs. (Adopting-revision cleanup is independent of this deletion - orb handles it automatically; see step 6.) +5. **Phase 2 - hand off.** With the gate lifted, `ApplyBundle` -> `OrbOperator.Apply` -> codgen produces the COD with `collisionProtection: Prevent`. The orb COD controller sees no owned COS matching its (Prevent) template hash and stamps the next revision (`Prevent`), which takes over the adopted objects from the adopting revision via sibling handoff, then reaches `completedAt` itself. +6. **Cleanup of the adopting revision is automatic.** Once the COD exists, orb's COD controller `adoptOrphans` claims the standalone adopting revision (it has no controller owner) by setting the COD as its controller. `archiveSuperseded` then archives it - but only after the `Prevent` revision `IsAvailable`, i.e. only after the takeover has succeeded (orb enforces the safe ordering for us) - and `pruneArchived` removes it per the COD's `revisionHistoryLimit`. The migrator does no manual cleanup. + +### Components + +- **`OrbStorageMigrator`** (in `internal/operator-controller/applier/`): holds a Helm `ActionClientGetter`, a COS-from-Helm generator, a client, `Scheme`, and `FieldOwner`. Looks up the most-recent **deployed** Helm release (falling back through history if the latest is not `deployed`, as the Boxcutter migrator does), reconciles the adopting revision (create/reuse with next-revision-number semantics, externalizing large releases), reports whether adoption has completed, and deletes the Helm release storage once it has. It does **not** clean up the adopting revision - orb does that automatically (see step 6). +- **COS-from-Helm generator**: a method (parallel to `SimpleRevisionGenerator.GenerateRevisionFromHelmRelease`) that builds an `orbac.ClusterObjectSetApplyConfiguration` from a Helm release - splitting the manifest into objects, sanitizing them, and placing them all in a **single, assertion-free phase** (chunked at the 50-object cap only when necessary), with `None` collision protection plus bundle annotations. It deliberately does **not** reuse the COD generator's kind-based phase assignment or per-GVK assertions. +- **Externalizer reuse**: add an `ExternalizeCOS(cos) (cos, []cosl, error)` entry point alongside the existing `ExternalizeCOD(cod)`, refactoring the shared logic into a private phases-level core (size probe + `pack(phases)` + replace-inline-with-refs + label/ownerRef propagation). `slicePacker.pack` already operates on `[]PhaseApplyConfiguration`, and `ClusterObjectSetSpec` embeds the same `Phases`, so the refactor is mechanical. The migrator applies the returned ClusterObjectSlices before the revision. +- **Reconcile step**: a dedicated orb step (do **not** overload the shared `StorageMigrator`/`MigrateStorage`, which is synchronous for Boxcutter). The orb migrator's `Migrate` returns `(*ctrl.Result, error)` so the step can requeue while adoption is in progress. It is wired as the first step after `ValidateClusterExtension` - **before `RetrieveRevisionStates`, `ResolveBundle`, `UnpackBundle`, and `ApplyBundle`**, i.e. before resolution. Adoption of an already-running workload needs no catalog access, so ordering it ahead of resolution lets migration proceed even when the catalog is unavailable, and its gating avoids doing resolve/unpack work while adoption is still in progress. +- **Wiring** in `orbOperatorReconcilerConfigurator`: construct a Helm `ActionClientGetter` (currently only the Boxcutter and Helm configurators build one) and the `OrbStorageMigrator`, and prepend the migration step. + +### Interaction with existing pieces + +- The `OrbOperatorRevisionStatesGetter` already lists COS by `spec.group` and classifies installed via `completedAt` - so during phase 1 it reports the adopting revision as installed, and after handoff it reports the `Prevent` revision (highest revision wins). No getter changes needed. +- The externalizer already propagates owner references and labels onto the slices; reusing it for the migrator's revision requires the `ExternalizeCOS` generalization described in Components / decision 2. + +## Resolved design decisions + +Each of these was verified against `github.com/joelanford/orb-operator@v0.0.3` controller source: + +1. **Adopting-revision cleanup: automatic, no manual step.** orb's COD controller `adoptOrphans` claims any COS in the group that has no controller owner, then `archiveSuperseded` archives non-latest owned revisions once the latest `IsAvailable`, and `pruneArchived` deletes them per `revisionHistoryLimit`. So the migrator creates the adopting revision with a **non-controller** CE ownerReference (leaving it adoptable), and orb handles takeover-then-archive-then-prune in the correct safe order. This also lets the Helm release be deleted at the adopting revision's `completedAt` without any cleanup-timing concern. +2. **Externalizer generalization: add `ExternalizeCOS`.** Refactor a shared phases-level core out of `ExternalizeCOD(cod)` and add `ExternalizeCOS(cos)`; `pack` already works on `[]PhaseApplyConfiguration` and COS embeds the same `Phases`. +3. **Reuse-vs-increment comparison: `equality.Semantic.DeepDerivative`** of the desired COS spec against the latest existing revision's spec (mirrors the applier's `alreadyApplied` gating), so the check is stable and does not spuriously increment. +4. **No `LabelTemplateHash` on the adopting revision.** Unset guarantees a mismatch with the COD's `Prevent` template hash, so the COD controller stamps the `Prevent` revision (rather than `ensureFieldOwnership`-ing the adopting revision back to `Prevent`). +5. **No finalizer interaction.** The orb content-manager-cache finalizer is a no-op today, and Helm-storage deletion is a reconcile-time action (not finalizer-driven), so there is nothing to coordinate. +6. **Dedicated orb step, shared interface untouched.** Keep Boxcutter's synchronous `StorageMigrator`/`MigrateStorage` as-is; the orb migrator's `Migrate` returns `(*ctrl.Result, error)` and is wrapped by an orb-specific step that can requeue during adoption. diff --git a/specs/2026-08-13-orb-operator-helm-migration/plan.md b/specs/2026-08-13-orb-operator-helm-migration/plan.md new file mode 100644 index 0000000000..8713655f6a --- /dev/null +++ b/specs/2026-08-13-orb-operator-helm-migration/plan.md @@ -0,0 +1,31 @@ +# Implementation Plan + +0. Generalize the externalizer (`internal/operator-controller/applier/orb/`): + - Extract a shared phases-level core (or add a COS entry point) so both the COD path and the migrator's COS can externalize; keep the existing `ExternalizeCOD(cod)` behavior intact + +1. COS-from-Helm generator (`internal/operator-controller/applier/`): + - Add a method that builds an `orbac.ClusterObjectSetApplyConfiguration` from a `*release.Release` and `ext`, parallel to `SimpleRevisionGenerator.GenerateRevisionFromHelmRelease` + - Reuse `splitManifestDocuments` and object sanitization, but place all objects in a **single assertion-free phase** (chunk at 50 only when necessary); do NOT reuse the COD generator's kind-based phase assignment or per-GVK assertions + - Set `spec.group = ext.Name`, `lifecycleState: Active`, `collisionProtection: None` (revision number set by the migrator) + - Set owner labels, bundle-metadata annotations, and a **non-controller** ownerReference to the ClusterExtension; do not set `LabelTemplateHash` + +2. `OrbStorageMigrator` (`internal/operator-controller/applier/`): + - Fields: `ActionClientGetter`, COS generator, client, `Scheme`, `FieldOwner` + - `Migrate`: find deployed Helm release (with history fallback); if none, no-op + - Build the desired adopting COS; externalize it (create slices first) + - List existing revisions for the group; if the desired spec is a `equality.Semantic.DeepDerivative` of the latest, reuse it; otherwise assign the next revision number and create it (never update in place) + - Report adoption state (adopting revision `completedAt`) so the step can gate/requeue + - Once `completedAt`, delete the Helm release storage: list the release's `helm.sh/release.v1` secrets and delete them oldest-to-newest (ascending version); do not run a Helm uninstall + - No manual adopting-revision cleanup: orb's `adoptOrphans`/`archiveSuperseded`/`pruneArchived` take over, archive (after the `Prevent` revision is available), and prune it + +3. Reconcile step (dedicated orb step; leave the shared `StorageMigrator`/`MigrateStorage` untouched): + - Orb migrator `Migrate` returns `(*ctrl.Result, error)`; the step returns a requeue result during phase 1 and nil afterward + - Wire it as the first step after `ValidateClusterExtension` (before `RetrieveRevisionStates` and before resolution/`ResolveBundle`) + +4. Wiring (`cmd/operator-controller/main.go`): + - Construct a Helm `ActionClientGetter` in `orbOperatorReconcilerConfigurator` (mirror the Boxcutter/Helm configurators) + - Construct `OrbStorageMigrator` and prepend the migration step as the first step after `ValidateClusterExtension` (before resolution) + +5. Tests: + - Unit tests for the generator and migrator covering the acceptance criteria (fake client + fake `ActionClientGetter`; seed COS with/without `completedAt`) + - An upgrade/regression e2e (under `test/`) proving a Helm-backed CE migrates to an orb `Prevent` revision with objects preserved diff --git a/specs/2026-08-13-orb-operator-helm-migration/requirements.md b/specs/2026-08-13-orb-operator-helm-migration/requirements.md new file mode 100644 index 0000000000..3b788e7c13 --- /dev/null +++ b/specs/2026-08-13-orb-operator-helm-migration/requirements.md @@ -0,0 +1,33 @@ +# Requirements + +- Add an `OrbStorageMigrator` in `internal/operator-controller/applier/` with a Helm `ActionClientGetter`, a COS-from-Helm generator, a client, `Scheme`, and `FieldOwner` +- The deployed Helm release is the migration trigger: when absent, migration is a no-op; when present, migration is in progress +- Look up the most-recent **deployed** Helm release (fall back through history when the latest release is not `deployed`), matching `BoxcutterStorageMigrator` behavior +- Provide a COS-from-Helm generator method (parallel to `GenerateRevisionFromHelmRelease`) that builds an `orbac.ClusterObjectSetApplyConfiguration` from a Helm release: sanitized objects placed in a **single, assertion-free phase** (chunked at the 50-object cap only when necessary; no kind-based ordering, no per-GVK assertions - mimicking Helm apply semantics), `spec.group = ext.Name`, `lifecycleState: Active`, `collisionProtection: None`, owner labels, bundle-metadata annotations, a **non-controller** ownerReference to the ClusterExtension (so orb's `adoptOrphans` can later set the COD as controller), and no `LabelTemplateHash` +- Provide an `ExternalizeCOS` entry point in the externalizer (shared phases-level core with `ExternalizeCOD(cod)`) for externalizing the adopting revision +- Externalize the built revision through the (generalized) externalizer: pack large object sets into ClusterObjectSlices with `objectRef`s, creating the slices before the revision, reusing the same externalizer used by the COD apply path +- Revision numbering respects COS spec immutability: compare the desired COS against the latest existing revision for the group via `equality.Semantic.DeepDerivative`; reuse it when already reflected, otherwise create a new revision with the next revision number (never update an existing revision in place) +- Phase 1: create the adopting (`None`) revision when the desired spec has no equivalent existing revision +- Gate the pipeline (return a non-nil `ctrl.Result` requeue) while revision 1 exists but `status.completedAt` is nil, so `ApplyBundle` does not create the COD during adoption +- Delete the Helm release storage only after the adopting revision reaches `completedAt`; delete only the Helm bookkeeping secrets (never a Helm uninstall, which would tear down the now-adopted resources) +- When multiple release-history secrets exist, delete them oldest-to-newest (ascending version) so a partial-delete failure leaves the newest deployed release present (no "rewind" on the next reconcile) +- Phase 2: after the gate lifts, the normal apply path creates the COD with `collisionProtection: Prevent` (no change to codgen's default), triggering orb to stamp revision 2 which takes over via sibling handoff +- Do NOT manually clean up the adopting revision: rely on orb's `adoptOrphans` -> `archiveSuperseded` (only after the `Prevent` revision `IsAvailable`) -> `pruneArchived` to take over, archive, and prune it +- Wire a Helm `ActionClientGetter` and the migration step into `orbOperatorReconcilerConfigurator`, ordered as the first step after `ValidateClusterExtension` - before `RetrieveRevisionStates` and, critically, before resolution (`ResolveBundle`) so adoption needs no catalog access and gates ahead of resolve/unpack +- Migration is idempotent and resumable across controller restarts + +## Acceptance Criteria + +- Unit test: no deployed Helm release -> migrator is a no-op (pipeline proceeds) +- Unit test: deployed release found, no COS -> revision 1 created with `None`, `spec.group = ext.Name`, revision 1, bundle annotations, owner labels, and CE ownerReference +- Unit test: desired COS equivalent to the latest existing revision -> no new revision created (idempotent) +- Unit test: desired COS differs from the latest existing revision -> a new revision is created with the next revision number (existing revision left unchanged) +- Unit test: a Helm release large enough to exceed the size threshold -> revision phases use `objectRef`s and ClusterObjectSlices are produced (and created before the revision) +- Unit test: latest release not `deployed` -> falls back to the most-recent `deployed` release in history; none found -> no-op +- Unit test: revision 1 exists but `completedAt` nil -> step returns a requeue result (pipeline gated) and the Helm release is NOT deleted +- Unit test: adopting revision has `completedAt` -> Helm release storage is deleted (bookkeeping secrets only) and the step no longer gates +- Unit test: multiple release-history secrets -> deleted oldest-to-newest; a simulated mid-delete failure leaves the newest deployed release present +- Unit test: COS-from-Helm generator places all objects in a single assertion-free phase (no per-GVK assertions, no kind-based ordering); a >50-object release splits into additional no-assertion phases only to satisfy the cap +- Unit test: the migration step is ordered before `ResolveBundle` in the orb pipeline +- Unit test: the adopting revision is created with a non-controller CE ownerReference and no `LabelTemplateHash` (so orb can adopt it and will stamp the `Prevent` revision) +- Unit/e2e: a Helm-backed ClusterExtension ends up managed by an orb COD with a `Prevent` revision, its objects preserved (not recreated), and reported installed by `OrbOperatorRevisionStatesGetter` diff --git a/specs/2026-08-13-orb-operator-helm-migration/verification.md b/specs/2026-08-13-orb-operator-helm-migration/verification.md new file mode 100644 index 0000000000..ae5c40b12b --- /dev/null +++ b/specs/2026-08-13-orb-operator-helm-migration/verification.md @@ -0,0 +1,30 @@ +# Verification + +## Implementation Correctness + +- [ ] Deployed Helm release is the migration trigger; absence -> no-op, presence -> migration in progress +- [ ] Most-recent `deployed` release is selected, with history fallback when the latest is not `deployed` +- [ ] COS-from-Helm generator produces a COS with `None`, `spec.group = ext.Name`, `Active`, owner labels, bundle annotations, a **non-controller** CE ownerReference, and no `LabelTemplateHash` +- [ ] The adopting COS places all objects in a single assertion-free phase (no per-GVK assertions, no kind-based ordering; chunked at 50 only when the object count requires it) +- [ ] The migration step is ordered before resolution (`ResolveBundle`) - it is the first step after `ValidateClusterExtension` +- [ ] Revision reuse-vs-increment uses `equality.Semantic.DeepDerivative` against the latest existing revision +- [ ] The adopting revision is externalized when large: phases use `objectRef`s and ClusterObjectSlices are created before the revision +- [ ] Revision numbering respects immutability: equivalent desired spec reuses the latest revision; a differing desired spec creates the next revision number without modifying the existing one +- [ ] The step returns a requeue `ctrl.Result` while the adopting revision's `completedAt` is nil (pipeline gated before `ApplyBundle`) +- [ ] The Helm release storage (bookkeeping secrets only, not an uninstall) is deleted only after the adopting revision reaches `completedAt` +- [ ] Release-history secrets are deleted oldest-to-newest, so a partial-delete failure leaves the newest deployed release present (no rewind) +- [ ] After the gate lifts, the COD is created with `Prevent` (codgen default unchanged) and orb stamps the `Prevent` revision that takes over via sibling handoff +- [ ] The migrator performs no manual adopting-revision cleanup (relies on orb's adopt/archive/prune); the non-controller CE ownerReference leaves the revision adoptable +- [ ] Migration is idempotent/resumable across controller restarts +- [ ] Helm `ActionClientGetter` and the migration step are wired into `orbOperatorReconcilerConfigurator` as the first step after `ValidateClusterExtension` (before resolution) +- [ ] All unit tests pass; e2e migration test passes + +## Project Conventions + +- [ ] Code follows Go style and passes `make lint` +- [ ] No `//nolint` comments added +- [ ] Reuses shared helpers (`splitManifestDocuments`, sanitization, phase building) rather than duplicating codgen logic +- [ ] Uses the `labels.*` key constants for annotations/labels +- [ ] Mirrors `BoxcutterStorageMigrator` structure where applicable (per specs/mission.md: simple, predictable, do not fight Kubernetes) +- [ ] Uses orb-operator and Helm types from tech-stack (`github.com/joelanford/orb-operator`, `helm.sh/helm/v3`, helm-operator-plugins) +- [ ] `make test-unit` passes; `make verify` shows no unintended generated-code changes From 6ea86c620abbae1bcc93edf3f71a7ffd058a1e03 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 21:12:11 -0400 Subject: [PATCH 24/26] :sparkles: Implement orb-operator Helm storage migration --- cmd/operator-controller/main.go | 35 ++ .../applier/orb/externalizer.go | 142 +++-- .../applier/orb/externalizer_test.go | 67 ++ .../applier/orbmigrator.go | 454 ++++++++++++++ .../applier/orbmigrator_test.go | 572 ++++++++++++++++++ .../orboperator_reconcile_steps.go | 29 + .../orboperator_reconcile_steps_test.go | 47 ++ .../testutil/mock/applier/mock_applier.go | 44 +- internal/testutil/mock/generate.go | 2 +- 9 files changed, 1344 insertions(+), 48 deletions(-) create mode 100644 internal/operator-controller/applier/orbmigrator.go create mode 100644 internal/operator-controller/applier/orbmigrator_test.go diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 83be397bd6..3f2244f161 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -760,6 +760,30 @@ func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers. return fmt.Errorf("unable to create field indexer for ClusterObjectSet spec.group: %w", err) } + // Build a Helm ActionClientGetter so the storage migrator can read (and, + // once adoption completes, delete the bookkeeping secrets of) the deployed + // Helm release. This mirrors the Boxcutter and Helm configurators. + coreClient, err := corev1client.NewForConfig(c.mgr.GetConfig()) + if err != nil { + return fmt.Errorf("unable to create core client: %w", err) + } + cfgGetter, err := helmclient.NewActionConfigGetter(c.mgr.GetConfig(), c.mgr.GetRESTMapper(), + helmclient.StorageDriverMapper(action.ChunkedStorageDriverMapper(coreClient, c.mgr.GetAPIReader(), cfg.systemNamespace)), + helmclient.ClientNamespaceMapper(func(obj client.Object) (string, error) { + ext := obj.(*ocv1.ClusterExtension) + return ext.Spec.Namespace, nil + }), + ) + if err != nil { + return fmt.Errorf("unable to create helm action config getter: %w", err) + } + acg, err := action.NewWrappedActionClientGetter(cfgGetter, + helmclient.WithFailureRollbacks(false), + ) + if err != nil { + return fmt.Errorf("unable to create helm action client getter: %w", err) + } + fieldOwner := fmt.Sprintf("%s/clusterextension-controller", fieldOwnerPrefix) codGen := &applier.RegistryV1CODGenerator{ ManifestProvider: c.regv1ManifestProvider, @@ -772,12 +796,23 @@ func (c *orbOperatorReconcilerConfigurator) Configure(ceReconciler *controllers. Preflights: c.preflights, FieldOwner: fieldOwner, } + storageMigrator := &applier.OrbStorageMigrator{ + ActionClientGetter: acg, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: c.mgr.GetClient(), + Scheme: c.mgr.GetScheme(), + FieldOwner: fieldOwner, + } revisionStatesGetter := &controllers.OrbOperatorRevisionStatesGetter{Reader: c.mgr.GetClient()} ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{ controllers.HandleFinalizers(c.finalizers), controllers.ValidateClusterExtension( controllers.ServiceAccountDeprecationWarning(), ), + // Migration is ordered ahead of resolution: adopting an already-running + // Helm workload needs no catalog access, and gating here avoids + // resolve/unpack work while adoption is still in progress. + controllers.MigrateOrbStorage(storageMigrator), controllers.RetrieveRevisionStates(revisionStatesGetter), controllers.ResolveBundle(c.resolver, c.mgr.GetClient()), controllers.UnpackBundle(c.imagePuller, c.imageCache), diff --git a/internal/operator-controller/applier/orb/externalizer.go b/internal/operator-controller/applier/orb/externalizer.go index 7dc08574a6..6ee5ea94dd 100644 --- a/internal/operator-controller/applier/orb/externalizer.go +++ b/internal/operator-controller/applier/orb/externalizer.go @@ -37,67 +37,122 @@ const ( func ExternalizeCOD( cod *orbac.ClusterObjectDeploymentApplyConfiguration, ) (*orbac.ClusterObjectDeploymentApplyConfiguration, []*orbac.ClusterObjectSliceApplyConfiguration, error) { - needed, err := shouldExternalize(cod) + name := "" + if n := cod.GetName(); n != nil { + name = *n + } + var lbls map[string]string + var ownerRefs []*metav1ac.OwnerReferenceApplyConfiguration + if cod.ObjectMetaApplyConfiguration != nil { + lbls = cod.Labels + ownerRefs = ownerReferencePointers(cod.OwnerReferences) + } + + result, err := externalizePhases(cod, name, cod.Spec.Template.Spec.Phases, lbls, ownerRefs) if err != nil { return nil, nil, err } - if !needed { + if result == nil { return cod, nil, nil } - codName := "" - if n := cod.GetName(); n != nil { - codName = *n + replaceInlineWithRefs(cod.Spec.Template.Spec.Phases, result) + return cod, result.slices, nil +} + +// ExternalizeCOS is the ClusterObjectSet analogue of ExternalizeCOD. It packs a +// COS apply configuration's inline phase objects into ClusterObjectSlice apply +// configurations and rewrites the phases to objectRef entries when the COS would +// exceed the safe etcd size threshold. A small COS is returned unchanged with a +// nil slice list. Produced slices inherit the COS's labels and owner references, +// matching the COD behavior. +func ExternalizeCOS( + cos *orbac.ClusterObjectSetApplyConfiguration, +) (*orbac.ClusterObjectSetApplyConfiguration, []*orbac.ClusterObjectSliceApplyConfiguration, error) { + name := "" + if n := cos.GetName(); n != nil { + name = *n + } + var lbls map[string]string + var ownerRefs []*metav1ac.OwnerReferenceApplyConfiguration + if cos.ObjectMetaApplyConfiguration != nil { + lbls = cos.Labels + ownerRefs = ownerReferencePointers(cos.OwnerReferences) + } + + var phases []orbac.PhaseApplyConfiguration + if cos.Spec != nil { + phases = cos.Spec.Phases } - packer := &slicePacker{codName: codName} - result, err := packer.pack(cod.Spec.Template.Spec.Phases) + result, err := externalizePhases(cos, name, phases, lbls, ownerRefs) if err != nil { return nil, nil, err } + if result == nil { + return cos, nil, nil + } - // Propagate the COD's labels (owner labels, etc.) and owner references onto - // each slice so the slices are discoverable by the same selector used to - // find the COD, and are garbage-collected / watched alongside the COD's + replaceInlineWithRefs(cos.Spec.Phases, result) + return cos, result.slices, nil +} + +// externalizePhases is the shared core of ExternalizeCOD and ExternalizeCOS. It +// probes the marshaled size of obj; when it fits, it returns a nil result to +// signal "no externalization needed". Otherwise it packs the phases into slices +// keyed off name and propagates the given labels and owner references onto each +// slice. +func externalizePhases( + obj any, + name string, + phases []orbac.PhaseApplyConfiguration, + lbls map[string]string, + ownerRefs []*metav1ac.OwnerReferenceApplyConfiguration, +) (*slicePackResult, error) { + needed, err := shouldExternalize(obj) + if err != nil { + return nil, err + } + if !needed { + return nil, nil + } + + packer := &slicePacker{codName: name} + result, err := packer.pack(phases) + if err != nil { + return nil, err + } + + // Propagate the object's labels (owner labels, etc.) and owner references + // onto each slice so the slices are discoverable by the same selector used + // to find the owner, and are garbage-collected / watched alongside the // owner (the ClusterExtension). - ownerRefs := codOwnerReferences(cod) for _, slice := range result.slices { - slice.WithLabels(codLabels(cod)) + if len(lbls) > 0 { + slice.WithLabels(lbls) + } if len(ownerRefs) > 0 { slice.WithOwnerReferences(ownerRefs...) } } - - replaceInlineWithRefs(cod, result) - return cod, result.slices, nil -} - -// codLabels returns the COD's metadata labels, or nil if none are set. -func codLabels(cod *orbac.ClusterObjectDeploymentApplyConfiguration) map[string]string { - if cod.ObjectMetaApplyConfiguration == nil { - return nil - } - return cod.Labels + return result, nil } -// codOwnerReferences returns pointers to the COD's owner references so they can -// be copied onto each ClusterObjectSlice. -func codOwnerReferences(cod *orbac.ClusterObjectDeploymentApplyConfiguration) []*metav1ac.OwnerReferenceApplyConfiguration { - if cod.ObjectMetaApplyConfiguration == nil { - return nil - } - refs := make([]*metav1ac.OwnerReferenceApplyConfiguration, 0, len(cod.OwnerReferences)) - for i := range cod.OwnerReferences { - ref := cod.OwnerReferences[i] - refs = append(refs, &ref) +// ownerReferencePointers returns pointers to the given owner references so they +// can be copied onto each ClusterObjectSlice. +func ownerReferencePointers(refs []metav1ac.OwnerReferenceApplyConfiguration) []*metav1ac.OwnerReferenceApplyConfiguration { + ptrs := make([]*metav1ac.OwnerReferenceApplyConfiguration, 0, len(refs)) + for i := range refs { + ref := refs[i] + ptrs = append(ptrs, &ref) } - return refs + return ptrs } -func shouldExternalize(cod *orbac.ClusterObjectDeploymentApplyConfiguration) (bool, error) { - data, err := json.Marshal(cod) +func shouldExternalize(obj any) (bool, error) { + data, err := json.Marshal(obj) if err != nil { - return false, fmt.Errorf("estimating COD size: %w", err) + return false, fmt.Errorf("estimating object size: %w", err) } return len(data) > maxDataSize, nil } @@ -209,18 +264,15 @@ func (p *slicePacker) pack(phases []orbac.PhaseApplyConfiguration) (*slicePackRe return result, nil } -func replaceInlineWithRefs(cod *orbac.ClusterObjectDeploymentApplyConfiguration, pack *slicePackResult) { - if cod == nil || cod.Spec == nil || cod.Spec.Template == nil || cod.Spec.Template.Spec == nil { - return - } - for phaseIdx := range cod.Spec.Template.Spec.Phases { - for objIdx := range cod.Spec.Template.Spec.Phases[phaseIdx].Objects { +func replaceInlineWithRefs(phases []orbac.PhaseApplyConfiguration, pack *slicePackResult) { + for phaseIdx := range phases { + for objIdx := range phases[phaseIdx].Objects { ref, ok := pack.refs[[2]int{phaseIdx, objIdx}] if !ok { continue } - cod.Spec.Template.Spec.Phases[phaseIdx].Objects[objIdx].Object = nil - cod.Spec.Template.Spec.Phases[phaseIdx].Objects[objIdx].ObjectRef = ref + phases[phaseIdx].Objects[objIdx].Object = nil + phases[phaseIdx].Objects[objIdx].ObjectRef = ref } } } diff --git a/internal/operator-controller/applier/orb/externalizer_test.go b/internal/operator-controller/applier/orb/externalizer_test.go index f9797cdaf0..c6a8dbc8e6 100644 --- a/internal/operator-controller/applier/orb/externalizer_test.go +++ b/internal/operator-controller/applier/orb/externalizer_test.go @@ -488,6 +488,73 @@ func TestExternalize_MissingIdentity(t *testing.T) { assert.Contains(t, err.Error(), "missing apiVersion or kind") } +func TestExternalizeCOS_SmallCOS_Unchanged(t *testing.T) { + cos := orbac.ClusterObjectSet("small"). + WithSpec(orbac.ClusterObjectSetSpec(). + WithGroup("small"). + WithPhases( + orbac.Phase().WithName("migrate").WithObjects( + orbac.PhaseObject().WithObject(rawObject("v1", "ConfigMap", "cm1", "ns1")), + ), + )) + + result, slices, err := ExternalizeCOS(cos) + require.NoError(t, err) + assert.Same(t, cos, result) + assert.Nil(t, slices) + assert.NotNil(t, result.Spec.Phases[0].Objects[0].Object) +} + +func TestExternalizeCOS_LargeCOS_ProducesSlices(t *testing.T) { + objects := make([]*orbac.PhaseObjectApplyConfiguration, 0, 5) + for j := range 5 { + objects = append(objects, orbac.PhaseObject().WithObject( + rawObjectWithData(fmt.Sprintf("cm-%d", j), 500*1024), + )) + } + cos := orbac.ClusterObjectSet("large"). + WithLabels(map[string]string{ + "olm.operatorframework.io/owner-name": "my-ext", + }). + WithOwnerReferences(metav1ac.OwnerReference(). + WithAPIVersion("olm.operatorframework.io/v1"). + WithKind("ClusterExtension"). + WithName("my-ext"). + WithUID("test-uid"). + WithBlockOwnerDeletion(true)). + WithSpec(orbac.ClusterObjectSetSpec(). + WithGroup("large"). + WithPhases( + orbac.Phase().WithName("migrate").WithObjects(objects...), + )) + + result, slices, err := ExternalizeCOS(cos) + require.NoError(t, err) + assert.Same(t, cos, result) + require.NotEmpty(t, slices) + + // Phases rewritten to objectRefs, inline objects cleared. + sawRef := false + for _, phase := range result.Spec.Phases { + for _, obj := range phase.Objects { + if obj.ObjectRef != nil { + sawRef = true + assert.True(t, strings.HasPrefix(*obj.ObjectRef.SliceName, "large-")) + assert.Nil(t, obj.Object) + } + } + } + assert.True(t, sawRef) + + // Labels and owner references propagated to slices; the CE owner reference + // remains non-controller. + for _, s := range slices { + assert.Equal(t, "my-ext", s.Labels["olm.operatorframework.io/owner-name"]) + require.Len(t, s.OwnerReferences, 1) + assert.Nil(t, s.OwnerReferences[0].Controller) + } +} + func TestExternalize_DeterministicNaming(t *testing.T) { makeCOD := func() *orbac.ClusterObjectDeploymentApplyConfiguration { return orbac.ClusterObjectDeployment("det-ext"). diff --git a/internal/operator-controller/applier/orbmigrator.go b/internal/operator-controller/applier/orbmigrator.go new file mode 100644 index 0000000000..27a5bc17d8 --- /dev/null +++ b/internal/operator-controller/applier/orbmigrator.go @@ -0,0 +1,454 @@ +package applier + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "time" + + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" + + helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + orb "github.com/operator-framework/operator-controller/internal/operator-controller/applier/orb" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/util/cache" +) + +// orbMigrationPhaseName is the name given to the assertion-free phase(s) of the +// adopting ClusterObjectSet built from a Helm release. +const orbMigrationPhaseName = "migrate" + +// orbAdoptionRequeueInterval is how often the migration step re-checks whether +// the adopting revision has completed while gating the pipeline. The controller +// does not watch ClusterObjectSet, so we poll for status.completedAt. +const orbAdoptionRequeueInterval = 5 * time.Second + +// OrbClusterObjectSetGenerator builds an orb ClusterObjectSet apply +// configuration from a deployed Helm release, for adoption into the orb runtime. +type OrbClusterObjectSetGenerator interface { + GenerateRevisionFromHelmRelease( + ctx context.Context, + helmRelease *release.Release, + ext *ocv1.ClusterExtension, + objectLabels map[string]string, + ) (*orbac.ClusterObjectSetApplyConfiguration, error) +} + +// SimpleOrbRevisionGenerator builds an adopting ClusterObjectSet from a Helm +// release. Unlike the COD generator, it places every object in a single +// assertion-free phase (chunked only to satisfy the per-phase object cap) and +// uses collisionProtection None, mimicking Helm's "apply everything at once, no +// ordering, no readiness gating" semantics so the revision can quickly take +// ownership of the already-running Helm-managed objects. +type SimpleOrbRevisionGenerator struct{} + +func (g *SimpleOrbRevisionGenerator) GenerateRevisionFromHelmRelease( + ctx context.Context, + helmRelease *release.Release, + ext *ocv1.ClusterExtension, + objectLabels map[string]string, +) (*orbac.ClusterObjectSetApplyConfiguration, error) { + docs := splitManifestDocuments(helmRelease.Manifest) + phaseObjects := make([]*orbac.PhaseObjectApplyConfiguration, 0, len(docs)) + for _, doc := range docs { + obj := unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(doc), &obj); err != nil { + return nil, fmt.Errorf("unmarshaling Helm manifest object: %w", err) + } + obj.SetLabels(mergeStringMaps(obj.GetLabels(), objectLabels)) + + // Memory optimization: strip large annotations. + // Note: ApplyStripAnnotationsTransform never returns an error in practice. + _ = cache.ApplyStripAnnotationsTransform(&obj) + sanitizedUnstructured(ctx, &obj) + + annotationUpdates := map[string]string{} + if v := helmRelease.Labels[labels.BundleVersionKey]; v != "" { + annotationUpdates[labels.BundleVersionKey] = v + } + if v, ok := helmRelease.Labels[labels.BundleReleaseKey]; ok { + annotationUpdates[labels.BundleReleaseKey] = v + } + if v := helmRelease.Labels[labels.PackageNameKey]; v != "" { + annotationUpdates[labels.PackageNameKey] = v + } + if len(annotationUpdates) > 0 { + obj.SetAnnotations(mergeStringMaps(obj.GetAnnotations(), annotationUpdates)) + } + + raw, err := json.Marshal(obj.Object) + if err != nil { + return nil, fmt.Errorf("marshaling Helm manifest object to JSON: %w", err) + } + // No assertions: adoption of already-running objects must not stall on + // per-object readiness conditions. + phaseObjects = append(phaseObjects, orbac.PhaseObject(). + WithObject(runtime.RawExtension{Raw: raw})) + } + + revisionAnnotations := map[string]string{ + labels.BundleNameKey: helmRelease.Labels[labels.BundleNameKey], + labels.PackageNameKey: helmRelease.Labels[labels.PackageNameKey], + labels.BundleVersionKey: helmRelease.Labels[labels.BundleVersionKey], + labels.BundleReferenceKey: helmRelease.Labels[labels.BundleReferenceKey], + } + if v, ok := helmRelease.Labels[labels.BundleReleaseKey]; ok { + revisionAnnotations[labels.BundleReleaseKey] = v + } + + spec := orbac.ClusterObjectSetSpec(). + WithGroup(ext.Name). + WithLifecycleState(orbv1alpha1.LifecycleStateActive). + // None so the revision adopts the still-Helm-owned objects. Only the + // first adoption needs None; every later (COD-owned) revision uses + // Prevent and takes over via orb's sibling handoff. + WithCollisionProtection(orbv1alpha1.CollisionProtectionNone). + WithPhases(buildOrbMigrationPhases(phaseObjects)...) + + // Owner labels let the externalizer's slices and the migrator's revision + // listing find this revision by the same selector used elsewhere. + // Deliberately no LabelTemplateHash ("orb.operatorframework.io/template-hash"): + // leaving it unset guarantees a mismatch against the COD's Prevent template + // hash, so the COD controller stamps the Prevent revision rather than forcing + // this adopting revision back to Prevent via ensureFieldOwnership. + return orbac.ClusterObjectSet(""). + WithAnnotations(revisionAnnotations). + WithLabels(map[string]string{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + labels.OwnerNameKey: ext.Name, + }). + WithSpec(spec), nil +} + +// buildOrbMigrationPhases places all objects in a single assertion-free phase, +// splitting into additional no-assertion phases only when the object count +// exceeds the per-phase cap. The split is purely to satisfy the limit, not for +// ordering. +func buildOrbMigrationPhases(objs []*orbac.PhaseObjectApplyConfiguration) []*orbac.PhaseApplyConfiguration { + chunks := slices.Collect(slices.Chunk(objs, maxObjectsPerPhase)) + multiChunk := len(chunks) > 1 + phases := make([]*orbac.PhaseApplyConfiguration, 0, len(chunks)) + for i, chunk := range chunks { + name := orbMigrationPhaseName + if multiChunk { + name = fmt.Sprintf("%s-%d", orbMigrationPhaseName, i+1) + } + phases = append(phases, orbac.Phase().WithName(name).WithObjects(chunk...)) + } + return phases +} + +// OrbStorageMigrator migrates a ClusterExtension installed under the legacy Helm +// runtime to the orb runtime without an uninstall/reinstall. It builds a first +// adopting ClusterObjectSet revision (collisionProtection None) directly from +// the deployed Helm release so the orb controller adopts the existing +// Helm-managed objects, then - once that revision completes - deletes the Helm +// release storage so the normal apply path takes over via an orb COD. +type OrbStorageMigrator struct { + ActionClientGetter helmclient.ActionClientGetter + RevisionGenerator OrbClusterObjectSetGenerator + Client orbStorageMigratorClient + Scheme *runtime.Scheme + FieldOwner string +} + +type orbStorageMigratorClient interface { + Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error + List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error +} + +// Migrate ensures the adopting revision exists for a deployed Helm release and +// reports whether the pipeline should be gated. +// +// - No deployed Helm release: nothing to migrate (fresh install, or migration +// already finished). Returns a nil result so the pipeline proceeds. +// - Deployed Helm release present, adoption not yet complete: ensures the +// adopting revision and returns a requeue result to gate the pipeline before +// the COD is created. +// - Deployed Helm release present, adoption complete: deletes the Helm release +// storage (bookkeeping secrets only) and returns a nil result. +func (m *OrbStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) (*ctrl.Result, error) { + l := log.FromContext(ctx) + + ac, err := m.ActionClientGetter.ActionClientFor(ctx, ext) + if err != nil { + return nil, err + } + + helmRelease, err := m.findDeployedRelease(ac, ext.GetName()) + if err != nil { + return nil, err + } + if helmRelease == nil { + // No deployed Helm release -> nothing to migrate. + return nil, nil + } + + existing, err := m.listRevisions(ctx, ext.GetName()) + if err != nil { + return nil, err + } + + adopting, err := m.ensureAdoptingRevision(ctx, ext, helmRelease, objectLabels, existing) + if err != nil { + return nil, err + } + + // Gate the pipeline while adoption is in progress: the COD must not be + // created (which would stamp a premature Prevent revision that collides with + // the still-unowned objects) until the adopting revision has completed. + if adopting.Status.CompletedAt == nil { + l.Info("waiting for adopting revision to complete before releasing Helm storage", "revision", adopting.Name) + return &ctrl.Result{RequeueAfter: orbAdoptionRequeueInterval}, nil + } + + // Adoption succeeded: delete only the Helm bookkeeping secrets (not a Helm + // uninstall - the managed objects are now orb-adopted and must not be torn + // down). From here migration becomes a no-op and the normal pipeline runs. + l.Info("adopting revision complete, deleting Helm release storage", "revision", adopting.Name) + if err := m.deleteHelmReleaseStorage(ac, ext.GetName()); err != nil { + return nil, fmt.Errorf("deleting Helm release storage: %w", err) + } + return nil, nil +} + +// findDeployedRelease returns the most-recent deployed Helm release, falling +// back through history when the latest release is not deployed. It returns +// (nil, nil) when there is no deployed release (nothing to migrate). +func (m *OrbStorageMigrator) findDeployedRelease(ac helmclient.ActionInterface, name string) (*release.Release, error) { + rel, err := ac.Get(name) + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if rel != nil && rel.Info != nil && rel.Info.Status == release.StatusDeployed { + return rel, nil + } + return m.findLatestDeployedRelease(ac, name) +} + +func (m *OrbStorageMigrator) findLatestDeployedRelease(ac helmclient.ActionInterface, name string) (*release.Release, error) { + history, err := ac.History(name) + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + var latest *release.Release + for _, rel := range history { + if rel == nil || rel.Info == nil || rel.Info.Status != release.StatusDeployed { + continue + } + if latest == nil || rel.Version > latest.Version { + latest = rel + } + } + return latest, nil +} + +func (m *OrbStorageMigrator) listRevisions(ctx context.Context, extName string) ([]orbv1alpha1.ClusterObjectSet, error) { + list := &orbv1alpha1.ClusterObjectSetList{} + if err := m.Client.List(ctx, list, client.MatchingLabels{labels.OwnerNameKey: extName}); err != nil { + return nil, fmt.Errorf("listing revisions: %w", err) + } + slices.SortFunc(list.Items, func(a, b orbv1alpha1.ClusterObjectSet) int { + return cmp.Compare(a.Spec.Revision, b.Spec.Revision) + }) + return list.Items, nil +} + +// ensureAdoptingRevision reuses the latest existing revision when the desired +// spec is already reflected there (idempotent), otherwise creates a new revision +// with the next revision number. It never updates an existing revision in place +// (COS spec is immutable). The returned ClusterObjectSet is used only to read +// status.completedAt: a freshly-created revision reports a nil completedAt. +func (m *OrbStorageMigrator) ensureAdoptingRevision( + ctx context.Context, + ext *ocv1.ClusterExtension, + helmRelease *release.Release, + objectLabels map[string]string, + existing []orbv1alpha1.ClusterObjectSet, +) (*orbv1alpha1.ClusterObjectSet, error) { + if len(existing) > 0 { + latest := &existing[len(existing)-1] + match, err := m.desiredMatchesRevision(ctx, ext, helmRelease, objectLabels, latest) + if err != nil { + return nil, err + } + if match { + return latest, nil + } + } + + revNum := nextOrbRevisionNumber(existing) + name := fmt.Sprintf("%s-%d", ext.Name, revNum) + + desired, err := m.buildDesired(ctx, ext, helmRelease, objectLabels, name, revNum) + if err != nil { + return nil, err + } + + // Externalize before creating so an oversized Helm release does not exceed + // etcd limits. The slices must be applied before the revision, since its + // objectRefs point to them. + externalized, slices, err := orb.ExternalizeCOS(desired) + if err != nil { + return nil, fmt.Errorf("externalizing adopting ClusterObjectSet: %w", err) + } + for _, slice := range slices { + if err := m.Client.Apply(ctx, slice, client.FieldOwner(m.FieldOwner), client.ForceOwnership); err != nil { + return nil, fmt.Errorf("applying ClusterObjectSlice: %w", err) + } + } + if err := m.Client.Apply(ctx, externalized, client.FieldOwner(m.FieldOwner), client.ForceOwnership); err != nil { + return nil, fmt.Errorf("applying adopting ClusterObjectSet: %w", err) + } + + // Freshly created: completedAt is nil, so the caller gates the pipeline. + return &orbv1alpha1.ClusterObjectSet{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +// desiredMatchesRevision reports whether the desired adopting COS content is +// already reflected in the given existing revision, comparing the (externalized) +// desired spec against the existing spec via DeepDerivative. The desired +// candidate is built with the existing revision's name and number so that +// name-derived slice references and the immutable revision field line up. +func (m *OrbStorageMigrator) desiredMatchesRevision( + ctx context.Context, + ext *ocv1.ClusterExtension, + helmRelease *release.Release, + objectLabels map[string]string, + existing *orbv1alpha1.ClusterObjectSet, +) (bool, error) { + desired, err := m.buildDesired(ctx, ext, helmRelease, objectLabels, existing.Name, existing.Spec.Revision) + if err != nil { + return false, err + } + externalized, _, err := orb.ExternalizeCOS(desired) + if err != nil { + return false, fmt.Errorf("externalizing adopting ClusterObjectSet for comparison: %w", err) + } + return specDeepDerivative(externalized, existing) +} + +// buildDesired generates a fresh adopting COS apply configuration for the given +// release and stamps it with the given name, revision number, and a +// non-controller owner reference to the ClusterExtension. Regenerating for each +// use avoids aliasing the phases mutated in place by externalization. +func (m *OrbStorageMigrator) buildDesired( + ctx context.Context, + ext *ocv1.ClusterExtension, + helmRelease *release.Release, + objectLabels map[string]string, + name string, + revision uint32, +) (*orbac.ClusterObjectSetApplyConfiguration, error) { + desired, err := m.RevisionGenerator.GenerateRevisionFromHelmRelease(ctx, helmRelease, ext, objectLabels) + if err != nil { + return nil, err + } + desired.WithName(name) + desired.Spec.WithRevision(revision) + + gvk, err := apiutil.GVKForObject(ext, m.Scheme) + if err != nil { + return nil, fmt.Errorf("get GVK for owner: %w", err) + } + // Non-controller owner reference: orb's adoptOrphans only adopts a COS with + // no controller owner, so this lets the COD later set itself as controller. + // It still garbage-collects the revision with the ClusterExtension during + // the pre-COD window. + desired.WithOwnerReferences(metav1ac.OwnerReference(). + WithAPIVersion(gvk.GroupVersion().String()). + WithKind(gvk.Kind). + WithName(ext.Name). + WithUID(ext.UID). + WithBlockOwnerDeletion(true)) + return desired, nil +} + +// deleteHelmReleaseStorage deletes the Helm bookkeeping secrets for the release +// oldest-to-newest (ascending version), so a partial-delete failure leaves the +// newest deployed release present and the next reconcile resumes against the +// same release rather than rewinding to an older one. This is not a Helm +// uninstall: the managed objects are now orb-adopted and must not be torn down. +func (m *OrbStorageMigrator) deleteHelmReleaseStorage(ac helmclient.ActionInterface, name string) error { + cfg := ac.Config() + if cfg == nil || cfg.Releases == nil { + return fmt.Errorf("Helm release storage unavailable") + } + + history, err := cfg.Releases.History(name) + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil + } + if err != nil { + return err + } + slices.SortFunc(history, func(a, b *release.Release) int { + return cmp.Compare(a.Version, b.Version) + }) + for _, rel := range history { + if _, err := cfg.Releases.Delete(name, rel.Version); err != nil { + return fmt.Errorf("deleting Helm release %q version %d storage: %w", name, rel.Version, err) + } + } + return nil +} + +func nextOrbRevisionNumber(existing []orbv1alpha1.ClusterObjectSet) uint32 { + if len(existing) == 0 { + return 1 + } + return existing[len(existing)-1].Spec.Revision + 1 +} + +// specDeepDerivative reports whether every field set in the desired COS spec is +// already reflected in the existing COS spec (mirrors the applier's +// alreadyApplied gating), so an already-captured desired spec does not spuriously +// increment the revision number. +func specDeepDerivative(desired *orbac.ClusterObjectSetApplyConfiguration, existing *orbv1alpha1.ClusterObjectSet) (bool, error) { + desiredSpec, err := specAsMap(desired) + if err != nil { + return false, fmt.Errorf("marshaling desired spec: %w", err) + } + existingSpec, err := specAsMap(existing) + if err != nil { + return false, fmt.Errorf("marshaling existing spec: %w", err) + } + return equality.Semantic.DeepDerivative(desiredSpec, existingSpec), nil +} + +func specAsMap(obj any) (map[string]any, error) { + raw, err := json.Marshal(obj) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + spec, _ := m["spec"].(map[string]any) + return spec, nil +} diff --git a/internal/operator-controller/applier/orbmigrator_test.go b/internal/operator-controller/applier/orbmigrator_test.go new file mode 100644 index 0000000000..59556aeac4 --- /dev/null +++ b/internal/operator-controller/applier/orbmigrator_test.go @@ -0,0 +1,572 @@ +package applier_test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" + orbac "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + 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/client/interceptor" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" + orb "github.com/operator-framework/operator-controller/internal/operator-controller/applier/orb" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + mockhelmclient "github.com/operator-framework/operator-controller/internal/testutil/mock/helmclient" +) + +func orbMigratorScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, orbv1alpha1.AddToScheme(scheme)) + require.NoError(t, ocv1.AddToScheme(scheme)) + return scheme +} + +// orbActionGetterConfig configures the fake Helm action client used by the +// migrator tests. +type orbActionGetterConfig struct { + getRel *release.Release + getErr error + history []*release.Release + historyErr error + storage *storage.Storage +} + +func newOrbActionGetter(ctrl *gomock.Controller, cfg orbActionGetterConfig) *mockhelmclient.MockActionClientGetterAndInterface { + m := mockhelmclient.NewMockActionClientGetterAndInterface(ctrl) + m.EXPECT().ActionClientFor(gomock.Any(), gomock.Any()).Return(m, nil).AnyTimes() + m.EXPECT().Get(gomock.Any(), gomock.Any()).Return(cfg.getRel, cfg.getErr).AnyTimes() + m.EXPECT().History(gomock.Any(), gomock.Any()).Return(cfg.history, cfg.historyErr).AnyTimes() + if cfg.storage != nil { + m.EXPECT().Config().Return(&action.Configuration{Releases: cfg.storage}).AnyTimes() + } else { + m.EXPECT().Config().Return(nil).AnyTimes() + } + return m +} + +const testExtName = "my-ext" + +func deployedRelease(version int, manifest string) *release.Release { + return &release.Release{ + Name: testExtName, + Version: version, + Manifest: manifest, + Info: &release.Info{Status: release.StatusDeployed}, + Labels: map[string]string{ + labels.BundleNameKey: testExtName + ".v1.0.0", + labels.PackageNameKey: testExtName, + labels.BundleVersionKey: "1.0.0", + labels.BundleReferenceKey: "example.com/" + testExtName + "@sha256:abc", + }, + } +} + +// Helm release manifests in this codebase are stored as one JSON object per +// line (see splitManifestDocuments), so tests use that format. +const cmManifest = `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"my-config","namespace":"my-ns"},"data":{"key":"value"}}` + +// typedCOSFromAC converts a ClusterObjectSet apply configuration into a typed +// ClusterObjectSet, so a matching existing revision can be seeded into the fake +// client for reuse/idempotency tests. +func typedCOSFromAC(t *testing.T, ac *orbac.ClusterObjectSetApplyConfiguration) *orbv1alpha1.ClusterObjectSet { + t.Helper() + raw, err := json.Marshal(ac) + require.NoError(t, err) + cos := &orbv1alpha1.ClusterObjectSet{} + require.NoError(t, json.Unmarshal(raw, cos)) + return cos +} + +// seededRevision builds the adopting revision the migrator would create for the +// given release/labels at the given revision number, as a typed ClusterObjectSet +// suitable for seeding into the fake client. +func seededRevision(t *testing.T, ext *ocv1.ClusterExtension, rel *release.Release, objLbls map[string]string) *orbv1alpha1.ClusterObjectSet { + t.Helper() + gen := &applier.SimpleOrbRevisionGenerator{} + ac, err := gen.GenerateRevisionFromHelmRelease(context.Background(), rel, ext, objLbls) + require.NoError(t, err) + ac.WithName(fmt.Sprintf("%s-1", ext.Name)) + ac.Spec.WithRevision(1) + externalized, _, err := orb.ExternalizeCOS(ac) + require.NoError(t, err) + return typedCOSFromAC(t, externalized) +} + +func objectLabelsFor(ext *ocv1.ClusterExtension) map[string]string { + return map[string]string{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + labels.OwnerNameKey: ext.GetName(), + } +} + +// --- Generator tests --- + +func TestSimpleOrbRevisionGenerator_SingleAssertionFreePhase(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + rel := deployedRelease(1, cmManifest) + + gen := &applier.SimpleOrbRevisionGenerator{} + cos, err := gen.GenerateRevisionFromHelmRelease(context.Background(), rel, ext, objectLabelsFor(ext)) + require.NoError(t, err) + + require.NotNil(t, cos.Spec) + assert.Equal(t, ext.Name, *cos.Spec.Group) + assert.Equal(t, orbv1alpha1.LifecycleStateActive, *cos.Spec.LifecycleState) + assert.Equal(t, orbv1alpha1.CollisionProtectionNone, *cos.Spec.CollisionProtection) + + require.Len(t, cos.Spec.Phases, 1) + assert.Len(t, cos.Spec.Phases[0].Objects, 1) + for _, obj := range cos.Spec.Phases[0].Objects { + assert.Empty(t, obj.Assertions, "adopting phase objects must have no assertions") + } + + // Owner labels present, bundle annotations present, no template-hash label. + assert.Equal(t, ocv1.ClusterExtensionKind, cos.Labels[labels.OwnerKindKey]) + assert.Equal(t, ext.Name, cos.Labels[labels.OwnerNameKey]) + assert.Empty(t, cos.Labels["orb.operatorframework.io/template-hash"]) + assert.Equal(t, "my-ext.v1.0.0", cos.Annotations[labels.BundleNameKey]) + assert.Equal(t, "1.0.0", cos.Annotations[labels.BundleVersionKey]) +} + +func TestSimpleOrbRevisionGenerator_SplitsAtObjectCap(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + + // Build a manifest with 120 objects: expect 3 assertion-free phases (50/50/20). + var sb strings.Builder + for i := range 120 { + if i > 0 { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-%d","namespace":"ns"}}`, i) + } + rel := deployedRelease(1, sb.String()) + + gen := &applier.SimpleOrbRevisionGenerator{} + cos, err := gen.GenerateRevisionFromHelmRelease(context.Background(), rel, ext, objectLabelsFor(ext)) + require.NoError(t, err) + + require.Len(t, cos.Spec.Phases, 3) + total := 0 + for _, p := range cos.Spec.Phases { + assert.LessOrEqual(t, len(p.Objects), 50) + for _, obj := range p.Objects { + assert.Empty(t, obj.Assertions, "no per-GVK assertions on migration phases") + } + total += len(p.Objects) + } + assert.Equal(t, 120, total) +} + +// --- Migrator tests --- + +func TestOrbStorageMigrator_NoDeployedRelease_NoOp(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + ctrl := gomock.NewController(t) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getErr: driver.ErrReleaseNotFound}) + + var applied int + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(context.Context, client.WithWatch, runtime.ApplyConfiguration, ...client.ApplyOption) error { + applied++ + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objectLabelsFor(ext)) + require.NoError(t, err) + assert.Nil(t, res, "pipeline should proceed when nothing to migrate") + assert.Zero(t, applied, "no revision should be created") +} + +func TestOrbStorageMigrator_DeployedNoCOS_CreatesRevision1(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + ctrl := gomock.NewController(t) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: deployedRelease(1, cmManifest)}) + + var appliedCOS *orbac.ClusterObjectSetApplyConfiguration + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + if cos, ok := obj.(*orbac.ClusterObjectSetApplyConfiguration); ok { + appliedCOS = cos + } + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objectLabelsFor(ext)) + require.NoError(t, err) + require.NotNil(t, res, "pipeline should be gated while adoption is in progress") + assert.Greater(t, res.RequeueAfter, time.Duration(0)) + + require.NotNil(t, appliedCOS, "adopting revision should be created") + assert.Equal(t, "my-ext-1", *appliedCOS.GetName()) + assert.Equal(t, uint32(1), *appliedCOS.Spec.Revision) + assert.Equal(t, "my-ext", *appliedCOS.Spec.Group) + assert.Equal(t, orbv1alpha1.CollisionProtectionNone, *appliedCOS.Spec.CollisionProtection) + assert.Equal(t, "my-ext", appliedCOS.Labels[labels.OwnerNameKey]) + assert.Equal(t, "my-ext.v1.0.0", appliedCOS.Annotations[labels.BundleNameKey]) + + // Non-controller CE owner reference, and no template-hash label. + require.Len(t, appliedCOS.OwnerReferences, 1) + ref := appliedCOS.OwnerReferences[0] + assert.Equal(t, "ClusterExtension", *ref.Kind) + assert.Equal(t, "my-ext", *ref.Name) + assert.Equal(t, "ext-uid", string(*ref.UID)) + assert.Nil(t, ref.Controller, "CE owner reference must not be a controller ref") + assert.Empty(t, appliedCOS.Labels["orb.operatorframework.io/template-hash"]) +} + +func TestOrbStorageMigrator_EquivalentDesired_NoNewRevision(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + rel := deployedRelease(1, cmManifest) + objLbls := objectLabelsFor(ext) + ctrl := gomock.NewController(t) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: rel}) + + existing := seededRevision(t, ext, rel, objLbls) + // completedAt nil -> pipeline should be gated, but no new revision created. + + var applied int + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithObjects(existing). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(context.Context, client.WithWatch, runtime.ApplyConfiguration, ...client.ApplyOption) error { + applied++ + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objLbls) + require.NoError(t, err) + require.NotNil(t, res, "gated because completedAt is nil") + assert.Zero(t, applied, "no new revision should be created for an equivalent desired spec") +} + +func TestOrbStorageMigrator_DifferingDesired_CreatesNextRevision(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + rel := deployedRelease(1, cmManifest) + objLbls := objectLabelsFor(ext) + ctrl := gomock.NewController(t) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: rel}) + + // Seed an existing revision 1 built from a DIFFERENT release manifest so the + // desired spec does not match. + otherRel := deployedRelease(1, `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"other","namespace":"ns"}}`) + existing := seededRevision(t, ext, otherRel, objLbls) + + var appliedCOS *orbac.ClusterObjectSetApplyConfiguration + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithObjects(existing). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + if cos, ok := obj.(*orbac.ClusterObjectSetApplyConfiguration); ok { + appliedCOS = cos + } + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objLbls) + require.NoError(t, err) + require.NotNil(t, res) + + require.NotNil(t, appliedCOS, "a new revision should be created") + assert.Equal(t, "my-ext-2", *appliedCOS.GetName()) + assert.Equal(t, uint32(2), *appliedCOS.Spec.Revision) +} + +func TestOrbStorageMigrator_LargeRelease_ExternalizesToSlices(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + ctrl := gomock.NewController(t) + + // Build a manifest large enough to exceed the externalization threshold. + var sb strings.Builder + for i := range 30 { + if i > 0 { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-%d","namespace":"ns"},"data":{"payload":%q}}`, i, strings.Repeat("x", 60*1024)) + } + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: deployedRelease(1, sb.String())}) + + var applied []string + var cosPhases []orbac.PhaseApplyConfiguration + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + switch o := obj.(type) { + case *orbac.ClusterObjectSliceApplyConfiguration: + applied = append(applied, "cosl") + case *orbac.ClusterObjectSetApplyConfiguration: + applied = append(applied, "cos") + cosPhases = o.Spec.Phases + } + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + _, err := m.Migrate(context.Background(), ext, objectLabelsFor(ext)) + require.NoError(t, err) + + require.NotEmpty(t, applied) + assert.Equal(t, "cos", applied[len(applied)-1], "ClusterObjectSet must be applied after its slices") + assert.Contains(t, applied, "cosl", "slices should be produced for a large release") + + // Phases should reference slices via objectRef, not carry inline objects. + sawRef := false + for _, p := range cosPhases { + for _, obj := range p.Objects { + if obj.ObjectRef != nil { + sawRef = true + } + assert.Nil(t, obj.Object, "large release phases should not carry inline objects") + } + } + assert.True(t, sawRef, "phases should use objectRefs after externalization") +} + +func TestOrbStorageMigrator_FallsBackToDeployedInHistory(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + ctrl := gomock.NewController(t) + + // Latest release is FAILED; a deployed release exists earlier in history. + failed := &release.Release{Name: "my-ext", Version: 2, Info: &release.Info{Status: release.StatusFailed}} + deployed := deployedRelease(1, cmManifest) + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{ + getRel: failed, + history: []*release.Release{deployed, failed}, + }) + + var appliedCOS *orbac.ClusterObjectSetApplyConfiguration + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(_ context.Context, _ client.WithWatch, obj runtime.ApplyConfiguration, _ ...client.ApplyOption) error { + if cos, ok := obj.(*orbac.ClusterObjectSetApplyConfiguration); ok { + appliedCOS = cos + } + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + _, err := m.Migrate(context.Background(), ext, objectLabelsFor(ext)) + require.NoError(t, err) + require.NotNil(t, appliedCOS, "should migrate from the deployed release found in history") + assert.Equal(t, "my-ext.v1.0.0", appliedCOS.Annotations[labels.BundleNameKey]) +} + +func TestOrbStorageMigrator_NoDeployedInHistory_NoOp(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext"}} + ctrl := gomock.NewController(t) + + failed := &release.Release{Name: "my-ext", Version: 1, Info: &release.Info{Status: release.StatusFailed}} + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{ + getRel: failed, + history: []*release.Release{failed}, + }) + + var applied int + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(context.Context, client.WithWatch, runtime.ApplyConfiguration, ...client.ApplyOption) error { + applied++ + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objectLabelsFor(ext)) + require.NoError(t, err) + assert.Nil(t, res) + assert.Zero(t, applied) +} + +func TestOrbStorageMigrator_CompletedRevision_DeletesHelmStorage(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + rel := deployedRelease(1, cmManifest) + objLbls := objectLabelsFor(ext) + ctrl := gomock.NewController(t) + + st := storage.Init(driver.NewMemory()) + require.NoError(t, st.Create(rel)) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: rel, storage: st}) + + existing := seededRevision(t, ext, rel, objLbls) + now := metav1.Now() + existing.Status.CompletedAt = &now + + var applied int + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithObjects(existing). + WithInterceptorFuncs(interceptor.Funcs{ + Apply: func(context.Context, client.WithWatch, runtime.ApplyConfiguration, ...client.ApplyOption) error { + applied++ + return nil + }, + }).Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + res, err := m.Migrate(context.Background(), ext, objLbls) + require.NoError(t, err) + assert.Nil(t, res, "pipeline should no longer be gated once adoption completed") + assert.Zero(t, applied, "no new revision when reusing a completed revision") + + // Helm release storage should be gone. + _, err = st.History("my-ext") + assert.ErrorIs(t, err, driver.ErrReleaseNotFound) +} + +// failAfterNDeletes wraps a driver and fails the (n+1)-th Delete call, to +// simulate a partial delete failure. +type failAfterNDeletes struct { + driver.Driver + remaining int +} + +func (d *failAfterNDeletes) Delete(key string) (*release.Release, error) { + if d.remaining <= 0 { + return nil, fmt.Errorf("simulated delete failure") + } + d.remaining-- + return d.Driver.Delete(key) +} + +func (d *failAfterNDeletes) Name() string { return d.Driver.Name() } + +func TestOrbStorageMigrator_PartialDelete_LeavesNewestPresent(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "my-ext", UID: "ext-uid"}} + rel := deployedRelease(2, cmManifest) + objLbls := objectLabelsFor(ext) + ctrl := gomock.NewController(t) + + mem := driver.NewMemory() + base := storage.Init(mem) + // Seed history: v1 superseded (oldest), v2 deployed (newest). + require.NoError(t, base.Create(&release.Release{Name: "my-ext", Version: 1, Info: &release.Info{Status: release.StatusSuperseded}})) + require.NoError(t, base.Create(rel)) + + // Fail on the second delete (the newest deployed version). + st := storage.Init(&failAfterNDeletes{Driver: mem, remaining: 1}) + + ag := newOrbActionGetter(ctrl, orbActionGetterConfig{getRel: rel, storage: st}) + + existing := seededRevision(t, ext, rel, objLbls) + now := metav1.Now() + existing.Status.CompletedAt = &now + + fakeClient := fake.NewClientBuilder(). + WithScheme(orbMigratorScheme(t)). + WithObjects(existing). + Build() + + m := &applier.OrbStorageMigrator{ + ActionClientGetter: ag, + RevisionGenerator: &applier.SimpleOrbRevisionGenerator{}, + Client: fakeClient, + Scheme: orbMigratorScheme(t), + FieldOwner: "test-owner", + } + + _, err := m.Migrate(context.Background(), ext, objLbls) + require.Error(t, err, "a mid-delete failure should surface") + + // v1 (oldest) deleted first, v2 (newest deployed) delete failed -> still present. + _, err = base.Get("my-ext", 1) + require.ErrorIs(t, err, driver.ErrReleaseNotFound, "oldest version should have been deleted") + newest, err := base.Get("my-ext", 2) + require.NoError(t, err, "newest deployed version should remain present after partial failure") + assert.Equal(t, 2, newest.Version) +} diff --git a/internal/operator-controller/controllers/orboperator_reconcile_steps.go b/internal/operator-controller/controllers/orboperator_reconcile_steps.go index b8eacad435..127e94826b 100644 --- a/internal/operator-controller/controllers/orboperator_reconcile_steps.go +++ b/internal/operator-controller/controllers/orboperator_reconcile_steps.go @@ -10,12 +10,41 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) +// OrbStorageMigrator migrates a ClusterExtension from the legacy Helm runtime to +// the orb runtime. Unlike the synchronous Boxcutter StorageMigrator, its Migrate +// returns a ctrl.Result so the reconcile step can requeue and gate the pipeline +// while adoption of the Helm-managed objects is still in progress. +type OrbStorageMigrator interface { + Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) (*ctrl.Result, error) +} + +// MigrateOrbStorage returns a reconcile step that runs the orb storage migrator. +// While adoption is in progress the migrator returns a non-nil requeue result, +// which stops the pipeline before ApplyBundle so the COD is not created until the +// adopting revision has completed. When there is nothing to migrate (or once +// migration has finished) it returns a nil result and the pipeline proceeds. +func MigrateOrbStorage(m OrbStorageMigrator) ReconcileStepFunc { + return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { + objLbls := map[string]string{ + labels.OwnerKindKey: ocv1.ClusterExtensionKind, + labels.OwnerNameKey: ext.GetName(), + } + + res, err := m.Migrate(ctx, ext, objLbls) + if err != nil { + return nil, fmt.Errorf("migrating storage: %w", err) + } + return res, nil + } +} + type OrbOperatorRevisionStatesGetter struct { Reader client.Reader } diff --git a/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go b/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go index f604360c94..681fd5f88d 100644 --- a/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go +++ b/internal/operator-controller/controllers/orboperator_reconcile_steps_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" "github.com/stretchr/testify/assert" @@ -11,6 +12,7 @@ import ( apimeta "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/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" @@ -414,3 +416,48 @@ func TestOrbGetRevisionStates_AvailablePassesThrough(t *testing.T) { assert.Equal(t, metav1.ConditionFalse, avail.Status) assert.Equal(t, orbv1alpha1.ReasonUnavailable, avail.Reason) } + +// fakeOrbStorageMigrator is a hand-rolled OrbStorageMigrator for exercising the +// MigrateOrbStorage step. +type fakeOrbStorageMigrator struct { + res *ctrl.Result + err error + gotLabels map[string]string + callCount int +} + +func (f *fakeOrbStorageMigrator) Migrate(_ context.Context, _ *ocv1.ClusterExtension, objectLabels map[string]string) (*ctrl.Result, error) { + f.callCount++ + f.gotLabels = objectLabels + return f.res, f.err +} + +func TestMigrateOrbStorage_GatesWithRequeue(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + m := &fakeOrbStorageMigrator{res: &ctrl.Result{RequeueAfter: 5 * time.Second}} + + res, err := controllers.MigrateOrbStorage(m)(context.Background(), nil, ext) + require.NoError(t, err) + require.NotNil(t, res) + assert.Greater(t, res.RequeueAfter, time.Duration(0)) + assert.Equal(t, ocv1.ClusterExtensionKind, m.gotLabels[labels.OwnerKindKey]) + assert.Equal(t, "argocd", m.gotLabels[labels.OwnerNameKey]) +} + +func TestMigrateOrbStorage_ProceedsWhenNil(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + m := &fakeOrbStorageMigrator{res: nil} + + res, err := controllers.MigrateOrbStorage(m)(context.Background(), nil, ext) + require.NoError(t, err) + assert.Nil(t, res) +} + +func TestMigrateOrbStorage_WrapsError(t *testing.T) { + ext := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "argocd"}} + m := &fakeOrbStorageMigrator{err: errors.New("boom")} + + _, err := controllers.MigrateOrbStorage(m)(context.Background(), nil, ext) + require.Error(t, err) + assert.Contains(t, err.Error(), "migrating storage") +} diff --git a/internal/testutil/mock/applier/mock_applier.go b/internal/testutil/mock/applier/mock_applier.go index 5ff9b4e08c..c4c87763b7 100644 --- a/internal/testutil/mock/applier/mock_applier.go +++ b/internal/testutil/mock/applier/mock_applier.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/operator-framework/operator-controller/internal/operator-controller/applier (interfaces: Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,ManifestProvider) +// Source: github.com/operator-framework/operator-controller/internal/operator-controller/applier (interfaces: Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,OrbClusterObjectSetGenerator,ManifestProvider) // // Generated by this command: // -// mockgen -destination=applier/mock_applier.go -package=applier github.com/operator-framework/operator-controller/internal/operator-controller/applier Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,ManifestProvider +// mockgen -destination=applier/mock_applier.go -package=applier github.com/operator-framework/operator-controller/internal/operator-controller/applier Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,OrbClusterObjectSetGenerator,ManifestProvider // // Package applier is a generated GoMock package. @@ -14,6 +14,7 @@ import ( fs "io/fs" reflect "reflect" + v1alpha1 "github.com/joelanford/orb-operator/applyconfigurations/api/v1alpha1" v1 "github.com/operator-framework/operator-controller/api/v1" v10 "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" gomock "go.uber.org/mock/gomock" @@ -206,6 +207,45 @@ func (mr *MockClusterObjectSetGeneratorMockRecorder) GenerateRevisionFromHelmRel return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateRevisionFromHelmRelease", reflect.TypeOf((*MockClusterObjectSetGenerator)(nil).GenerateRevisionFromHelmRelease), ctx, helmRelease, ext, objectLabels) } +// MockOrbClusterObjectSetGenerator is a mock of OrbClusterObjectSetGenerator interface. +type MockOrbClusterObjectSetGenerator struct { + ctrl *gomock.Controller + recorder *MockOrbClusterObjectSetGeneratorMockRecorder + isgomock struct{} +} + +// MockOrbClusterObjectSetGeneratorMockRecorder is the mock recorder for MockOrbClusterObjectSetGenerator. +type MockOrbClusterObjectSetGeneratorMockRecorder struct { + mock *MockOrbClusterObjectSetGenerator +} + +// NewMockOrbClusterObjectSetGenerator creates a new mock instance. +func NewMockOrbClusterObjectSetGenerator(ctrl *gomock.Controller) *MockOrbClusterObjectSetGenerator { + mock := &MockOrbClusterObjectSetGenerator{ctrl: ctrl} + mock.recorder = &MockOrbClusterObjectSetGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOrbClusterObjectSetGenerator) EXPECT() *MockOrbClusterObjectSetGeneratorMockRecorder { + return m.recorder +} + +// GenerateRevisionFromHelmRelease mocks base method. +func (m *MockOrbClusterObjectSetGenerator) GenerateRevisionFromHelmRelease(ctx context.Context, helmRelease *release.Release, ext *v1.ClusterExtension, objectLabels map[string]string) (*v1alpha1.ClusterObjectSetApplyConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateRevisionFromHelmRelease", ctx, helmRelease, ext, objectLabels) + ret0, _ := ret[0].(*v1alpha1.ClusterObjectSetApplyConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateRevisionFromHelmRelease indicates an expected call of GenerateRevisionFromHelmRelease. +func (mr *MockOrbClusterObjectSetGeneratorMockRecorder) GenerateRevisionFromHelmRelease(ctx, helmRelease, ext, objectLabels any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateRevisionFromHelmRelease", reflect.TypeOf((*MockOrbClusterObjectSetGenerator)(nil).GenerateRevisionFromHelmRelease), ctx, helmRelease, ext, objectLabels) +} + // MockManifestProvider is a mock of ManifestProvider interface. type MockManifestProvider struct { ctrl *gomock.Controller diff --git a/internal/testutil/mock/generate.go b/internal/testutil/mock/generate.go index 8b07554eb7..aa6b536a82 100644 --- a/internal/testutil/mock/generate.go +++ b/internal/testutil/mock/generate.go @@ -31,7 +31,7 @@ package mock //go:generate mockgen -destination=catalogdservice/mock_graphqlservice.go -package=catalogdservice github.com/operator-framework/operator-controller/internal/catalogd/service GraphQLService // Internal interfaces — operator-controller applier -//go:generate mockgen -destination=applier/mock_applier.go -package=applier github.com/operator-framework/operator-controller/internal/operator-controller/applier Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,ManifestProvider +//go:generate mockgen -destination=applier/mock_applier.go -package=applier github.com/operator-framework/operator-controller/internal/operator-controller/applier Preflight,HelmReleaseToObjectsConverterInterface,HelmChartProvider,ClusterObjectSetGenerator,OrbClusterObjectSetGenerator,ManifestProvider // Internal interfaces — operator-controller catalogmetadata //go:generate mockgen -destination=catalogclient/mock_cache.go -package=catalogclient github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/client Cache From c52d0ce05aeeb94bc18abdc3ed1a15edf1da5c79 Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 21:32:07 -0400 Subject: [PATCH 25/26] :seedling: Mark orb-operator-helm-migration spec done --- .../verification.md | 30 ------------------ .../README.md | 2 +- .../plan.md | 0 .../requirements.md | 0 .../verification.md | 31 +++++++++++++++++++ 5 files changed, 32 insertions(+), 31 deletions(-) delete mode 100644 specs/2026-08-13-orb-operator-helm-migration/verification.md rename specs/{ => closed}/2026-08-13-orb-operator-helm-migration/README.md (99%) rename specs/{ => closed}/2026-08-13-orb-operator-helm-migration/plan.md (100%) rename specs/{ => closed}/2026-08-13-orb-operator-helm-migration/requirements.md (100%) create mode 100644 specs/closed/2026-08-13-orb-operator-helm-migration/verification.md diff --git a/specs/2026-08-13-orb-operator-helm-migration/verification.md b/specs/2026-08-13-orb-operator-helm-migration/verification.md deleted file mode 100644 index ae5c40b12b..0000000000 --- a/specs/2026-08-13-orb-operator-helm-migration/verification.md +++ /dev/null @@ -1,30 +0,0 @@ -# Verification - -## Implementation Correctness - -- [ ] Deployed Helm release is the migration trigger; absence -> no-op, presence -> migration in progress -- [ ] Most-recent `deployed` release is selected, with history fallback when the latest is not `deployed` -- [ ] COS-from-Helm generator produces a COS with `None`, `spec.group = ext.Name`, `Active`, owner labels, bundle annotations, a **non-controller** CE ownerReference, and no `LabelTemplateHash` -- [ ] The adopting COS places all objects in a single assertion-free phase (no per-GVK assertions, no kind-based ordering; chunked at 50 only when the object count requires it) -- [ ] The migration step is ordered before resolution (`ResolveBundle`) - it is the first step after `ValidateClusterExtension` -- [ ] Revision reuse-vs-increment uses `equality.Semantic.DeepDerivative` against the latest existing revision -- [ ] The adopting revision is externalized when large: phases use `objectRef`s and ClusterObjectSlices are created before the revision -- [ ] Revision numbering respects immutability: equivalent desired spec reuses the latest revision; a differing desired spec creates the next revision number without modifying the existing one -- [ ] The step returns a requeue `ctrl.Result` while the adopting revision's `completedAt` is nil (pipeline gated before `ApplyBundle`) -- [ ] The Helm release storage (bookkeeping secrets only, not an uninstall) is deleted only after the adopting revision reaches `completedAt` -- [ ] Release-history secrets are deleted oldest-to-newest, so a partial-delete failure leaves the newest deployed release present (no rewind) -- [ ] After the gate lifts, the COD is created with `Prevent` (codgen default unchanged) and orb stamps the `Prevent` revision that takes over via sibling handoff -- [ ] The migrator performs no manual adopting-revision cleanup (relies on orb's adopt/archive/prune); the non-controller CE ownerReference leaves the revision adoptable -- [ ] Migration is idempotent/resumable across controller restarts -- [ ] Helm `ActionClientGetter` and the migration step are wired into `orbOperatorReconcilerConfigurator` as the first step after `ValidateClusterExtension` (before resolution) -- [ ] All unit tests pass; e2e migration test passes - -## Project Conventions - -- [ ] Code follows Go style and passes `make lint` -- [ ] No `//nolint` comments added -- [ ] Reuses shared helpers (`splitManifestDocuments`, sanitization, phase building) rather than duplicating codgen logic -- [ ] Uses the `labels.*` key constants for annotations/labels -- [ ] Mirrors `BoxcutterStorageMigrator` structure where applicable (per specs/mission.md: simple, predictable, do not fight Kubernetes) -- [ ] Uses orb-operator and Helm types from tech-stack (`github.com/joelanford/orb-operator`, `helm.sh/helm/v3`, helm-operator-plugins) -- [ ] `make test-unit` passes; `make verify` shows no unintended generated-code changes diff --git a/specs/2026-08-13-orb-operator-helm-migration/README.md b/specs/closed/2026-08-13-orb-operator-helm-migration/README.md similarity index 99% rename from specs/2026-08-13-orb-operator-helm-migration/README.md rename to specs/closed/2026-08-13-orb-operator-helm-migration/README.md index c8848e1f54..e01840c3df 100644 --- a/specs/2026-08-13-orb-operator-helm-migration/README.md +++ b/specs/closed/2026-08-13-orb-operator-helm-migration/README.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # orb-operator Helm Storage Migration diff --git a/specs/2026-08-13-orb-operator-helm-migration/plan.md b/specs/closed/2026-08-13-orb-operator-helm-migration/plan.md similarity index 100% rename from specs/2026-08-13-orb-operator-helm-migration/plan.md rename to specs/closed/2026-08-13-orb-operator-helm-migration/plan.md diff --git a/specs/2026-08-13-orb-operator-helm-migration/requirements.md b/specs/closed/2026-08-13-orb-operator-helm-migration/requirements.md similarity index 100% rename from specs/2026-08-13-orb-operator-helm-migration/requirements.md rename to specs/closed/2026-08-13-orb-operator-helm-migration/requirements.md diff --git a/specs/closed/2026-08-13-orb-operator-helm-migration/verification.md b/specs/closed/2026-08-13-orb-operator-helm-migration/verification.md new file mode 100644 index 0000000000..27b78e1e99 --- /dev/null +++ b/specs/closed/2026-08-13-orb-operator-helm-migration/verification.md @@ -0,0 +1,31 @@ +# Verification + +## Implementation Correctness + +- [x] Deployed Helm release is the migration trigger; absence -> no-op, presence -> migration in progress +- [x] Most-recent `deployed` release is selected, with history fallback when the latest is not `deployed` +- [x] COS-from-Helm generator produces a COS with `None`, `spec.group = ext.Name`, `Active`, owner labels, bundle annotations, a **non-controller** CE ownerReference, and no `LabelTemplateHash` (the generator sets spec/labels/annotations; the migrator stamps the non-controller CE ownerReference, mirroring `BoxcutterStorageMigrator`) +- [x] The adopting COS places all objects in a single assertion-free phase (no per-GVK assertions, no kind-based ordering; chunked at 50 only when the object count requires it) +- [x] The migration step is ordered before resolution (`ResolveBundle`) - it is the first step after `ValidateClusterExtension` +- [x] Revision reuse-vs-increment uses `equality.Semantic.DeepDerivative` against the latest existing revision (comparing the externalized desired spec so large releases do not spuriously increment) +- [x] The adopting revision is externalized when large: phases use `objectRef`s and ClusterObjectSlices are created before the revision +- [x] Revision numbering respects immutability: equivalent desired spec reuses the latest revision; a differing desired spec creates the next revision number without modifying the existing one +- [x] The step returns a requeue `ctrl.Result` while the adopting revision's `completedAt` is nil (pipeline gated before `ApplyBundle`) +- [x] The Helm release storage (bookkeeping secrets only, not an uninstall) is deleted only after the adopting revision reaches `completedAt` +- [x] Release-history secrets are deleted oldest-to-newest, so a partial-delete failure leaves the newest deployed release present (no rewind) +- [x] After the gate lifts, the COD is created with `Prevent` (codgen default unchanged) and orb stamps the `Prevent` revision that takes over via sibling handoff +- [x] The migrator performs no manual adopting-revision cleanup (relies on orb's adopt/archive/prune); the non-controller CE ownerReference leaves the revision adoptable +- [x] Migration is idempotent/resumable across controller restarts +- [x] Helm `ActionClientGetter` and the migration step are wired into `orbOperatorReconcilerConfigurator` as the first step after `ValidateClusterExtension` (before resolution) +- [x] All unit tests pass +- [ ] e2e migration test passes - **deferred**: no migration e2e precedent exists in the repo (the analogous `BoxcutterStorageMigrator` is covered by unit tests only, and there is no orb e2e suite yet). Tracked as a follow-up. + +## Project Conventions + +- [x] Code follows Go style and passes `make lint` +- [x] No `//nolint` comments added +- [x] Reuses shared helpers (`splitManifestDocuments`, sanitization via `sanitizedUnstructured`, `mergeStringMaps`, the `orb` externalizer) rather than duplicating codgen logic +- [x] Uses the `labels.*` key constants for annotations/labels +- [x] Mirrors `BoxcutterStorageMigrator` structure where applicable (per specs/mission.md: simple, predictable, do not fight Kubernetes) +- [x] Uses orb-operator and Helm types from tech-stack (`github.com/joelanford/orb-operator`, `helm.sh/helm/v3`, helm-operator-plugins) +- [x] `make test-unit` passes; regeneration (`make generate manifests`) produces no unintended generated-code changes From 8b59f043da1955486f51ef5d1840f0a9e248be9a Mon Sep 17 00:00:00 2001 From: Joe Lanford <joe.lanford@gmail.com> Date: Thu, 13 Aug 2026 22:18:37 -0400 Subject: [PATCH 26/26] :seedling: Make e2e resource-gathering helpers orb-runtime aware --- test/e2e/steps/hooks.go | 7 ++ test/e2e/steps/steps.go | 156 ++++++++++++++++++++++++++++++++++------ 2 files changed, 142 insertions(+), 21 deletions(-) diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index ee93303408..a5e9c4a4b5 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -230,6 +230,13 @@ func scenarioCtx(ctx context.Context) *scenarioContext { return ctx.Value(scenarioContextKey).(*scenarioContext) } +// featureEnabled reports whether the given feature gate is present and enabled +// in the detected operator-controller configuration. +func featureEnabled(f featuregate.Feature) bool { + enabled, found := featureGates[f] + return found && enabled +} + func stderrOutput(err error) string { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr != nil { diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 31abf4bc0b..6164d347ee 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -24,6 +24,7 @@ import ( jsonpatch "github.com/evanphx/json-patch" "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/crane" + orbv1alpha1 "github.com/joelanford/orb-operator/api/v1alpha1" "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" "github.com/spf13/pflag" @@ -2126,10 +2127,14 @@ func getResource(kind string, name string, namespace string) (*unstructured.Unst // this method is best called when the extension has been installed successfully. An error is returned if there was // any issue in determining the extension's resources. func listExtensionResources(extName string) ([]client.Object, error) { - if enabled, found := featureGates[features.BoxcutterRuntime]; found && enabled { + switch { + case featureEnabled(features.BoxcutterRuntime): return listExtensionRevisionResources(extName) + case featureEnabled(features.OrbOperatorRuntime): + return listOrbRevisionResources(extName) + default: + return listHelmReleaseResources(extName) } - return listHelmReleaseResources(extName) } // listHelmReleaseResources returns a slice of client.Object containing all resources for a ClusterExtension's @@ -2161,7 +2166,7 @@ func helmReleaseSecretForExtension(extName string) (*corev1.Secret, error) { return nil, err } if strings.TrimSpace(out) == "" { - return nil, err + return nil, fmt.Errorf("no deployed helm release secret found for extension %s", extName) } var secretList corev1.SecretList @@ -2169,7 +2174,7 @@ func helmReleaseSecretForExtension(extName string) (*corev1.Secret, error) { return nil, err } if len(secretList.Items) != 1 { - return nil, err + return nil, fmt.Errorf("expected exactly 1 deployed helm release secret for extension %s, found %d", extName, len(secretList.Items)) } return &secretList.Items[0], nil } @@ -2262,23 +2267,9 @@ func resolveObjectRef(ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, err if !ok { return nil, fmt.Errorf("key %q not found in Secret %s/%s", ref.Key, ref.Namespace, ref.Name) } - // Auto-detect gzip compression (magic bytes 0x1f 0x8b) - if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { - reader, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("creating gzip reader for key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err) - } - defer reader.Close() - const maxDecompressedSize = 10 * 1024 * 1024 // 10 MiB - limited := io.LimitReader(reader, maxDecompressedSize+1) - decompressed, err := io.ReadAll(limited) - if err != nil { - return nil, fmt.Errorf("decompressing key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err) - } - if len(decompressed) > maxDecompressedSize { - return nil, fmt.Errorf("decompressed data for key %q in Secret %s/%s exceeds maximum size (%d bytes)", ref.Key, ref.Namespace, ref.Name, maxDecompressedSize) - } - data = decompressed + data, err = decodeMaybeGzipped(data) + if err != nil { + return nil, fmt.Errorf("decoding key %q in Secret %s/%s: %w", ref.Key, ref.Namespace, ref.Name, err) } obj := &unstructured.Unstructured{} if err := json.Unmarshal(data, &obj.Object); err != nil { @@ -2287,6 +2278,129 @@ func resolveObjectRef(ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, err return obj, nil } +// decodeMaybeGzipped returns data decompressed when it carries the gzip magic +// bytes (0x1f 0x8b), or the data unchanged otherwise. Decompression is bounded +// to guard against decompression bombs. +func decodeMaybeGzipped(data []byte) ([]byte, error) { + if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b { + return data, nil + } + reader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating gzip reader: %w", err) + } + defer reader.Close() + const maxDecompressedSize = 10 * 1024 * 1024 // 10 MiB + limited := io.LimitReader(reader, maxDecompressedSize+1) + decompressed, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("decompressing: %w", err) + } + if len(decompressed) > maxDecompressedSize { + return nil, fmt.Errorf("decompressed data exceeds maximum size (%d bytes)", maxDecompressedSize) + } + return decompressed, nil +} + +// listOrbRevisionResources lists the objects managed by the latest active orb +// ClusterObjectSet revision for the extension. Objects are stored either inline +// in a phase or by reference to a ClusterObjectSlice. +func listOrbRevisionResources(extName string) ([]client.Object, error) { + rev, err := latestActiveOrbRevisionForExtension(extName) + if err != nil { + return nil, fmt.Errorf("failed to get latest active orb revision for extension %s: %w", extName, err) + } + + var objs []client.Object + for i := range rev.Spec.Phases { + phase := &rev.Spec.Phases[i] + for j := range phase.Objects { + po := &phase.Objects[j] + switch { + case po.ObjectRef != nil: + resolved, err := resolveOrbObjectRef(po.ObjectRef) + if err != nil { + return nil, fmt.Errorf("resolving objectRef in phase %q object %d: %w", phase.Name, j, err) + } + objs = append(objs, resolved) + case len(po.Object.Raw) > 0: + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(po.Object.Raw, &obj.Object); err != nil { + return nil, fmt.Errorf("unmarshaling inline object %d in phase %q: %w", j, phase.Name, err) + } + objs = append(objs, obj) + default: + return nil, fmt.Errorf("object %d in phase %q has neither object nor objectRef", j, phase.Name) + } + } + } + return objs, nil +} + +// latestActiveOrbRevisionForExtension returns the highest-revision, non-archived +// orb ClusterObjectSet whose spec.group matches the extension name. The orb +// ClusterObjectSet CRD (orb.operatorframework.io) is distinct from OLM's +// ClusterObjectSet, so it must be addressed by its fully-qualified resource name. +func latestActiveOrbRevisionForExtension(extName string) (*orbv1alpha1.ClusterObjectSet, error) { + out, err := k8sClient(context.Background(), "get", "clusterobjectsets.orb.operatorframework.io", "-o", "json") + if err != nil { + return nil, fmt.Errorf("error listing orb revisions for extension '%s': %w", extName, err) + } + + var revisionList orbv1alpha1.ClusterObjectSetList + if err := json.Unmarshal([]byte(out), &revisionList); err != nil { + return nil, fmt.Errorf("error unmarshalling orb revisions for extension '%s': %w", extName, err) + } + + var latest *orbv1alpha1.ClusterObjectSet + for i := range revisionList.Items { + rev := &revisionList.Items[i] + if rev.Spec.Group != extName { + continue + } + if rev.Spec.LifecycleState == orbv1alpha1.LifecycleStateArchived { + continue + } + if latest == nil || rev.Spec.Revision > latest.Spec.Revision { + latest = rev + } + } + + if latest == nil { + return nil, fmt.Errorf("no active orb revisions found for extension '%s'", extName) + } + return latest, nil +} + +// resolveOrbObjectRef fetches an object referenced by an orb phase from its +// ClusterObjectSlice, matching on the reference's identity fields. +func resolveOrbObjectRef(ref *orbv1alpha1.ObjectRef) (*unstructured.Unstructured, error) { + out, err := k8sClient(context.Background(), "get", "clusterobjectslices.orb.operatorframework.io", ref.SliceName, "-o", "json") + if err != nil { + return nil, fmt.Errorf("getting ClusterObjectSlice %s: %w", ref.SliceName, err) + } + var slice orbv1alpha1.ClusterObjectSlice + if err := json.Unmarshal([]byte(out), &slice); err != nil { + return nil, fmt.Errorf("unmarshaling ClusterObjectSlice %s: %w", ref.SliceName, err) + } + for i := range slice.Objects { + so := &slice.Objects[i] + if so.APIVersion != ref.APIVersion || so.Kind != ref.Kind || so.Name != ref.Name || so.Namespace != ref.Namespace { + continue + } + data, err := decodeMaybeGzipped(so.Content) + if err != nil { + return nil, fmt.Errorf("decoding object %s/%s in ClusterObjectSlice %s: %w", ref.Namespace, ref.Name, ref.SliceName, err) + } + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(data, &obj.Object); err != nil { + return nil, fmt.Errorf("unmarshaling object %s/%s from ClusterObjectSlice %s: %w", ref.Namespace, ref.Name, ref.SliceName, err) + } + return obj, nil + } + return nil, fmt.Errorf("object %s/%s (%s %s) not found in ClusterObjectSlice %s", ref.Namespace, ref.Name, ref.APIVersion, ref.Kind, ref.SliceName) +} + // latestActiveRevisionForExtension returns the latest active revision for the extension called extName func latestActiveRevisionForExtension(extName string) (*ocv1.ClusterObjectSet, error) { out, err := k8sClient(context.Background(), "get", "clusterobjectsets", "-l", fmt.Sprintf("olm.operatorframework.io/owner-name=%s", extName), "-o", "json")