diff --git a/.docs-lint-scope b/.docs-lint-scope index 770d40cb..ec9a4e47 100644 --- a/.docs-lint-scope +++ b/.docs-lint-scope @@ -35,3 +35,12 @@ docs/demo/commit-window.md docs/facts/kubernetes-watch-options.md docs/spec/watch-event-ordering-and-attribution-grace.md docs/bi-directional.md +docs/README.md +docs/github-setup-guide.md +docs/attribution-setup-guide.md +docs/audit-setup/cluster/readme.md +docs/sops-age-guide.md +docs/commit-signing.md +docs/commit-messages.md +docs/definitions.md +charts/gitops-reverser/README.md diff --git a/docs/future/task-oriented-docs-and-website.md b/docs/future/task-oriented-docs-and-website.md index b473480e..ca295f63 100644 --- a/docs/future/task-oriented-docs-and-website.md +++ b/docs/future/task-oriented-docs-and-website.md @@ -6,7 +6,7 @@ here are future work. ## Goal Give GitOps Reverser a public website that helps people understand the product, install it, and -complete common tasks. Use the same documentation sources for the repository and the website. +complete common tasks, built from the same Markdown sources as the repository. Organize the reader-facing navigation around outcomes. A reader should be able to find the next step without already knowing names such as `GitTarget`, attribution, or API-first publication. @@ -15,7 +15,7 @@ Keep architecture and configuration reference available alongside the task guide ## The four main categories These categories form the documentation landing page and the website's documentation navigation. -They describe reader journeys; they do not require matching physical folders in the first phase. +They describe reader journeys; they do not require matching physical folders. ### 1. Get started @@ -24,195 +24,169 @@ GitOps Reverser writes? If yes, follow the bi-directional path even when the imm to record cluster changes. An existing deployment loop needs coordination regardless of whether anyone plans to edit Git manually. -#### Capture cluster state in Git +**Capture cluster state in Git.** This path reaches a verified commit without an applier claim or +a Git push webhook. Describe it as capturing selected cluster state: batching and restart recovery +mean Git history is not a complete record of every event. - **Install GitOps Reverser.** For a cluster administrator: prerequisites, credentials, permissions, installation, a first captured change, verification, and cleanup. Keep the default path usable - without audit attribution. Start from the [quick start](../quickstart.md). + without audit attribution. - **Start capturing a team's resources.** For a platform administrator or team owner: choose the source cluster, namespaces, resource selection, repository, branch, and folder. Cover adopting an existing repository through a scratch branch, supported layouts, deletion policy, and - cross-namespace access. Build a focused guide from [configuration](../configuration.md) and - the repository adoption guidance in the [project overview](../../README.md). -- **Record who made a change (optional).** For an administrator configuring authenticated Git - authorship: check cluster eligibility, configure audit delivery and attribution, and verify a - known actor's commit. Use the existing [attribution setup guide](../attribution-setup-guide.md) - as the home for this journey. See the dedicated scope below. - -This path reaches a verified commit without an applier claim or Git push webhook. Describe it as -capturing selected cluster state: batching and restart recovery mean Git history is not a complete -record of every event. Attribution is optional in both setup paths. - -#### Coordinate changes from Kubernetes and Git - -- **Choose bi-directional coordination.** Use [bi-directional GitOps](../bi-directional.md) as the - overview and entry point. Explain when the shared-folder deployment loop needs coordination, - then build on the installation and resource-selection steps above. + cross-namespace access. +- **Record who made a change (optional in both paths).** Check cluster eligibility, configure audit + delivery and attribution, and verify a known actor's commit. See the dedicated scope below. + +**Coordinate changes from Kubernetes and Git.** Build on the installation and resource-selection +steps above. + +- **Choose bi-directional coordination.** Explain when the shared-folder deployment loop needs + coordination, from the [bi-directional GitOps](../bi-directional.md) overview. - **Configure the applier.** Follow the [Flux](../bi-directional-flux.md) or - [Argo CD](../bi-directional-argocd.md) setup guide to configure the claim and connect the target. + [Argo CD](../bi-directional-argocd.md) guide to configure the claim and connect the target. Explain who controls applying the folder and what happens when the operator is unavailable. -- **React promptly to Git pushes.** For an administrator enabling edits from both Git and - Kubernetes: configure the Git host's push webhook and a `GitReceiver`, then verify delivery and - the resulting apply. Make this an explicit step in bi-directional setup and link it from - "Make a change through Git." See the dedicated scope below. +- **React promptly to Git pushes.** Configure the Git host's push webhook and a `GitReceiver`. + See the dedicated scope below. - **Verify both directions.** Capture a Kubernetes edit, then push a Git edit and verify the - coordinated apply. Link to the everyday change guides and delayed-apply troubleshooting. - -#### Shared setup guidance - -Installation happens at the platform level; onboarding repeats for each team or destination. -Keep those outcomes separate so a team can start using an existing installation without repeating -the administrator's setup. + coordinated apply. -Link supporting setup guides where readers need them: [GitHub](../github-setup-guide.md), +Installation happens at the platform level; onboarding repeats for each team or destination. Keep +those outcomes separate so a team can start using an existing installation without repeating the +administrator's setup. Link the supporting guides ([GitHub](../github-setup-guide.md), [Azure DevOps](../azure-devops-getting-started.md), [RBAC](../rbac.md), -[Secret encryption](../sops-age-guide.md), and [commit signing](../commit-signing.md). -Explain when each capability is required or optional. +[Secret encryption](../sops-age-guide.md), [commit signing](../commit-signing.md)) where readers +need them, and say when each capability is required or optional. ### 2. Make changes - **Make a change through Kubernetes.** For an end user: create, update, or delete a selected - resource; follow the change into Git; understand timing and refusal outcomes. Include a simple - worked example and links to deletion policy and supported manifest layouts. + resource; follow the change into Git; understand timing and refusal outcomes. - **Group changes and explain why.** For an end user: collect related edits, attach intent through a `CommitRequest`, and verify the resulting commit or no-commit outcome. Explain author matching - and window timing. Build from the [commit-window demo](../demo/commit-window.md) and - [commit messages](../commit-messages.md). + and window timing. - **Work alongside other people on the same branch.** For collaborating teams: explain how authors and targets share a branch, what splits a commit window, and what happens when edits overlap. - Link to the shared conflict and publication contract in - [API-first publication](../api-first-publication.md). -- **Make a change through Git.** For an end user and the administrator enabling this workflow: - explain how an external push reaches the cluster through Flux or Argo CD, what happens to pending - Kubernetes edits, and how to verify the apply. Link back to the bi-directional setup prerequisites; - keep the common explanation in its overview and the everyday example in this task guide. - -Distinguish another person's Kubernetes edit from another person's Git push. Both can affect the -same branch, but they exercise different parts of the system and need different worked examples. -Link [installing applications as Kubernetes resources](../installing-apps-as-krm.md) as an expanded -example of choosing which declarations to capture. +- **Make a change through Git.** For an end user: explain how an external push reaches the cluster + through Flux or Argo CD, what happens to pending Kubernetes edits, and how to verify the apply. + Link back to the bi-directional setup prerequisites. + +Another person's Kubernetes edit and another person's Git push can both affect the same branch, +but they exercise different parts of the system and need different worked examples. ### 3. Operate -- **Monitor health and resource usage.** For administrators: check readiness, publication progress, - latency, backlog, memory, watch count, and Git activity. Connect each observation to a question - and a next action. Use [interpreting metrics](../interpreting-metrics.md) as the shared reference. +- **Monitor health and resource usage.** For administrators: readiness, publication progress, + latency, backlog, memory, watch count, and Git activity, each tied to a question and a next + action. - **Investigate a missing or unexpected change.** For administrators and end users: start from - symptoms such as no commit, an unresolved author, a refused folder, or a delayed apply. Identify - the relevant conditions, events, logs, and metrics. Explain recovery after an outage or restart, - including the limits of retained work and deletion recovery. -- **Upgrade an installation.** For administrators: identify migration requirements, apply the - upgrade, and verify the installation afterward. Keep release-specific changes in the - [upgrade guide](../UPGRADING.md); link to them from a stable upgrade procedure. + symptoms such as no commit, an unresolved author, a refused folder, or a delayed apply. Explain + recovery after an outage or restart, including the limits of retained work and deletion recovery. +- **Upgrade an installation.** A stable procedure that links to the release-specific entries. Keep routine monitoring separate from symptom-driven troubleshooting. Readers investigating an incident should reach a diagnostic path without first learning the entire metrics vocabulary. ### 4. Understand and reference -- **Architecture.** A concise account of the system: one Kubernetes edit reaching Git, one Git - edit reaching the cluster, component ownership, and safety and recovery boundaries. Refactor - [architecture](../architecture.md) around that reading path. -- **Configuration.** The resource relationships, settings, defaults, and examples. Keep - [configuration](../configuration.md) authoritative and link to it from task guides. +- **Architecture.** One Kubernetes edit reaching Git, one Git edit reaching the cluster, component + ownership, and safety and recovery boundaries. Move field inventories, OIDC setup, and + implementation history to their guides or references. Qualify recovery guarantees at first + mention: deletion policy matters, unpublished work is held in memory, and high availability is + unfinished. Keep the "optional but advised" wording for Redis and explain that planned HA + requires it. +- **Configuration.** Resource relationships, settings, defaults, and examples. Stays authoritative; + task guides link to it. - **Security model.** Credentials, permissions, source identity, namespace boundaries, encryption, - and audit ingress trust. Use [the security model](../security-model.md) and the RBAC guide. + and audit ingress trust. - **Supported manifest layouts.** What the writer can edit, what it refuses, how it preserves - existing documents, and how it places new ones. Provide a reader-facing explanation linked to - the [support contract](../design/support-boundary/support-contract.md) and configuration reference. -- **API, Helm, and command reference.** Exact fields, chart values, flags, and defaults. Establish - an explicit source for each surface and check it against the implementation. Start with - configuration and the [Helm chart reference](../../charts/gitops-reverser/README.md). -- **Glossary.** Extract the reader vocabulary from [definitions](../definitions.md) into a - published glossary. Keep maintainer naming rules in the existing document, linking to the - extracted definitions so each term still has one authoritative definition. Update citations - and anchors as part of that split. - -Keep [the component inventory](../components.md) and deeper implementation records accessible to -contributors through the existing [maintainer index](../INDEX.md). Rewrite the existing -[user landing page](../README.md) around the four categories. - -## Guide inventory and concrete backlog - -This is the initial mapping, completed as part of this plan. "Exists" means a usable source exists; -it still needs publication review. "Needs split" means extract or separate reader content from a -broader document. "Missing" means a dedicated task guide is absent, even where source material -already exists. Proposed filenames are decisions for implementation, not links to absent pages. - -| Guide or source | Category or destination | State and action | + existing documents, and how it places new ones. +- **API, Helm, and command reference.** Exact fields, chart values, flags, and defaults, each with + an explicit source that is checked against the implementation. +- **Glossary.** The reader vocabulary from [definitions](../definitions.md). Maintainer naming rules + stay in that document and link to the glossary, so each term keeps one authoritative definition. + +The existing [maintainer index](../INDEX.md) stays the contributors' entry point. The existing +[user landing page](../README.md) is rewritten around the four categories. + +## Guide inventory + +"Exists" means a usable source exists; it still needs publication review. "Needs split" means +separating reader content from a broader document. "Missing" means no dedicated task guide exists, +even where source material does. Proposed guides are named by task, not by filename. + +| Guide or source | Destination | State and action | | --- | --- | --- | -| `docs/README.md` | Documentation landing page | Exists: rewrite navigation | -| Root `README.md` | Homepage | Exists: reuse product explanation and demo | +| `docs/README.md` | Documentation landing page | Exists: rewrite around the four categories | +| Root `README.md` | Homepage | Exists: reuse the product explanation and demo | | `alternatives.md` | Homepage: is this right for me? | Exists: link from adoption guidance | -| `quickstart.md` | Get started: capture cluster state | Exists: verify first commit without an applier or push webhook | -| Team onboarding | Get started: capture cluster state | Missing: extract selection, sharing, and repository adoption; check for an existing applier | -| `github-setup-guide.md` | Get started: GitHub credentials | Exists: link to push-notification setup | -| `azure-devops-getting-started.md` | Get started: Azure DevOps credentials | Exists: retain provider-specific setup | -| `attribution-setup-guide.md` | Get started: record who | Exists: retain outcome and eligibility opening | -| React promptly to Git pushes | Get started: bi-directional setup | Needs split: extract setup from `bi-directional.md`, add a GitHub walkthrough | -| `sops-age-guide.md` | Get started: encrypt sensitive resources | Exists: supporting guide | -| `commit-signing.md` | Get started: sign commits | Exists: supporting guide | +| `quickstart.md` | Get started: capture | Exists: verify a first commit without an applier claim or push webhook | +| Team onboarding | Get started: capture | Missing: extract selection, sharing, and repository adoption from `configuration.md` and the root README; ask whether an applier deploys the folder | +| `github-setup-guide.md` | Get started: Git host | Exists: link to push-notification setup | +| `azure-devops-getting-started.md` | Get started: Git host | Exists: state the push-notification limit described below | +| `attribution-setup-guide.md` | Get started: record who | Exists: keep its outcome and eligibility opening | +| `audit-setup/cluster/readme.md` | Get started: record who | Exists: publish as the k3s example the attribution guide links | +| `bi-directional.md` | Get started: coordination overview | Needs split: keep the setup decision and common behavior; extract receiver setup into the push-notification guide and the everyday Git edit into Make changes | +| `bi-directional-flux.md`, `bi-directional-argocd.md` | Get started: applier setup | Exists: link receiver setup and verify both directions | +| `sops-age-guide.md`, `commit-signing.md` | Get started: supporting guides | Exists | | Normal Kubernetes change | Make changes | Missing: expand the quick-start example into create, update, delete, and refusal outcomes | -| `demo/commit-window.md` | Make changes: group changes and explain why | Exists: use as the worked task guide | -| `commit-messages.md` | Understand and reference; linked from grouped changes | Exists: keep template reference separate from the task | -| Collaboration on a shared branch | Make changes | Missing: use publication semantics and multi-author examples | -| `api-first-publication.md` | Understand and reference; linked from collaboration | Exists: shared publication contract | -| `bi-directional.md` | Get started: bi-directional overview | Needs split: retain the setup decision and common behavior, extract receiver setup and everyday Git-edit example | -| `bi-directional-flux.md` | Get started: bi-directional Flux setup | Exists: link to receiver setup and verification of both directions | -| `bi-directional-argocd.md` | Get started: bi-directional Argo CD setup | Exists: link to receiver setup and verification of both directions | -| Make a change through Git | Make changes | Needs split: extract the everyday example from bi-directional guidance and link to setup prerequisites | -| `installing-apps-as-krm.md` | Make changes: capture application intent | Exists: expanded example | -| `interpreting-metrics.md` | Operate: monitor | Exists: shared metric reference and interpretation | +| `demo/commit-window.md` | Make changes: group and explain | Exists: the worked task guide | +| `commit-messages.md` | Reference, linked from grouped changes | Exists: template reference | +| Collaboration on a shared branch | Make changes | Missing: publication semantics and multi-author examples | +| `api-first-publication.md` | Reference, linked from collaboration | Exists: shared publication contract | +| `installing-apps-as-krm.md` | Make changes | Exists: expanded example of choosing which declarations to capture | +| `interpreting-metrics.md` | Operate: monitor | Exists: shared metric reference | | Symptom-driven troubleshooting | Operate | Missing: gather diagnostic paths from configuration and metrics | -| Stable upgrade procedure | Operate | Missing: steps and verification linking to release changes | -| `UPGRADING.md` | Operate: release-specific migrations | Exists: retain release entries | -| `architecture.md` | Understand and reference | Needs split: overview, setup detail, and implementation rationale | -| `configuration.md` | Understand and reference | Needs split: extract onboarding steps, retain exact settings | -| `security-model.md` and `rbac.md` | Understand and reference; linked from setup | Exists: trust boundaries and access | -| Supported manifest layouts | Understand and reference | Needs split: reader guide from configuration and the support contract | -| `definitions.md` | Understand and reference: glossary | Needs split: publish vocabulary, retain maintainer naming rules | -| Chart `README.md` | Understand and reference: Helm | Exists: published reference sourced from the chart | -| API and command reference | Understand and reference | Needs split: consolidate existing field and flag descriptions, identify uncovered surfaces | -| `INDEX.md` and `components.md` | Maintainer entry point | Exists: retain repository links | -| `CONTRIBUTING.md`, `style-guide.md`, and e2e debugging | Maintainer entry point | Exists: retain repository links | +| Stable upgrade procedure | Operate | Missing: steps and verification linking to release entries | +| `UPGRADING.md` | Operate: release migrations | Exists: keep release entries | +| `architecture.md` | Reference | Needs split: overview, setup detail, implementation rationale | +| `configuration.md` | Reference | Needs split: extract onboarding steps, keep exact settings | +| `security-model.md`, `rbac.md` | Reference, linked from setup | Exists | +| Supported manifest layouts | Reference | Missing: reader guide sourced from the support contract and the `layout/` specs (`new-file-placement-rules.md`, `contextual-namespace.md`) | +| `definitions.md` | Reference: glossary | Needs split: publish the vocabulary, keep the naming rules | +| Chart `README.md` | Reference: Helm | Exists: published from the chart | +| API and command reference | Reference | Needs split: consolidate field and flag descriptions, find uncovered surfaces | +| `INDEX.md`, `components.md`, `CONTRIBUTING.md`, `style-guide.md` | Maintainer entry point | Exists: link to repository pages | ## Push notifications as an explicit setup activity -The outcome is "react promptly to Git pushes." Treat webhook setup as part of the normal -bi-directional installation path; capture-only installations can complete setup without it. -A Git-side commit leaves the cluster behind until the -coordinated apply runs; prompt notification shortens that interval and the opportunity for further -Kubernetes edits to overlap the incoming change. +The outcome is "react promptly to Git pushes." Webhook setup is part of the normal bi-directional +path; capture-only installations complete setup without it. A Git-side commit leaves the cluster +behind until the coordinated apply runs. Prompt notification shortens that interval and the +opportunity for further Kubernetes edits to overlap the incoming change. -The applier's usual reconciliation schedule is insufficient here: an applier claim holds the -applier, and GitOps Reverser decides when it applies. A rejected push or periodic refresh still -detects remote changes when webhook delivery is unavailable. Describe these as fallback paths, -with their latency, and preserve the existing conflict semantics. Do not promise that webhooks -prevent all clashes or that mirroring stops working without them. +The applier's own reconciliation schedule does not help: an applier claim holds the applier, and +GitOps Reverser decides when it applies. Without a webhook, a rejected push detects the remote +change at the next publication and the periodic refresh detects it within `--git-refresh-interval` +plus the reconcile tick ([bi-directional GitOps](../bi-directional.md#telling-gitops-reverser-that-somebody-else-pushed)). +Describe these as fallback paths with that latency. Do not promise that webhooks prevent all +clashes or that mirroring stops working without them. -Extract the setup from -[telling GitOps Reverser that somebody else pushed](../bi-directional.md#telling-gitops-reverser-that-somebody-else-pushed). -Keep [the GitReceiver reference](../configuration.md#gitreceiver) authoritative for fields and -delivery diagnostics. The task guide covers: +The task guide is extracted from that section; [the GitReceiver reference](../configuration.md#gitreceiver) +stays authoritative for fields and delivery diagnostics. It covers: 1. Enable and expose the receiver listener at an HTTPS endpoint reachable by the Git host. 2. Create the token Secret and `GitReceiver`, then read `status.webhookPath`. -3. Configure a GitHub push webhook with that URL and secret. Verify current host instructions - when writing the walkthrough; link other supported host variants from the reference. -4. Follow the documented Flux or Argo CD rules for existing applier webhooks. Keep audit delivery - and Git push notifications clearly named as separate webhook purposes. +3. Configure a GitHub push webhook with that URL and secret. Verify current host instructions when + writing the walkthrough; link the GitLab, Gitea, and `generic-hmac` variants from the reference. +4. Follow the documented Flux or Argo CD rules for existing applier webhooks. Name audit delivery + and Git push notifications as separate webhook purposes. 5. Push a known change and check host delivery, `GitBranch.status.lastPushNoticeAt`, the observed remote commit, and the applier's result. A successful HTTP response alone does not prove apply. 6. Diagnose unreachable endpoints, rejected signatures, repository URL mismatches, and unmatched - branches. Cover token rotation, which changes the webhook path, and missed-delivery recovery. + branches. Cover token rotation, which moves the webhook path, and missed-delivery recovery. -Link this guide from Git-host setup, both applier setup guides, and delayed-apply troubleshooting. +**Azure DevOps has no receiver type.** `GitReceiver` verifies `github`, `gitlab`, `gitea`, and +`generic-hmac` deliveries. The Azure DevOps guide states that coordination on Azure DevOps relies +on the fallback detection above, with its latency, and that prompt notification needs a relay of +the reader's own that signs deliveries as `generic-hmac`. Do not document a relay walkthrough until +one is tested. ## Attribution as a first-class optional journey -The outcome is "record who made a change." Explain that the author comes from an authenticated -Kubernetes identity, which can represent a person or a service account. Explain the operator's -separate committer identity and the explicit unresolved-author outcome. +The outcome is "record who made a change." The author comes from an authenticated Kubernetes +identity, which can represent a person or a service account. Explain the operator's separate +committer identity and the explicit unresolved-author outcome. Order the guide around these questions: @@ -225,142 +199,92 @@ Order the guide around these questions: 5. How can an administrator diagnose missing evidence or an unresolved author? 6. How can optional OIDC claim mapping add a friendly display name and email? -Retain the attribution setup guide's existing prerequisite: "managed control planes that hide -these settings cannot use this webhook." Its opening already states the outcome and the required -control over API-server flags and files. Preserve that precise boundary and the configured-author -installation path; verify any new provider-specific claims when writing them. +Keep the guide's existing boundary: "managed control planes that hide these settings cannot use +this webhook." Keep the configured-author installation path visible, and verify any new +provider-specific claim when writing it. Link this guide from normal changes and grouped changes. Keep "who made the change" and "why the change was needed" as distinct tasks. Explain the separate admission-based submitter capture used by `CommitRequest` where it affects matching, so readers do not assume audit setup controls both. -## How the guides fit together - -Give each task guide a consistent structure: +## Guide structure -1. Goal and intended reader. -2. Prerequisites, permissions, and optional capabilities. -3. Steps with a concrete example. -4. Expected result and how to verify it. -5. Relevant failure cases and next actions. -6. Links to the shared explanation and exact reference. +Give each task guide the same structure: goal and intended reader; prerequisites, permissions, and +optional capabilities; steps with a concrete example; expected result and how to verify it; +relevant failure cases and next actions; links to the shared explanation and exact reference. -Create a separate document when a task has its own meaningful outcome. Keep closely related +Create a separate document when a task has its own meaningful outcome; keep closely related variations together. Give each contract one authoritative home and link to it from the guides. -Shorten the architecture overview by moving field inventories, OIDC setup instructions, and -implementation history to their appropriate guides or references. Bring manifest editing and its -support boundary forward. Qualify recovery guarantees at first mention: deletion policy matters, -unpublished work is held in memory, and high availability is unfinished. Preserve the architecture's -"optional but advised" wording for Redis and explain that planned HA requires it. Distinguish -current single-replica behavior from that future requirement. - ## Static website -### Homepage and entry paths - -Create a product homepage with a short explanation of the problem GitOps Reverser solves, an -API-to-Git diagram, and an example change with its resulting commit. Give visitors two prominent -actions: "Install GitOps Reverser" and "Understand how it works." - -Show the supported workflows and important adoption boundaries before installation. Include a -visible link to attribution eligibility. Present "Capture cluster state in Git" and "Coordinate -changes from Kubernetes and Git" as distinct setup paths under Get started, with the shared-folder -decision visible before readers choose. Keep the four top-level categories. -Use [alternatives](../alternatives.md) for the "is this right for me?" decision. Reuse existing demos -and diagrams where they explain the behavior clearly. - -The documentation landing page exposes the four categories above. Add search, page-local contents, -and related next steps. Keep source code, releases, contribution guidance, and issue reporting -easy to find without placing maintainer plans in the main user navigation. - -### Content and publishing requirements - -- Build static pages from repository Markdown with one maintained source per guide. Keep the - source readable on GitHub and avoid a separately maintained website copy of the documentation. -- Define a curated publication manifest. Exclude `spec/`, `design/`, `facts/`, `finished/`, - `layout/`, `future/`, `review/`, `local-only/`, and `*.ignore.md` from publication and search by - default. Publish extracted reader guides deliberately; link remaining maintainer references to - their repository pages. Only explicitly listed pages enter the build. -- Require every published Markdown source to appear in [.docs-lint-scope](../../.docs-lint-scope). - Check this inclusion automatically in CI; the prose scope may also contain unpublished contracts. - Add pages to the gate as they are cleaned, before publishing them. -- Require Mermaid rendering and existing image support. Preserve relative `.md` links in source - so GitHub navigation works; resolve published targets to site routes and unpublished references - to the corresponding repository revision. -- Preserve stable page URLs and heading anchors. Record redirects when pages move or split, and - retain valid repository links and source-code citations. -- Generate search only from the publication manifest and check the installation path on mobile - and with keyboard navigation. -- Label which release the installation instructions and reference describe. Keep development docs - identifiable; decide whether archived release versions are needed before adding a version selector. -- Provide a reproducible local build, a preview for review, and automated checks for generated - links, assets, and site output before publication. - -Choose the static site generator, hosting service, domain, and release publication policy during -implementation. Evaluate them against these requirements and maintenance cost. The content plan -does not depend on a particular framework or hosting provider. +**Homepage.** A short explanation of the problem, an API-to-Git diagram, and an example change +with its resulting commit. Two prominent actions: "Install GitOps Reverser" and "Understand how it +works." Show the supported workflows, the shared-folder decision between the two setup paths, the +attribution eligibility link, and [alternatives](../alternatives.md) before installation. Keep +source code, releases, contribution guidance, and issue reporting easy to find. + +**Requirements.** + +- Build from repository Markdown with one maintained source per guide. Keep relative `.md` links + in the source so GitHub navigation works; resolve published targets to site routes and + unpublished references to the repository revision. +- Publish only pages listed in a curated publication manifest. `spec/`, `design/`, `facts/`, + `finished/`, `layout/`, `future/`, `review/`, `local-only/`, and `*.ignore.md` stay unpublished; + extracted reader guides are published deliberately. Search covers the manifest only. +- Every published Markdown source is also listed in [.docs-lint-scope](../../.docs-lint-scope), so + Vale gates it. CI checks the inclusion. The scope may also list unpublished contracts. +- Render Mermaid diagrams and existing images. +- Preserve page URLs and heading anchors; record redirects when pages move or split. +- Label which release the installation instructions and reference describe. Decide whether archived + release versions are needed before adding a version selector. +- Provide a reproducible local build and a preview for review. + +The generator, hosting, domain, and release publication policy are chosen during implementation +against these requirements and their maintenance cost. ## Implementation phases -### Phase 1: Apply the inventory to navigation - -- Use the inventory above to rewrite `docs/README.md` under the four categories, with both setup - paths under Get started and `bi-directional.md` as the coordination entry point. -- Retain `docs/INDEX.md` as the maintainer entry point and link it explicitly. -- Give every inventory row a destination or a named extraction task; avoid empty public pages. -- Preserve the existing maintainer lifecycle folders and source citations. Decide physical moves - only when they improve maintenance; navigation can provide the new structure immediately. +**Phase 1: Navigation.** Rewrite `docs/README.md` under the four categories from the inventory, +with both setup paths under Get started. Link `docs/INDEX.md` as the maintainer entry point. Keep +the lifecycle folders and source citations; navigation provides the new structure without moving +files. -### Phase 2: Complete the essential reading paths +**Phase 2: Reading paths.** -- Establish capture-only installation through first verified commit as the shortest complete path. -- Fill the missing onboarding, normal-change, collaboration, troubleshooting, and stable upgrade - guides identified above. -- Complete the bi-directional path: applier setup, push notifications with a GitHub example, and - verification of both directions. Link everyday Git edits back to these prerequisites. -- Complete the marked splits and attribution ordering, checking examples against supported behavior. +- Capture-only installation through a first verified commit, as the shortest complete path. +- The missing onboarding, normal-change, collaboration, troubleshooting, and upgrade guides. +- The bi-directional path: applier setup, push notifications with a GitHub example, the Azure + DevOps limit, and verification of both directions. +- The marked splits and the attribution ordering. +- Bring each source the manifest will publish under the prose gate. Measured on 2026-10-26, these + fail Vale: `UPGRADING.md` (135 errors), `installing-apps-as-krm.md` (15), `rbac.md` (10), + `security-model.md` (6), `api-first-publication.md` (5), `azure-devops-getting-started.md` (3), + and `alternatives.md` (1). The other inventoried sources pass and are already gated. -### Phase 3: Build the website +**Phase 3: Website.** Select the generator and hosting, build the homepage and the curated +documentation, validate the reading paths in a preview, then configure the domain and publish. -- Select the generator and hosting, then implement the homepage and curated documentation build. -- Add navigation, search, diagram rendering, link resolution, release labeling, and previews. -- Validate the essential reading paths in a rendered preview, including mobile and keyboard use. -- Configure the chosen domain and publication workflow, then publish the reviewed site. - -### Phase 4: Maintain it with the product - -- Update the affected task guide and shared contract when behavior changes. -- Validate installation examples against the release they document. -- Check source and rendered links, preserve redirects, and keep the publication list explicit. -- Use installation feedback and support questions to improve the task paths. +After launch, a behavior change updates the affected task guide and shared contract in the same +change, and support questions feed back into the task paths. ## Completion criteria - Every inventory row has a published destination or an intentional maintainer-only destination. -- The homepage links directly to installation and architecture; the documentation landing page - presents all four categories with working task links and both setup paths under Get started. -- A recorded walkthrough against the documented release reaches a first commit from installation, - without an applier claim or Git push webhook, then onboards a second target using the existing - installation. Each guide names the expected observable result. +- The homepage links directly to installation and architecture. The documentation landing page + presents the four categories with working task links and both setup paths under Get started. +- A CI job runs the quick start's commands against the documented release, reaches a first commit + without an applier claim or Git push webhook, then onboards a second target on the same + installation. - Onboarding asks whether an applier deploys the same folder and routes that case to bi-directional setup. The Flux and Argo CD paths each verify a Kubernetes edit reaching Git and a Git edit - reaching the cluster. The everyday Git-edit guide links back to those prerequisites. -- The attribution page states its outcome and eligibility before commands, retains the - configured-author path, and includes a known-actor verification example. + reaching the cluster. - The GitHub receiver walkthrough verifies host delivery, branch notification, remote observation, - and apply outcome. Both applier guides link to it and document fallback detection. + and apply outcome. The Azure DevOps guide states its fallback latency and the relay option. +- The attribution page states its outcome and eligibility before commands, keeps the + configured-author path, and includes a known-actor verification example. - Worked examples cover a Kubernetes edit, a Git push, and grouped changes with intent. - Troubleshooting includes no commit, unresolved author, refused folder, and delayed apply. -- CI rejects a published Markdown source absent from `.docs-lint-scope`, an unlisted built page, - or a broken rendered link or asset. Excluded maintainer pages do not enter the search index. -- The preview renders the existing Mermaid diagrams and preserves headings and relative links; - changed routes have tested redirects. Installation works on mobile and with keyboard navigation. -- `task lint-docs`, Vale on touched prose, and the site build checks pass. Website configuration - changes also pass the applicable repository validation gates. - -## Change isolation - -The plan was drafted on `docs-metrics-architecture-inventory`. Carry it and the documentation work -in a separate documentation PR from the metrics changes. Preserve unrelated work and avoid a -branch-wide commit or file move as part of the navigation rewrite. + Troubleshooting covers no commit, unresolved author, refused folder, and delayed apply. +- CI rejects a published source absent from `.docs-lint-scope`, an unlisted built page, a broken + rendered link or asset, and a changed route without a redirect. +- `task lint-docs` and the site build checks pass. diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index ee0df400..aa8f41fe 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -20,7 +20,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -174,20 +173,21 @@ func (r *ApplierClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request st := beginStatus(r.Client, r.Recorder, claim) switch t := claim.(type) { case *configbutleraiv1alpha3.FluxClaim: - return r.reconcileFlux(ctx, st, t) + return reconcileHeldClaim(ctx, r, st, t, r.holdFlux) case *configbutleraiv1alpha3.ArgoCDClaim: - return r.reconcileArgoCD(ctx, st, t) + return reconcileHeldClaim(ctx, r, st, t, r.holdArgoCD) } return ctrl.Result{}, fmt.Errorf("no applier claim kind %q", r.Kind) } -// reconcileFlux is Reconcile for a FluxClaim: one being deleted, or that no eligible GitTarget -// references any more, hands its Kustomization back; any other holds it while it is Configured. -func (r *ApplierClaimReconciler) reconcileFlux( - ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.FluxClaim, +// reconcileHeldClaim is Reconcile for one applier claim: one being deleted, or that no eligible GitTarget +// references any more, hands its applier back; any other holds it with hold while it is Configured. +func reconcileHeldClaim[C configbutleraiv1alpha3.ApplierClaim]( + ctx context.Context, r *ApplierClaimReconciler, st *reconcileStatus, claim C, + hold func(context.Context, *reconcileStatus, C, claimCheck) (conditionValue, error), ) (ctrl.Result, error) { - if !claim.DeletionTimestamp.IsZero() { - if !controllerutil.ContainsFinalizer(claim, configbutleraiv1alpha3.FluxClaimFinalizer) { + if !claim.GetDeletionTimestamp().IsZero() { + if !applier.Holding(claim) { return ctrl.Result{}, nil } return r.retire(ctx, st, claim, nil) @@ -197,7 +197,7 @@ func (r *ApplierClaimReconciler) reconcileFlux( if check.retiring(claim) { return r.retire(ctx, st, claim, &check) } - inControl, err := r.holdFlux(ctx, st, claim, check) + inControl, err := hold(ctx, st, claim, check) if err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/argocdclaim_hold.go b/internal/controller/argocdclaim_hold.go index ed6923ce..89e047fd 100644 --- a/internal/controller/argocdclaim_hold.go +++ b/internal/controller/argocdclaim_hold.go @@ -11,8 +11,6 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8stypes "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/applier" @@ -41,32 +39,6 @@ import ( // held off by GitOps Reverser, or kept off by the switch's owner (the message says which). const ReasonAutomatedSyncOff = "AutomatedSyncOff" -// reconcileArgoCD is Reconcile for an ArgoCDClaim: one being deleted, or that no eligible -// GitTarget references any more, hands its Application's automated sync back; any other holds it -// off while it is Configured. -func (r *ApplierClaimReconciler) reconcileArgoCD( - ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.ArgoCDClaim, -) (ctrl.Result, error) { - if !claim.DeletionTimestamp.IsZero() { - if !controllerutil.ContainsFinalizer(claim, configbutleraiv1alpha3.ArgoCDClaimFinalizer) { - return ctrl.Result{}, nil - } - return r.retire(ctx, st, claim, nil) - } - check := r.configuredVerdict(ctx, claim) - publishCheck(st, claim, check) - if check.retiring(claim) { - return r.retire(ctx, st, claim, &check) - } - inControl, err := r.holdArgoCD(ctx, st, claim, check) - if err != nil { - return ctrl.Result{}, err - } - st.setValue(ApplierClaimConditionInControl, inControl) - return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &inControl), &inControl, - heldClaimSteadyInterval) -} - // holdArgoCD returns an ArgoCDClaim's InControl condition. A Configured applier claim holds its // Application's automated sync off. One that is not Configured holds nothing, and one in line // behind another holder lets go of a finalizer it carries, handing nothing back. diff --git a/internal/controller/dependency_watches.go b/internal/controller/dependency_watches.go index e6e6e2a3..5c6353c7 100644 --- a/internal/controller/dependency_watches.go +++ b/internal/controller/dependency_watches.go @@ -8,6 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -96,3 +97,22 @@ func ruleDependencyWatches( Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(namespaces), builder.WithPredicates(predicate.LabelChangedPredicate{})) } + +// specOrStatusChanged passes create and delete, and an update that changed the spec (generation) or +// what summary reads of the status: the status-only update GenerationChangedPredicate drops. An +// update whose objects are not a T is let through. +func specOrStatusChanged[T client.Object, S comparable](summary func(T) S) predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { return true }, + DeleteFunc: func(event.DeleteEvent) bool { return true }, + GenericFunc: func(event.GenericEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { + before, ok1 := e.ObjectOld.(T) + after, ok2 := e.ObjectNew.(T) + if !ok1 || !ok2 { + return true + } + return before.GetGeneration() != after.GetGeneration() || summary(before) != summary(after) + }, + } +} diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index eadb9996..8e5bc34b 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -1735,43 +1735,17 @@ func kubeProviderReadyStatus(cp *configbutleraiv1alpha3.KubeProvider) metav1.Con // controller: fire on create/delete, and on an update when the SPEC changed (generation) OR the // Ready condition status changed — the latter a status-only update GenerationChangedPredicate drops. func kubeProviderReadyOrSpecChanged() predicate.Predicate { - return predicate.Funcs{ - CreateFunc: func(event.CreateEvent) bool { return true }, - DeleteFunc: func(event.DeleteEvent) bool { return true }, - GenericFunc: func(event.GenericEvent) bool { return false }, - UpdateFunc: func(e event.UpdateEvent) bool { - oldKP, ok1 := e.ObjectOld.(*configbutleraiv1alpha3.KubeProvider) - newKP, ok2 := e.ObjectNew.(*configbutleraiv1alpha3.KubeProvider) - if !ok1 || !ok2 { - return true - } - return oldKP.Generation != newKP.Generation || - kubeProviderReadyStatus(oldKP) != kubeProviderReadyStatus(newKP) - }, - } + return specOrStatusChanged(kubeProviderReadyStatus) } // gitBranchReadyOrSpecChanged is the GitBranch watch predicate for the GitTarget controller: fire on // create/delete, and on an update when the spec changed or the Ready condition's status or reason // did. A status.remote write alone is filtered. func gitBranchReadyOrSpecChanged() predicate.Predicate { - readyOf := func(b *configbutleraiv1alpha3.GitBranch) string { + return specOrStatusChanged(func(b *configbutleraiv1alpha3.GitBranch) string { if c := findCondition(b.Status.Conditions, ConditionTypeReady); c != nil { return string(c.Status) + "/" + c.Reason } return "" - } - return predicate.Funcs{ - CreateFunc: func(event.CreateEvent) bool { return true }, - DeleteFunc: func(event.DeleteEvent) bool { return true }, - GenericFunc: func(event.GenericEvent) bool { return false }, - UpdateFunc: func(e event.UpdateEvent) bool { - oldB, ok1 := e.ObjectOld.(*configbutleraiv1alpha3.GitBranch) - newB, ok2 := e.ObjectNew.(*configbutleraiv1alpha3.GitBranch) - if !ok1 || !ok2 { - return true - } - return oldB.Generation != newB.Generation || readyOf(oldB) != readyOf(newB) - }, - } + }) } diff --git a/internal/controller/gittarget_dependency_status.go b/internal/controller/gittarget_dependency_status.go index ac257fce..172fd42d 100644 --- a/internal/controller/gittarget_dependency_status.go +++ b/internal/controller/gittarget_dependency_status.go @@ -6,7 +6,6 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" @@ -74,23 +73,13 @@ func gitTargetReadyCondition(target configbutleraiv1alpha3.GitTarget) conditionV // reports the same reason as a stall or as progress, so a target can flip Stalled while its mirrored // Status and Reason stay put, and the rule judges its stall from that verdict (see ruleReadiness). func gitTargetReadyProjectionChanged() predicate.Predicate { - return predicate.Funcs{ - CreateFunc: func(event.CreateEvent) bool { return true }, - DeleteFunc: func(event.DeleteEvent) bool { return true }, - GenericFunc: func(event.GenericEvent) bool { return false }, - UpdateFunc: func(e event.UpdateEvent) bool { - oldTarget, ok1 := e.ObjectOld.(*configbutleraiv1alpha3.GitTarget) - newTarget, ok2 := e.ObjectNew.(*configbutleraiv1alpha3.GitTarget) - if !ok1 || !ok2 { - return true - } - if oldTarget.Generation != newTarget.Generation { - return true - } - before := gitTargetReadyCondition(*oldTarget) - after := gitTargetReadyCondition(*newTarget) - return before.Status != after.Status || before.Reason != after.Reason || - gitTargetStalled(*oldTarget) != gitTargetStalled(*newTarget) - }, + type projection struct { + status metav1.ConditionStatus + reason string + stalled bool } + return specOrStatusChanged(func(target *configbutleraiv1alpha3.GitTarget) projection { + ready := gitTargetReadyCondition(*target) + return projection{status: ready.Status, reason: ready.Reason, stalled: gitTargetStalled(*target)} + }) } diff --git a/internal/queue/fact_index_store.go b/internal/queue/fact_index_store.go index 6e6739f0..927a0ad7 100644 --- a/internal/queue/fact_index_store.go +++ b/internal/queue/fact_index_store.go @@ -170,23 +170,14 @@ func newScopeFacts() *scopeFacts { // putExact stores the immutable (uid, rv) fact. func (s *scopeFacts) putExact(uid, rv string, entry *indexedFact) { - key := exactFactKey{uid: uid, rv: rv} - if _, ok := s.exact[key]; !ok { - s.count++ - } - s.exact[key] = entry - s.order = append(s.order, factRef{kind: factKindExact, uid: uid, rv: rv, seq: entry.seq}) + putKeyed(s, s.exact, exactFactKey{uid: uid, rv: rv}, entry, factRef{kind: factKindExact, uid: uid, rv: rv}) } // putLatest stores the last-writer-wins pointer for an object. Entries are applied in delivery // order, which is what makes "last writer" mean the last fact appended rather than whichever // goroutine got there first. func (s *scopeFacts) putLatest(uid string, entry *indexedFact) { - if _, ok := s.latest[uid]; !ok { - s.count++ - } - s.latest[uid] = entry - s.order = append(s.order, factRef{kind: factKindLatest, uid: uid, seq: entry.seq}) + putKeyed(s, s.latest, uid, entry, factRef{kind: factKindLatest, uid: uid}) } // putRemoval stores the sticky removal pointer for an object: the fact that it was DELETED, and by @@ -203,32 +194,32 @@ func (s *scopeFacts) putLatest(uid string, entry *indexedFact) { // // A later removal fact may replace it, because that is a statement about the same question. func (s *scopeFacts) putRemoval(uid string, entry *indexedFact) { - if _, ok := s.removals[uid]; !ok { - s.count++ - } - s.removals[uid] = entry - s.order = append(s.order, factRef{kind: factKindRemoval, uid: uid, seq: entry.seq}) + putKeyed(s, s.removals, uid, entry, factRef{kind: factKindRemoval, uid: uid}) } // putRV stores the rv-only escape hatch. func (s *scopeFacts) putRV(rv string, entry *indexedFact) { - if _, ok := s.rvOnly[rv]; !ok { - s.count++ - } - s.rvOnly[rv] = entry - s.order = append(s.order, factRef{kind: factKindRV, rv: rv, seq: entry.seq}) + putKeyed(s, s.rvOnly, rv, entry, factRef{kind: factKindRV, rv: rv}) } // putName stores the (namespace, name) floor. Like putLatest it is last-writer-wins: two writes to // one name inside the TTL leave the later actor, which is the same answer the uid tier would give // for the same pair of writes. func (s *scopeFacts) putName(namespace, name string, entry *indexedFact) { - key := nameFactKey{namespace: namespace, name: name} - if _, ok := s.byName[key]; !ok { + putKeyed(s, s.byName, nameFactKey{namespace: namespace, name: name}, entry, + factRef{kind: factKindName, namespace: namespace, name: name}) +} + +// putKeyed stores entry under key in one keyed tier, and its reference last in order. It is counted +// only when the key is new: the entry it overwrites leaves a stale reference in order, which remove +// skips. +func putKeyed[K comparable](s *scopeFacts, tier map[K]*indexedFact, key K, entry *indexedFact, ref factRef) { + if _, ok := tier[key]; !ok { s.count++ } - s.byName[key] = entry - s.order = append(s.order, factRef{kind: factKindName, namespace: namespace, name: name, seq: entry.seq}) + tier[key] = entry + ref.seq = entry.seq + s.order = append(s.order, ref) } // putCollection appends one collection fact. Collection facts are not keyed one per namespace: diff --git a/internal/typeset/observe.go b/internal/typeset/observe.go index edf3df8d..98952c97 100644 --- a/internal/typeset/observe.go +++ b/internal/typeset/observe.go @@ -95,23 +95,9 @@ func observationFromEntry( // (gvk-not-unique). Exact duplicates (same GVR) collapse, so a doubly-listed resource // is not mistaken for a conflict. func distinctGVRsByGVK(entries []Entry) map[schema.GroupVersionKind][]schema.GroupVersionResource { - seen := map[schema.GroupVersionKind]map[schema.GroupVersionResource]struct{}{} - for _, e := range entries { - if e.Subresource { - continue - } - if seen[e.GVK] == nil { - seen[e.GVK] = map[schema.GroupVersionResource]struct{}{} - } - seen[e.GVK][e.GVR] = struct{}{} - } - out := make(map[schema.GroupVersionKind][]schema.GroupVersionResource, len(seen)) - for gvk, set := range seen { - for gvr := range set { - out[gvk] = append(out[gvk], gvr) - } - } - return out + return distinctByKey(entries, func(e Entry) (schema.GroupVersionKind, schema.GroupVersionResource) { + return e.GVK, e.GVR + }) } // distinctGVKsByGVR indexes each resource to the distinct kinds it resolves to, so a @@ -121,20 +107,29 @@ func distinctGVRsByGVK(entries []Entry) map[schema.GroupVersionKind][]schema.Gro // hand-crafted (snapshot) surface; modelling it keeps the bijection honest in both // directions rather than silently picking a winner. func distinctGVKsByGVR(entries []Entry) map[schema.GroupVersionResource][]schema.GroupVersionKind { - seen := map[schema.GroupVersionResource]map[schema.GroupVersionKind]struct{}{} + return distinctByKey(entries, func(e Entry) (schema.GroupVersionResource, schema.GroupVersionKind) { + return e.GVR, e.GVK + }) +} + +// distinctByKey indexes the top-level entries by the key pair returns, to the distinct values it +// returns for that key. Subresources are skipped, and the order of each value list is unspecified. +func distinctByKey[K, V comparable](entries []Entry, pair func(Entry) (K, V)) map[K][]V { + seen := map[K]map[V]struct{}{} for _, e := range entries { if e.Subresource { continue } - if seen[e.GVR] == nil { - seen[e.GVR] = map[schema.GroupVersionKind]struct{}{} + key, value := pair(e) + if seen[key] == nil { + seen[key] = map[V]struct{}{} } - seen[e.GVR][e.GVK] = struct{}{} + seen[key][value] = struct{}{} } - out := make(map[schema.GroupVersionResource][]schema.GroupVersionKind, len(seen)) - for gvr, set := range seen { - for gvk := range set { - out[gvr] = append(out[gvr], gvk) + out := make(map[K][]V, len(seen)) + for key, set := range seen { + for value := range set { + out[key] = append(out[key], value) } } return out diff --git a/test/e2e/commit_request_e2e_test.go b/test/e2e/commit_request_e2e_test.go index 706b67d5..1f6a7bbf 100644 --- a/test/e2e/commit_request_e2e_test.go +++ b/test/e2e/commit_request_e2e_test.go @@ -40,43 +40,8 @@ var _ = Describe("Commit Request", Label("commit-request", "audit-consumer"), Or const commitWindow = "300s" BeforeAll(func() { - By("creating commit-request test namespace and applying git secrets") - testNs = testNamespaceFor("commit-request") - _, _ = kubectlRun("create", "namespace", testNs) - repo = SetupRepo( - resolveE2EContext(), - testNs, - fmt.Sprintf("e2e-commit-request-%d", GinkgoRandomSeed()), - ) - _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML) - Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to namespace") - applySOPSAgeKeyToNamespace(testNs) - - seed := GinkgoRandomSeed() - gitProvName = fmt.Sprintf("commit-request-gitprovider-%d", seed) - gitTargetName = fmt.Sprintf("commit-request-gittarget-%d", seed) - watchRuleName = fmt.Sprintf("commit-request-watchrule-%d", seed) - - createReadyGitProvider(gitProvName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) - - By(fmt.Sprintf("creating GitTarget with spec.commit.window=%s", commitWindow)) - createValidatedGitTargetWithCommitWindow( - gitTargetName, testNs, gitProvName, "e2e/commit-request-test", commitWindow) - - // Watch Deployments, not ConfigMaps: a fresh namespace contains NO Deployments, whereas every - // namespace is pre-populated with a kube-root-ca.crt ConfigMap that a configmaps WatchRule - // would match — its initial reconcile would establish main before the spec's own edit, hiding - // the pure "branch not even created until something is finalized" behaviour this suite tests. - applyDeploymentWatchRule(testNs, watchRuleName, gitTargetName) - verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Succeeded", "") - - // Gate on StreamsRunning=True before any spec creates a Deployment: under watch-first, "Ready" only - // means the watch set is reconciled, not that each watch has opened and finished its - // sendInitialEvents replay. A Deployment created before the (empty) initial replay completes - // is folded into that committer-authored reconcile and committed immediately — establishing - // main and breaking the "branch not even created until the window is finalized" assertion. - // StreamsRunning=True is the documented barrier that the object is a genuine live, windowed event. - waitForStreamsRunning(gitTargetName, testNs) + p := setupWindowedDeploymentPipeline("commit-request", "e2e/commit-request-test", commitWindow) + testNs, repo, gitProvName, gitTargetName, watchRuleName = p.ns, p.repo, p.gitProvider, p.gitTarget, p.watchRule }) AfterAll(func() { @@ -327,42 +292,8 @@ var _ = Describe("Commit Request Bundle (UC2)", Label("commit-request", "audit-c const commitWindow = "300s" BeforeAll(func() { - By("creating commit-request-bundle test namespace and applying git secrets") - testNs = testNamespaceFor("commit-request-bundle") - _, _ = kubectlRun("create", "namespace", testNs) - repo = SetupRepo( - resolveE2EContext(), - testNs, - fmt.Sprintf("e2e-commit-request-bundle-%d", GinkgoRandomSeed()), - ) - _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML) - Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to namespace") - applySOPSAgeKeyToNamespace(testNs) - - seed := GinkgoRandomSeed() - gitProvName = fmt.Sprintf("commit-request-bundle-gitprovider-%d", seed) - gitTargetName = fmt.Sprintf("commit-request-bundle-gittarget-%d", seed) - watchRuleName = fmt.Sprintf("commit-request-bundle-watchrule-%d", seed) - - createReadyGitProvider(gitProvName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) - - By(fmt.Sprintf("creating GitTarget with spec.commit.window=%s", commitWindow)) - createValidatedGitTargetWithCommitWindow( - gitTargetName, testNs, gitProvName, "e2e/commit-request-bundle", commitWindow) - - // Deployments only (no ConfigMaps): a fresh namespace has no Deployments, so - // main stays absent until the bundle is finalized — unlike a ConfigMap rule, - // which would mirror the pre-existing kube-root-ca.crt and establish main early. - applyDeploymentWatchRule(testNs, watchRuleName, gitTargetName) - verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Succeeded", "") - - // Gate on StreamsRunning=True before any spec creates a Deployment: under watch-first, "Ready" only - // means the watch set is reconciled, not that each watch has opened and finished its - // sendInitialEvents replay. A Deployment created before the (empty) initial replay completes - // is folded into that committer-authored reconcile and committed immediately — establishing - // main and breaking the "branch not even created until the window is finalized" assertion. - // StreamsRunning=True is the documented barrier that the object is a genuine live, windowed event. - waitForStreamsRunning(gitTargetName, testNs) + p := setupWindowedDeploymentPipeline("commit-request-bundle", "e2e/commit-request-bundle", commitWindow) + testNs, repo, gitProvName, gitTargetName, watchRuleName = p.ns, p.repo, p.gitProvider, p.gitTarget, p.watchRule }) AfterAll(func() { @@ -638,3 +569,53 @@ func expectCommitRequestCommitted(g Gomega, namespace, name, diagnostics string) g.Expect(outcome.Commit).NotTo(BeEmpty(), "a committed CommitRequest must report status.commit\n%s", outcome) return outcome.Commit } + +// windowedDeploymentPipeline is the pipeline a CommitRequest suite saves through: a namespace and +// Gitea repo of its own, a GitProvider, a GitTarget with a long spec.commit.window, and a WatchRule +// on Deployments. +type windowedDeploymentPipeline struct { + ns string + repo *RepoArtifacts + gitProvider string + gitTarget string + watchRule string +} + +// setupWindowedDeploymentPipeline creates the pipeline, every name derived from name, and returns +// once its streams run: from then on a Deployment is a live, windowed event, and nothing is on main +// until a commit window is finalized. +func setupWindowedDeploymentPipeline(name, basePath, commitWindow string) windowedDeploymentPipeline { + By("creating " + name + " test namespace and applying git secrets") + p := windowedDeploymentPipeline{ns: testNamespaceFor(name)} + _, _ = kubectlRun("create", "namespace", p.ns) + p.repo = SetupRepo(resolveE2EContext(), p.ns, fmt.Sprintf("e2e-%s-%d", name, GinkgoRandomSeed())) + _, err := kubectlRunInNamespace(p.ns, "apply", "-f", p.repo.SecretsYAML) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to namespace") + applySOPSAgeKeyToNamespace(p.ns) + + seed := GinkgoRandomSeed() + p.gitProvider = fmt.Sprintf("%s-gitprovider-%d", name, seed) + p.gitTarget = fmt.Sprintf("%s-gittarget-%d", name, seed) + p.watchRule = fmt.Sprintf("%s-watchrule-%d", name, seed) + + createReadyGitProvider(p.gitProvider, p.ns, p.repo.GitSecretHTTP, p.repo.RepoURLHTTP) + + By(fmt.Sprintf("creating GitTarget with spec.commit.window=%s", commitWindow)) + createValidatedGitTargetWithCommitWindow(p.gitTarget, p.ns, p.gitProvider, basePath, commitWindow) + + // Watch Deployments, not ConfigMaps: a fresh namespace contains NO Deployments, whereas every + // namespace is pre-populated with a kube-root-ca.crt ConfigMap that a configmaps WatchRule + // would match — its initial reconcile would establish main before the spec's own edit, hiding + // the pure "branch not even created until something is finalized" behaviour these suites test. + applyDeploymentWatchRule(p.ns, p.watchRule, p.gitTarget) + verifyResourceStatus("watchrule", p.watchRule, p.ns, "True", "Succeeded", "") + + // Gate on StreamsRunning=True before any spec creates a Deployment: under watch-first, "Ready" only + // means the watch set is reconciled, not that each watch has opened and finished its + // sendInitialEvents replay. A Deployment created before the (empty) initial replay completes + // is folded into that committer-authored reconcile and committed immediately — establishing + // main and breaking the "branch not even created until the window is finalized" assertion. + // StreamsRunning=True is the documented barrier that the object is a genuine live, windowed event. + waitForStreamsRunning(p.gitTarget, p.ns) + return p +} diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index 7ae16f85..f94ec1bb 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -221,15 +221,7 @@ func (r *quickstartFrameworkRun) verifyStarterConfigMapCommit() { _, err = kubectlRunInNamespace(ns, "create", "configmap", configMapName, "--from-literal=value=one") Expect(err).NotTo(HaveOccurred(), "failed to create quickstart ConfigMap") - Eventually(func(g Gomega) { - g.Expect(r.gitPull()).To(Succeed()) - content, readErr := os.ReadFile(expectedFile) - g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file must exist at %s", expectedFile)) - g.Expect(string(content)).To(ContainSubstring("value: one")) - commitsAfter, countErr := r.gitCommitCount() - g.Expect(countErr).NotTo(HaveOccurred()) - g.Expect(commitsAfter).To(BeNumerically(">", commitsBefore)) - }, quickstartTimeout(), 2*time.Second).Should(Succeed()) + r.eventuallyCommitsConfigMapFile(expectedFile, "value: one", commitsBefore) _, err = kubectlRunInNamespace(ns, "delete", "configmap", configMapName) Expect(err).NotTo(HaveOccurred(), "failed to delete quickstart ConfigMap") @@ -421,17 +413,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { ) Expect(err).NotTo(HaveOccurred(), "failed to create quickstart ConfigMap") - Eventually(func(g Gomega) { - g.Expect(r.gitPull()).To(Succeed()) - - content, readErr := os.ReadFile(expectedFile) - g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file must exist at %s", expectedFile)) - g.Expect(string(content)).To(ContainSubstring("value: one")) - - commitsAfter, countErr := r.gitCommitCount() - g.Expect(countErr).NotTo(HaveOccurred()) - g.Expect(commitsAfter).To(BeNumerically(">", commitsBefore)) - }, quickstartTimeout(), 2*time.Second).Should(Succeed()) + r.eventuallyCommitsConfigMapFile(expectedFile, "value: one", commitsBefore) commitsAfterCreate, err := r.gitCommitCount() Expect(err).NotTo(HaveOccurred()) @@ -447,17 +429,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { ) Expect(err).NotTo(HaveOccurred(), "failed to patch quickstart ConfigMap") - Eventually(func(g Gomega) { - g.Expect(r.gitPull()).To(Succeed()) - - content, readErr := os.ReadFile(expectedFile) - g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file must exist at %s", expectedFile)) - g.Expect(string(content)).To(ContainSubstring("value: two")) - - commitsAfter, countErr := r.gitCommitCount() - g.Expect(countErr).NotTo(HaveOccurred()) - g.Expect(commitsAfter).To(BeNumerically(">", commitsAfterCreate)) - }, quickstartTimeout(), 2*time.Second).Should(Succeed()) + r.eventuallyCommitsConfigMapFile(expectedFile, "value: two", commitsAfterCreate) commitsAfterUpdate, err := r.gitCommitCount() Expect(err).NotTo(HaveOccurred()) @@ -596,6 +568,22 @@ func (r *quickstartFrameworkRun) verifyInvalidProviderActionableMessage() { }, quickstartTimeout(), 2*time.Second).Should(Succeed()) } +// eventuallyCommitsConfigMapFile waits until the checkout, pulled, has the ConfigMap file at path +// containing want, in more commits than before. +func (r *quickstartFrameworkRun) eventuallyCommitsConfigMapFile(path, want string, before int) { + Eventually(func(g Gomega) { + g.Expect(r.gitPull()).To(Succeed()) + + content, readErr := os.ReadFile(path) + g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file must exist at %s", path)) + g.Expect(string(content)).To(ContainSubstring(want)) + + commitsAfter, countErr := r.gitCommitCount() + g.Expect(countErr).NotTo(HaveOccurred()) + g.Expect(commitsAfter).To(BeNumerically(">", before)) + }, quickstartTimeout(), 2*time.Second).Should(Succeed()) +} + func (r *quickstartFrameworkRun) gitPull() error { pullCmd := exec.Command("git", "pull", "--ff-only") pullCmd.Dir = r.checkoutDir