From 462aeb51dde6f2517c668bb013d844b11321e280 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Feb 2026 12:30:48 +0000 Subject: [PATCH 01/11] feat: Use sops to encrypt secrets --- .dockerignore | 1 + Dockerfile | 14 + README.md | 6 +- .../gitops-reverser/templates/deployment.yaml | 4 + charts/gitops-reverser/values.yaml | 8 + cmd/main.go | 22 + cmd/main_audit_server_test.go | 25 ++ config/deployment.yaml | 2 + config/sops/.sops.yaml | 3 + docs/SOPS_ENCRYPTION_PLAN.md | 411 +++++++++--------- internal/git/content_writer.go | 162 +++++++ internal/git/content_writer_test.go | 281 ++++++++++++ internal/git/encryption.go | 94 ++++ internal/git/encryption_test.go | 72 +++ internal/git/git.go | 18 +- internal/git/secret_write_test.go | 78 ++++ internal/git/sops_encryptor.go | 75 ++++ internal/git/sops_encryptor_test.go | 59 +++ internal/metrics/exporter.go | 11 + internal/watch/resource_filter.go | 6 +- internal/watch/resource_filter_test.go | 4 +- test/e2e/e2e_test.go | 23 +- 22 files changed, 1146 insertions(+), 233 deletions(-) create mode 100644 config/sops/.sops.yaml create mode 100644 internal/git/content_writer.go create mode 100644 internal/git/content_writer_test.go create mode 100644 internal/git/encryption.go create mode 100644 internal/git/encryption_test.go create mode 100644 internal/git/secret_write_test.go create mode 100644 internal/git/sops_encryptor.go create mode 100644 internal/git/sops_encryptor_test.go diff --git a/.dockerignore b/.dockerignore index 1063df94..5d384c0a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ !cmd/ !api/ !internal/ +!config/sops/.sops.yaml # Additional files needed for dev container build (.devcontainer/Dockerfile) !.golangci.yml diff --git a/Dockerfile b/Dockerfile index a3f13fb3..6e9a2223 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,11 +21,25 @@ COPY internal/ internal/ # Build for the target platform RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o manager cmd/main.go +FROM alpine:3.22 AS sops-downloader +ARG TARGETARCH +ARG SOPS_VERSION=v3.11.0 +RUN apk add --no-cache curl +RUN case "${TARGETARCH}" in \ + amd64) SOPS_ARCH=amd64 ;; \ + arm64) SOPS_ARCH=arm64 ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" && exit 1 ;; \ + esac \ + && curl -fsSL -o /usr/local/bin/sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${SOPS_ARCH}" \ + && chmod 0555 /usr/local/bin/sops + # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static:debug WORKDIR / COPY --from=builder /workspaces/manager . +COPY --from=sops-downloader /usr/local/bin/sops /usr/local/bin/sops +COPY config/sops/.sops.yaml /etc/gitops-reverser/.sops.yaml USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/README.md b/README.md index 3a0491f9..b8e53ef7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,11 @@ Avoid infinite loops: Do not point GitOps (Argo CD/Flux) and GitOps Reverser at ## Known limitations / design choices - GitOps Reverser currently supports only a single controller pod (no multi-pod/HA yet). -- `Secret` resources (`core/v1`, `secrets`) are intentionally ignored and never written to Git, even if a `WatchRule` includes `secrets` or `*`. +- `Secret` resources (`core/v1`, `secrets`) are written via the same pipeline, but sensitive values are expected to be encrypted before commit. + - Configure encryption via `--sops-binary-path` and optional `--sops-config-path`. + - The container image ships with `/usr/local/bin/sops` and a default config at `/etc/gitops-reverser/.sops.yaml`. + - Override `--sops-config-path` with your own key policy for production environments. + - If Secret encryption fails, Secret writes are rejected (no plaintext fallback). - Avoid multiple GitProvider configurations pointing at the same repo to prevent queue collisions. - Queue collisions are possible when multiple configs target the same repository (so don't do that). diff --git a/charts/gitops-reverser/templates/deployment.yaml b/charts/gitops-reverser/templates/deployment.yaml index 49e8a379..b664e4cc 100644 --- a/charts/gitops-reverser/templates/deployment.yaml +++ b/charts/gitops-reverser/templates/deployment.yaml @@ -70,6 +70,10 @@ spec: - --audit-read-timeout={{ .Values.servers.audit.timeouts.read }} - --audit-write-timeout={{ .Values.servers.audit.timeouts.write }} - --audit-idle-timeout={{ .Values.servers.audit.timeouts.idle }} + - --sops-binary-path={{ .Values.sops.binaryPath }} + {{- if .Values.sops.configPath }} + - --sops-config-path={{ .Values.sops.configPath }} + {{- end }} {{- if .Values.logging.level }} - --zap-log-level={{ .Values.logging.level }} {{- end }} diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 006c2e15..8426de96 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -209,3 +209,11 @@ logging: labels: managedBy: Helm # Allows CI generated install.yaml creation (don't change when you are using Helm) + +# Secret encryption settings +sops: + # Absolute path to the sops binary in the controller image. + binaryPath: /usr/local/bin/sops + # Optional absolute path to a mounted .sops.yaml config file. + # Keep this empty unless you explicitly provide your own key policy. + configPath: "" diff --git a/cmd/main.go b/cmd/main.go index 6b2315c2..47157557 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -97,6 +97,12 @@ func main() { "webhook-insecure", cfg.webhookInsecure, "audit-insecure", cfg.auditInsecure) + // Configure Secret encryption behavior for git writes. + fatalIfErr(git.ConfigureSecretEncryption(git.EncryptionConfig{ + SOPSBinaryPath: cfg.sopsBinaryPath, + SOPSConfigPath: cfg.sopsConfigPath, + }), "unable to configure Secret encryption") + // Initialize metrics setupCtx := ctrl.SetupSignalHandler() _, err := metrics.InitOTLPExporter(setupCtx) @@ -289,6 +295,8 @@ type appConfig struct { auditReadTimeout time.Duration auditWriteTimeout time.Duration auditIdleTimeout time.Duration + sopsBinaryPath string + sopsConfigPath string zapOpts zap.Options } @@ -339,6 +347,10 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { "Write timeout for the dedicated audit ingress HTTPS server.") fs.DurationVar(&cfg.auditIdleTimeout, "audit-idle-timeout", defaultAuditIdleTimeout, "Idle timeout for the dedicated audit ingress HTTPS server.") + fs.StringVar(&cfg.sopsBinaryPath, "sops-binary-path", "", + "Absolute path to the sops binary used to encrypt Secret resources before writing to git.") + fs.StringVar(&cfg.sopsConfigPath, "sops-config-path", "", + "Absolute path to an optional sops config file.") cfg.zapOpts = zap.Options{ Development: true, @@ -354,6 +366,9 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { if err := validateAuditConfig(cfg); err != nil { return appConfig{}, err } + if err := validateSOPSConfig(cfg); err != nil { + return appConfig{}, err + } return cfg, nil } @@ -403,6 +418,13 @@ func validateAuditConfig(cfg appConfig) error { return nil } +func validateSOPSConfig(cfg appConfig) error { + return git.EncryptionConfig{ + SOPSBinaryPath: cfg.sopsBinaryPath, + SOPSConfigPath: cfg.sopsConfigPath, + }.Validate() +} + // fatalIfErr logs and exits the process if err is not nil. func fatalIfErr(err error, msg string, keysAndValues ...any) { if err != nil { diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index a644eab9..95b1c770 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -20,6 +20,8 @@ import ( "flag" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -42,6 +44,8 @@ func TestParseFlagsWithArgs_Defaults(t *testing.T) { assert.Equal(t, 15*time.Second, cfg.auditReadTimeout) assert.Equal(t, 30*time.Second, cfg.auditWriteTimeout) assert.Equal(t, 60*time.Second, cfg.auditIdleTimeout) + assert.Equal(t, "", cfg.sopsBinaryPath) + assert.Equal(t, "", cfg.sopsConfigPath) } func TestParseFlagsWithArgs_AuditUnsecure(t *testing.T) { @@ -123,6 +127,27 @@ func TestParseFlagsWithArgs_InvalidAuditSettings(t *testing.T) { } } +func TestParseFlagsWithArgs_SOPSValidation(t *testing.T) { + t.Run("invalid sops binary path", func(t *testing.T) { + fs := flag.NewFlagSet("test-invalid-sops-bin", flag.ContinueOnError) + _, err := parseFlagsWithArgs(fs, []string{"--sops-binary-path=/does/not/exist/sops"}) + require.Error(t, err) + }) + + t.Run("invalid sops config relative path", func(t *testing.T) { + dir := t.TempDir() + binPath := filepath.Join(dir, "sops") + require.NoError(t, os.WriteFile(binPath, []byte("bin"), 0700)) + + fs := flag.NewFlagSet("test-invalid-sops-config", flag.ContinueOnError) + _, err := parseFlagsWithArgs(fs, []string{ + "--sops-binary-path=" + binPath, + "--sops-config-path=relative/config.yaml", + }) + require.Error(t, err) + }) +} + func TestBuildAuditServeMux_RoutesAuditPaths(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) diff --git a/config/deployment.yaml b/config/deployment.yaml index eddd54f5..1e391873 100644 --- a/config/deployment.yaml +++ b/config/deployment.yaml @@ -28,6 +28,8 @@ spec: - --health-probe-bind-address=:8081 - --webhook-cert-path=/tmp/k8s-admission-server/admission-server-certs - --audit-cert-path=/tmp/k8s-audit-server/audit-server-certs + - --sops-binary-path=/usr/local/bin/sops + - --sops-config-path=/etc/gitops-reverser/.sops.yaml command: - /manager env: diff --git a/config/sops/.sops.yaml b/config/sops/.sops.yaml new file mode 100644 index 00000000..6c5c0ba5 --- /dev/null +++ b/config/sops/.sops.yaml @@ -0,0 +1,3 @@ +creation_rules: + - encrypted_regex: "^(data|stringData)$" + age: "age1q0u59kpa7qeh2tyktekm8hxxr3q2378wdp9z2xsgfe3xdky6lg6scuuy8p" diff --git a/docs/SOPS_ENCRYPTION_PLAN.md b/docs/SOPS_ENCRYPTION_PLAN.md index 45115c40..7cf7fa7c 100644 --- a/docs/SOPS_ENCRYPTION_PLAN.md +++ b/docs/SOPS_ENCRYPTION_PLAN.md @@ -2,247 +2,240 @@ ## Goal -Encrypt sensitive Kubernetes resources (initially `Secret`) with SOPS before they are written to the Git worktree, so commits contain encrypted payloads instead of plaintext `data`/`stringData`. +Encrypt sensitive Kubernetes resources (starting with `Secret`) before writing them to the git worktree, so committed manifests never contain plaintext secret values. -## Scope +## Why This Plan Was Reworked -- In scope: - - Encrypt on write path (watch event -> sanitize -> git file write). - - Support SOPS execution strategy for encryption (external binary first iteration). - - Add runtime configuration for enablement, policy, and SOPS invocation. - - Support standard SOPS key backends through mounted credentials/config. - - Tests, docs, and Helm wiring. -- Out of scope (first iteration): - - Decryption in controller runtime. - - Re-encrypting existing historical commits. - - Complex per-namespace/per-rule encryption policies. +We now need a plan that: +- assumes Secret write support is enabled (or is being enabled now), +- handles existing safety exceptions that previously blocked Secret commits, +- enforces small implementation steps with test checks after each step, +- and separates content-processing logic from generic git operations. -## Current Baseline (Why this is needed) +## Current-State Analysis (Code + Tests) -- `internal/sanitize/sanitize.go` preserves `data` and `binaryData`. -- `internal/watch/informers.go` enqueues sanitized objects as-is. -- `internal/git/git.go` writes YAML generated from event object directly to disk. -- Result: if a `WatchRule` includes `secrets` (or `*`), secret payloads are committed in plaintext. +### 1. Secret write exception still present in watch path -## High-Level Design +Current code still hard-filters core Secrets: +- `internal/watch/resource_filter.go` +- `internal/watch/informers.go` -### 1. Encryption Hook Point +This means Secret events are dropped before git write logic. -Add encryption at the final write stage in `internal/git/git.go` inside `handleCreateOrUpdateOperation`: +### 2. Existing tests and docs still encode “never commit Secret” behavior -1. Generate ordered YAML from sanitized object (existing behavior). -2. Apply encryption policy: - - If resource should be encrypted, run SOPS encryption. - - If not, keep plaintext YAML. -3. Continue with existing file compare/write/stage logic. +Current expectation appears in: +- `test/e2e/e2e_test.go` (`should never commit Secret manifests...`) +- `README.md` statement that Secrets are intentionally ignored -This keeps upstream watch/sanitize flow unchanged and centralizes git-output guarantees. +If Secret writing is now intentionally enabled, these become migration points that must be updated with new encrypted-write expectations. -### 2. Encryption Policy +### 3. `handleCreateOrUpdateOperation` is overloaded -Introduce explicit policy config (controller process-level first): +Current write path in `internal/git/git.go` combines: +- object -> ordered YAML rendering, +- write diff/idempotency check, +- filesystem write/stage. -- `disabled` (default for backward compatibility). -- `secretsOnly` (recommended default when enabled). -- `matchResources` (future): configurable list of `(group, version, resource)` patterns. +Adding encryption here directly will increase coupling. Refactoring content logic into its own file should be part of this plan. -Initial policy decision: -- Encrypt only Kubernetes `Secret` resources (`group=""`, `version="v1"`, `resource="secrets"`). +## Target Design -### 3. SOPS Invocation Model +## 1. Split content pipeline out of `git.go` -Use external SOPS binary for first implementation, invoked by the manager process. +Create a dedicated content writer module in `internal/git`, for example: +- `internal/git/content_writer.go` -Proposed approach: +Responsibilities: +- Render sanitized object to ordered YAML. +- For `Secret` resources: apply encryptor before write. +- Return final bytes for compare/write. -1. Write plaintext YAML to a secure temp file in `/tmp`. -2. Run SOPS command to produce encrypted YAML. -3. Read encrypted output and remove temp files. -4. Write encrypted output to repo path. +`handleCreateOrUpdateOperation` should then become orchestration only: +- ask content writer for final content, +- perform existing file compare/write/stage. -Command strategy: -- Prefer `.sops.yaml`-driven encryption rules. -- Allow optional explicit args passthrough only through an allowlist (for example output/input type and config path), not arbitrary raw flags. +## 2. Secret handling rule (phase-1 simplification) -Failure behavior (configurable): -- `failClosed` (recommended): do not write/commit if encryption fails. -- `failOpen` (optional): log error and write plaintext (not recommended for production). +For now, no policy matrix: +- If incoming resource is Kubernetes `Secret` (`group=""`, `version="v1"`, `resource="secrets"`), attempt encryption. +- If encryption succeeds, write encrypted content. +- If encryption fails, do not write and emit a warning log. +- If resource is not `Secret`, keep existing write behavior. -### 3a. Architecture Choice: External Binary vs Embedded Library +## 3. Encryption provider abstraction -This should be an explicit engineering decision, not a hidden assumption. +Add interface in `internal/git`: +- `type Encryptor interface { Encrypt(ctx context.Context, plain []byte, meta ResourceMeta) ([]byte, error) }` -Option A: External SOPS binary (first iteration) -- Pros: - - Reuses upstream SOPS behavior exactly (CLI parity with existing workflows). - - Faster implementation and lower maintenance in this codebase. - - Keeps cloud KMS/age/PGP backend behavior aligned with standard SOPS usage. -- Cons: - - Extra process spawn overhead per encrypted object. - - Runtime dependency management (binary presence, version pinning, CVE tracking). +First implementation: +- `SOPSEncryptor` invoking external `sops` binary. -Option B: Embed encryption implementation directly in `gitops-reverser` -- Pros: - - No external process execution; simpler runtime dependency surface. - - Potentially better performance and tighter observability hooks. -- Cons: - - Higher implementation and long-term maintenance cost. - - Risk of behavior drift from upstream SOPS semantics and config handling. - - More complex support burden across key backends. +This keeps the path open for future embedded implementations. -Decision for this plan: -- Implement Option A first (external binary), behind feature flags. -- Keep abstraction boundary (`Encryptor` interface) so Option B can be added later without reworking git write flow. +## 4. Runtime config -Revisit triggers: -- Encryption latency becomes a measurable bottleneck. -- Operational burden from binary distribution/versioning is high. -- There is a strong requirement for in-process crypto execution. +Add config inputs (flags + Helm values): +- `--sops-binary-path` +- `--sops-config-path` (optional) -### 4. Runtime Config Model +Validation: +- invalid values fail startup. -Add manager flags + Helm values for encryption: +## 5. Failure behavior -- `--encryption-enabled` -- `--encryption-policy=secretsOnly|disabled` -- `--encryption-provider=sops` -- `--sops-binary-path=/usr/local/bin/sops` -- `--sops-config-path=/etc/sops/.sops.yaml` (optional) -- `--encryption-failure-policy=failClosed|failOpen` +- Required behavior for Secret encryption path: if encryption fails, reject write. +- Emit warning logs for encryption failures so operators can diagnose quickly. +- Plaintext Secret values must never be written to git under any runtime condition. -Configuration precedence: -- `--encryption-enabled=false` always disables encryption regardless of policy value. -- `--encryption-enabled=true` requires a non-`disabled` policy. -- Invalid combinations should fail startup with a clear validation error. +## 6. Performance and caching strategy (required for usability) -Helm values section proposal: +Encryption can be expensive, especially with external KMS/Vault-backed SOPS setups. -```yaml -encryption: - enabled: false - policy: secretsOnly - failurePolicy: failClosed - sops: - binaryPath: /usr/local/bin/sops - configPath: /etc/sops/.sops.yaml -``` +Required optimizations: +- Keep pre-write deduplication effective so unchanged Secret content does not trigger a new encryption call. +- Add optional in-memory cache for encrypted payload reuse within a process lifetime. +- Track Secret change markers (`uid`, `resourceVersion`, `generation`) in runtime state to skip obvious no-op re-encryption attempts. -### 5. Key Material / Backend Configuration +In-memory cache design (safe default): +- Cache key: resource identity + canonical plaintext digest. +- Cache value: encrypted output bytes. +- Scope: process memory only (never persisted to git, CR status, annotations, or commit metadata). -Do not invent key management inside the operator. Reuse native SOPS backends: +Secret marker usage (runtime only): +- Keep last-seen tuple per Secret: `uid`, `resourceVersion`, `generation`. +- If tuple is unchanged, skip encryption/write work for that Secret event. +- `uid` protects against delete/recreate with same name. +- `resourceVersion` and `generation` provide cheap change detection hints. +- These markers are hints for performance; correctness still depends on final content/diff checks. -- `age` via mounted secret and `SOPS_AGE_KEY_FILE`. -- cloud KMS via workload identity / IAM env (AWS/GCP/Azure). -- PGP if needed (lower priority). +Security constraints: +- Do not persist plaintext-derived hash metadata in repository content, commit messages, annotations, or labels. +- Reason: plaintext hashes can leak signal and may allow offline guessing attacks for low-entropy secrets. +- If persisted metadata is ever required in the future, it must be a separately reviewed design (for example keyed HMAC with managed key rotation), not part of first implementation. +- Do not persist `uid`/`resourceVersion`/`generation` optimization state outside process memory in the first implementation. -Helm should support: - -- Extra volume mounts for key files and `.sops.yaml`. -- Extra env vars for SOPS backend configuration. - -## Implementation Phases - -## Phase 1: Core plumbing (code-only, no encryption yet) - -- Add `EncryptionConfig` struct and wire it from `cmd/main.go` into git worker path. -- Add policy evaluator utility (`shouldEncrypt(event)`). -- Add unit tests for policy decisions. - -Deliverable: -- Feature-flagged no-op framework merged. - -## Phase 2: SOPS binary integration - -- Implement `SOPSEncryptor` (interface + concrete implementation). -- Integrate into `handleCreateOrUpdateOperation` before file write. -- Implement temp-file execution with strict permissions. -- Add structured logging and metrics: - - encrypt attempts - - encrypt success/failure - - fail-open count - -Deliverable: -- Functional encryption when enabled and policy matches. - -## Phase 3: Packaging and Helm configuration - -- Update `Dockerfile` multi-stage build: - - Add stage to fetch pinned SOPS release binary. - - Copy binary into final distroless image (e.g. `/usr/local/bin/sops`). -- Update chart: - - New `encryption.*` values. - - Add manager args from values. - - Document volume/env examples for keys and `.sops.yaml`. - -Deliverable: -- Deployable encrypted workflow via Helm settings. - -## Phase 4: Test coverage - -- Unit tests: - - `Secret` gets encrypted. - - non-secret not encrypted (policy `secretsOnly`). - - encryption failure with `failClosed` blocks write. - - encryption failure with `failOpen` writes plaintext and emits warning metric. - - invalid flag combinations are rejected at config validation time. -- Integration tests (git operations): - - verify resulting file contains SOPS envelope fields and no raw secret values. -- Optional e2e: - - run with local age key and assert encrypted commits. - -Deliverable: -- CI coverage for happy path and failure modes. - -## Phase 5: Documentation and migration - -- Update `README.md` and chart README with: - - enabling encryption - - key backend setup examples - - operational caveats -- Add migration note: - - existing plaintext history remains in git; requires manual history rewrite if needed. - -Deliverable: -- Operator docs for secure rollout. - -## Security Considerations - -- Default to `failClosed` when encryption is enabled. -- Treat `failOpen` as development-only or break-glass behavior. -- Ensure temp files are `0600` and cleaned up. -- Ensure temp-file cleanup runs on both success and failure paths. -- Avoid logging plaintext content. -- Prefer `age` or cloud KMS over static PGP workflows. -- Recommend separate repos/branches for encrypted outputs when integrating with downstream GitOps tools. - -## Operational Considerations - -- Performance: - - SOPS process spawn per encrypted object adds overhead. - - Mitigation: keep policy narrow (`secretsOnly`) and batch commit behavior unchanged. -- Determinism: - - SOPS metadata may vary; deduplication currently happens pre-write on sanitized plaintext. - - This is acceptable for first iteration but should be documented. -- Compatibility: - - Downstream consumers (Flux/Argo) must be configured for SOPS decryption if they deploy encrypted files. - -## Proposed Acceptance Criteria - -- When enabled with `secretsOnly`, committed Secret manifests are SOPS-encrypted and plaintext secret values never appear in repo files. -- Non-secret resources continue to be committed as before. -- If SOPS is missing or misconfigured: - - `failClosed`: write is rejected and error surfaced. - - `failOpen`: plaintext write proceeds with explicit warning/metric (non-production only). -- If invalid encryption configuration is provided, manager startup fails with actionable error output. -- Helm users can: - - enable encryption - - mount key/config material - - point to SOPS binary/config path without rebuilding chart templates manually. - -## Suggested Rollout - -1. Merge framework + binary integration behind feature flag (disabled by default). -2. Run in staging with `enabled=true`, `policy=secretsOnly`, `failClosed`. -3. Validate commit contents and operational metrics. -4. Roll to production. -5. Optionally extend policy beyond `Secret` after proving stability. +## 7. Lifecycle side-note (future, not part of first implementation) + +- Add a periodic re-encryption workflow (for example monthly) to support routine key rotation. +- This should re-encrypt Secret files with current key material and create normal git commits. +- Periodic re-encryption must not rely on unchanged `uid`/`resourceVersion`/`generation`; it intentionally rewrites encrypted content on cadence. +- This is explicitly out of scope for initial implementation; include as later lifecycle enhancement. + +## Implementation Plan (Small Steps + Mandatory Test Gates) + +## Phase 0: Align baseline with Secret-write direction + +Changes: +- remove or gate secret-ignore filter in watch path. +- update baseline docs/comments that still say secrets are always ignored. + +Test gate: +- `go test ./internal/watch/...` +- targeted e2e spec update/validation for secret behavior (no longer “never commit”, now “never commit plaintext”). + +Exit criteria: +- Secret events can reach git write path (at least in enabled mode). + +## Phase 1: Extract content logic from `handleCreateOrUpdateOperation` + +Changes: +- create content writer module (`internal/git/content_writer.go`). +- move marshal + future encryption hook into module. +- keep behavior identical (no encryption yet). + +Test gate: +- `go test ./internal/git/...` + +Exit criteria: +- no behavior change, cleaner seam for encryption. + +## Phase 2: Add Secret-detection plumbing (no SOPS yet) + +Changes: +- add encryption config structs + startup validation. +- add simple Secret detector (`isSecretResource(event)` or equivalent). +- pass config into git write pipeline. + +Test gate: +- `go test ./cmd/... ./internal/git/...` + +Exit criteria: +- Secret detection decisions tested, still no encryption side effects. + +## Phase 3: Add `SOPSEncryptor` and integrate + +Changes: +- implement CLI-backed encryptor. +- integrate in content writer when resource is Secret. +- ensure temp files are secure and cleaned up. +- emit metrics/logs for attempts/success/failure. +- reject Secret writes on encryption failure (no plaintext fallback path). +- use runtime Secret markers (`uid`/`resourceVersion`/`generation`) to bypass obvious no-op Secret reprocessing. + +Test gate: +- `go test ./internal/git/...` +- add integration tests that assert encrypted output shape (SOPS envelope fields) and absence of plaintext values. + +Exit criteria: +- Secret writes are encrypted whenever Secret events reach the write path. + +## Phase 4: Packaging + Helm wiring + +Changes: +- package pinned `sops` binary in image. +- add chart values/args/env/volume wiring for SOPS config and key material. +- add in-memory caching configuration knobs only if needed (size/TTL), defaulting to conservative values. + +Test gate: +- `go test ./...` +- helm template/lint checks used in repo workflow. + +Exit criteria: +- deployable encrypted workflow through chart config. + +## Phase 5: E2E migration and hardening + +Changes: +- replace old e2e assertion “Secret file must not exist” with: + - file exists, + - content is encrypted, + - plaintext secret value not present. +- keep/create negative-path e2e for encryption failure behavior, asserting write is rejected and plaintext is never committed. +- add e2e or integration perf check to verify unchanged Secret updates do not repeatedly invoke encryption. +- add integration tests for marker-based skips and delete/recreate behavior (same name, different `uid`). + +Test gate: +- targeted e2e secret scenarios. + +Exit criteria: +- e2e reflects new contract: encrypted Secret commits, never plaintext. + +## Acceptance Criteria + +- When Secret events are processed, Secret manifests are committed encrypted. +- Plaintext secret values do not appear in committed files. +- Non-secret resources remain unchanged. +- Encryption errors block Secret writes. +- No fail-open mode exists for Secret encryption. +- Unchanged Secret content can skip re-encryption through dedupe/cache without persisting plaintext-derived metadata. +- Secret-only marker tracking (`uid`/`resourceVersion`/`generation`) reduces redundant re-encryption attempts. +- `handleCreateOrUpdateOperation` no longer owns content transformation/encryption details directly. + +## Risks And Mitigations + +- Residual legacy assumptions (“secrets never written”) in tests/docs. + - Mitigation: Phase 0 alignment before encryption implementation. +- Increased complexity in write path. + - Mitigation: extract content writer first. +- SOPS binary dependency and runtime config drift. + - Mitigation: pinned version + startup validation + clear metrics. +- Encryption latency with external backends. + - Mitigation: dedupe first, then in-memory cache; no persisted plaintext-hash metadata. + +## Rollout Recommendation + +1. Land Phase 0 and Phase 1 first with no encryption behavior change. +2. Roll out Secret encryption in staging (encrypt-or-skip for Secret writes). +3. Verify no plaintext leakage in git history for new commits. +4. Roll out gradually to production targets. diff --git a/internal/git/content_writer.go b/internal/git/content_writer.go new file mode 100644 index 00000000..4f5ad372 --- /dev/null +++ b/internal/git/content_writer.go @@ -0,0 +1,162 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "sync" + + "github.com/ConfigButler/gitops-reverser/internal/metrics" + "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +type resourceMeta struct { + Identifier types.ResourceIdentifier + UID string + ResourceVersion string + Generation int64 +} + +type secretMarker struct { + UID string + ResourceVersion string + Generation int64 +} + +type contentWriter struct { + encryptor Encryptor + + mu sync.RWMutex + secretCache map[string][]byte + secretMarker map[string]secretMarker +} + +func newContentWriter() *contentWriter { + return &contentWriter{ + secretCache: make(map[string][]byte), + secretMarker: make(map[string]secretMarker), + } +} + +func (w *contentWriter) setEncryptor(encryptor Encryptor) { + w.mu.Lock() + defer w.mu.Unlock() + w.encryptor = encryptor +} + +var defaultContentWriter = newContentWriter() + +// buildContentForWrite renders event content to stable ordered YAML and applies +// Secret-specific encryption when configured. +func buildContentForWrite(ctx context.Context, event Event) ([]byte, error) { + content, err := sanitize.MarshalToOrderedYAML(event.Object) + if err != nil { + return nil, fmt.Errorf("failed to marshal object to YAML: %w", err) + } + + if !isSecretResource(event.Identifier) { + return content, nil + } + + return defaultContentWriter.encryptSecretContent(ctx, event, content) +} + +func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, plain []byte) ([]byte, error) { + meta := buildResourceMeta(event) + identityKey := secretIdentityKey(meta.Identifier) + digest := sha256.Sum256(plain) + cacheKey := fmt.Sprintf("%s:%x", identityKey, digest) + currentMarker := secretMarker{ + UID: meta.UID, + ResourceVersion: meta.ResourceVersion, + Generation: meta.Generation, + } + + w.mu.RLock() + encryptor := w.encryptor + lastMarker, markerExists := w.secretMarker[identityKey] + if markerExists && lastMarker == currentMarker { + if cached, ok := w.secretCache[cacheKey]; ok { + if metrics.SecretEncryptionMarkerSkipsTotal != nil { + metrics.SecretEncryptionMarkerSkipsTotal.Add(ctx, 1) + } + if metrics.SecretEncryptionCacheHitsTotal != nil { + metrics.SecretEncryptionCacheHitsTotal.Add(ctx, 1) + } + w.mu.RUnlock() + return append([]byte(nil), cached...), nil + } + } + w.mu.RUnlock() + + if encryptor == nil { + return nil, errors.New("secret encryption is required but no encryptor is configured") + } + + if metrics.SecretEncryptionAttemptsTotal != nil { + metrics.SecretEncryptionAttemptsTotal.Add(ctx, 1) + } + encrypted, err := encryptor.Encrypt(ctx, plain, ResourceMeta{ + Identifier: meta.Identifier, + UID: meta.UID, + ResourceVersion: meta.ResourceVersion, + Generation: meta.Generation, + }) + if err != nil { + if metrics.SecretEncryptionFailuresTotal != nil { + metrics.SecretEncryptionFailuresTotal.Add(ctx, 1) + } + return nil, fmt.Errorf("secret encryption failed: %w", err) + } + if metrics.SecretEncryptionSuccessTotal != nil { + metrics.SecretEncryptionSuccessTotal.Add(ctx, 1) + } + + w.mu.Lock() + w.secretCache[cacheKey] = append([]byte(nil), encrypted...) + w.secretMarker[identityKey] = currentMarker + w.mu.Unlock() + + return encrypted, nil +} + +func buildResourceMeta(event Event) resourceMeta { + meta := resourceMeta{ + Identifier: event.Identifier, + } + if event.Object == nil { + return meta + } + meta.UID = string(event.Object.GetUID()) + meta.ResourceVersion = event.Object.GetResourceVersion() + meta.Generation = event.Object.GetGeneration() + return meta +} + +func secretIdentityKey(id types.ResourceIdentifier) string { + return fmt.Sprintf("%s/%s/%s/%s/%s", id.Group, id.Version, id.Resource, id.Namespace, id.Name) +} + +func isSecretResource(id types.ResourceIdentifier) bool { + return id.Group == "" && id.Version == "v1" && id.Resource == "secrets" +} diff --git a/internal/git/content_writer_test.go b/internal/git/content_writer_test.go new file mode 100644 index 00000000..9ac13188 --- /dev/null +++ b/internal/git/content_writer_test.go @@ -0,0 +1,281 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +type stubEncryptor struct { + callCount int + err error + result []byte +} + +func (s *stubEncryptor) Encrypt(_ context.Context, _ []byte, _ ResourceMeta) ([]byte, error) { + s.callCount++ + if s.err != nil { + return nil, s.err + } + return append([]byte(nil), s.result...), nil +} + +func TestBuildContentForWrite_MarshalOrderedYAML(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "my-config", + "namespace": "default", + }, + "data": map[string]interface{}{ + "config.yaml": "enabled: true", + }, + }, + }, + } + + got, err := buildContentForWrite(context.Background(), event) + require.NoError(t, err) + + output := string(got) + assert.Contains(t, output, "apiVersion: v1") + assert.Contains(t, output, "kind: ConfigMap") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "name: my-config") + assert.Contains(t, output, "namespace: default") + assert.Contains(t, output, "data:") + assert.Contains(t, output, "config.yaml: 'enabled: true'") +} + +func TestBuildContentForWrite_ReturnsMarshalError(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "bad-config", + "namespace": "default", + }, + "data": map[string]interface{}{ + "invalid": make(chan int), + }, + }, + }, + } + + _, err := buildContentForWrite(context.Background(), event) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to marshal object to YAML") +} + +func TestBuildContentForWrite_SecretRequiresEncryptor(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "my-secret", + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "my-secret", + "namespace": "default", + }, + "data": map[string]interface{}{ + "password": "cGxhaW4=", + }, + }, + }, + } + + _, err := buildContentForWrite(context.Background(), event) + require.Error(t, err) + assert.Contains(t, err.Error(), "secret encryption is required but no encryptor is configured") +} + +func TestBuildContentForWrite_SecretEncryptionCacheMarkerReuse(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + enc := &stubEncryptor{result: []byte("encrypted: true\nsops:\n version: 3.9.0\n")} + defaultContentWriter.setEncryptor(enc) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "my-secret", + "namespace": "default", + "uid": "uid-1", + "resourceVersion": "10", + "generation": int64(1), + }, + "data": map[string]interface{}{ + "password": "cGxhaW4=", + }, + }, + } + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "my-secret", + }, + Object: obj, + } + + first, err := buildContentForWrite(context.Background(), event) + require.NoError(t, err) + second, err := buildContentForWrite(context.Background(), event) + require.NoError(t, err) + assert.Equal(t, 1, enc.callCount) + assert.Equal(t, first, second) +} + +func TestBuildContentForWrite_SecretUIDChangeForcesReencrypt(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + enc := &stubEncryptor{result: []byte("encrypted: true\nsops:\n version: 3.9.0\n")} + defaultContentWriter.setEncryptor(enc) + + makeSecret := func(uid string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "my-secret", + "namespace": "default", + "uid": uid, + "resourceVersion": "10", + "generation": int64(1), + }, + "data": map[string]interface{}{ + "password": "cGxhaW4=", + }, + }, + } + } + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "my-secret", + }, + Object: makeSecret("uid-1"), + } + _, err := buildContentForWrite(context.Background(), event) + require.NoError(t, err) + + event.Object = makeSecret("uid-2") + _, err = buildContentForWrite(context.Background(), event) + require.NoError(t, err) + assert.Equal(t, 2, enc.callCount) +} + +func TestBuildContentForWrite_SecretEncryptionFailure(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + defaultContentWriter.setEncryptor(&stubEncryptor{err: errors.New("boom")}) + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "my-secret", + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "my-secret", + "namespace": "default", + }, + "data": map[string]interface{}{ + "password": "cGxhaW4=", + }, + }, + }, + } + + _, err := buildContentForWrite(context.Background(), event) + require.Error(t, err) + assert.Contains(t, err.Error(), "secret encryption failed") +} + +func TestIsSecretResource(t *testing.T) { + assert.True(t, isSecretResource(types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + })) + assert.False(t, isSecretResource(types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + })) + assert.False(t, isSecretResource(types.ResourceIdentifier{ + Group: "example.com", + Version: "v1", + Resource: "secrets", + })) +} diff --git a/internal/git/encryption.go b/internal/git/encryption.go new file mode 100644 index 00000000..1b404ac9 --- /dev/null +++ b/internal/git/encryption.go @@ -0,0 +1,94 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +// ResourceMeta is passed to encryptors for context and diagnostics. +type ResourceMeta struct { + Identifier types.ResourceIdentifier + UID string + ResourceVersion string + Generation int64 +} + +// Encryptor transforms plaintext bytes into encrypted bytes. +type Encryptor interface { + Encrypt(ctx context.Context, plain []byte, meta ResourceMeta) ([]byte, error) +} + +// EncryptionConfig controls runtime encryption behavior for Secret writes. +type EncryptionConfig struct { + SOPSBinaryPath string + SOPSConfigPath string +} + +// ConfigureSecretEncryption wires Secret encryption for git write paths. +func ConfigureSecretEncryption(cfg EncryptionConfig) error { + if strings.TrimSpace(cfg.SOPSBinaryPath) == "" { + defaultContentWriter.setEncryptor(nil) + return nil + } + + if err := cfg.Validate(); err != nil { + return err + } + + defaultContentWriter.setEncryptor(NewSOPSEncryptor(cfg.SOPSBinaryPath, cfg.SOPSConfigPath)) + return nil +} + +// Validate verifies encryption config values. Empty SOPSBinaryPath is allowed +// and means encryption is not configured (Secret writes will be rejected). +func (c EncryptionConfig) Validate() error { + binPath := strings.TrimSpace(c.SOPSBinaryPath) + if binPath != "" { + info, err := os.Stat(binPath) + if err != nil { + return fmt.Errorf("invalid sops-binary-path %q: %w", binPath, err) + } + if info.IsDir() { + return fmt.Errorf("invalid sops-binary-path %q: path is a directory", binPath) + } + } + + configPath := strings.TrimSpace(c.SOPSConfigPath) + if configPath != "" { + info, err := os.Stat(configPath) + if err != nil { + return fmt.Errorf("invalid sops-config-path %q: %w", configPath, err) + } + if info.IsDir() { + return fmt.Errorf("invalid sops-config-path %q: path is a directory", configPath) + } + if !filepath.IsAbs(configPath) { + return fmt.Errorf("invalid sops-config-path %q: must be an absolute path", configPath) + } + } + + return nil +} diff --git a/internal/git/encryption_test.go b/internal/git/encryption_test.go new file mode 100644 index 00000000..01917cfa --- /dev/null +++ b/internal/git/encryption_test.go @@ -0,0 +1,72 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncryptionConfigValidate(t *testing.T) { + t.Run("empty config is allowed", func(t *testing.T) { + cfg := EncryptionConfig{} + require.NoError(t, cfg.Validate()) + }) + + t.Run("invalid binary path fails", func(t *testing.T) { + cfg := EncryptionConfig{SOPSBinaryPath: "/does/not/exist/sops"} + require.Error(t, cfg.Validate()) + }) + + t.Run("relative config path fails", func(t *testing.T) { + f := filepath.Join(t.TempDir(), "sops") + require.NoError(t, os.WriteFile(f, []byte("binary"), 0700)) + cfg := EncryptionConfig{ + SOPSBinaryPath: f, + SOPSConfigPath: "relative/path", + } + require.Error(t, cfg.Validate()) + }) + + t.Run("absolute paths pass", func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "sops") + cfgPath := filepath.Join(dir, ".sops.yaml") + require.NoError(t, os.WriteFile(bin, []byte("binary"), 0700)) + require.NoError(t, os.WriteFile(cfgPath, []byte("creation_rules: []"), 0600)) + cfg := EncryptionConfig{ + SOPSBinaryPath: bin, + SOPSConfigPath: cfgPath, + } + require.NoError(t, cfg.Validate()) + }) +} + +func TestConfigureSecretEncryption(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + require.NoError(t, ConfigureSecretEncryption(EncryptionConfig{})) + assert.Nil(t, defaultContentWriter.encryptor) +} diff --git a/internal/git/git.go b/internal/git/git.go index f70b664d..62d72c04 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -39,8 +39,6 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/ConfigButler/gitops-reverser/internal/sanitize" ) var ( @@ -693,7 +691,7 @@ func applyEventToWorktree(ctx context.Context, worktree *git.Worktree, event Eve return handleDeleteOperation(logger, filePath, fullPath, worktree) } - return handleCreateOrUpdateOperation(event, filePath, fullPath, worktree) + return handleCreateOrUpdateOperation(ctx, event, filePath, fullPath, worktree) } // handleDeleteOperation removes a file from the repository. @@ -732,14 +730,22 @@ func handleDeleteOperation( // handleCreateOrUpdateOperation writes and stages a file in the repository. // Returns true if changes were made, false if the file already has the desired content. func handleCreateOrUpdateOperation( + ctx context.Context, event Event, filePath, fullPath string, worktree *git.Worktree, ) (bool, error) { - // Convert object to ordered YAML - content, err := sanitize.MarshalToOrderedYAML(event.Object) + content, err := buildContentForWrite(ctx, event) if err != nil { - return false, fmt.Errorf("failed to marshal object to YAML: %w", err) + if isSecretResource(event.Identifier) { + log.FromContext(ctx).Info( + "Secret write skipped because encryption failed", + "resource", event.Identifier.String(), + "file", filePath, + "error", err.Error(), + ) + } + return false, err } // Check if file already exists with same content diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go new file mode 100644 index 00000000..a2ce359b --- /dev/null +++ b/internal/git/secret_write_test.go @@ -0,0 +1,78 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + + gogit "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +func TestWriteEvents_SecretEncryptionFailureDoesNotWritePlaintext(t *testing.T) { + originalWriter := defaultContentWriter + defaultContentWriter = newContentWriter() + t.Cleanup(func() { defaultContentWriter = originalWriter }) + + repoPath := t.TempDir() + _, err := gogit.PlainInit(repoPath, false) + require.NoError(t, err) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "test-secret", + "namespace": "default", + }, + "data": map[string]interface{}{ + "password": "ZG8tbm90LWNvbW1pdA==", + }, + }, + }, + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "test-secret", + }, + Operation: "CREATE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + } + + _, err = WriteEvents(context.Background(), repoPath, []Event{event}, "master", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "secret encryption is required") + + secretPath := filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.yaml") + _, statErr := os.Stat(secretPath) + assert.Error(t, statErr, "Secret file should not be written when encryption fails") +} diff --git a/internal/git/sops_encryptor.go b/internal/git/sops_encryptor.go new file mode 100644 index 00000000..19282e3f --- /dev/null +++ b/internal/git/sops_encryptor.go @@ -0,0 +1,75 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// SOPSEncryptor encrypts YAML by invoking the external sops binary. +type SOPSEncryptor struct { + binaryPath string + configPath string +} + +// NewSOPSEncryptor creates an Encryptor that shells out to sops. +func NewSOPSEncryptor(binaryPath, configPath string) *SOPSEncryptor { + return &SOPSEncryptor{ + binaryPath: binaryPath, + configPath: configPath, + } +} + +// Encrypt writes plaintext to a secure temp file, encrypts it with sops, and +// returns encrypted YAML bytes. +func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMeta) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "gitops-reverser-sops-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir for sops: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + inFile := filepath.Join(tmpDir, "secret.yaml") + if writeErr := os.WriteFile(inFile, plain, 0600); writeErr != nil { + return nil, fmt.Errorf("failed to write temp secret file: %w", writeErr) + } + + args := []string{ + "--encrypt", + "--input-type", "yaml", + "--output-type", "yaml", + inFile, + } + if strings.TrimSpace(e.configPath) != "" { + args = append([]string{"--config", e.configPath}, args...) + } + + cmd := exec.CommandContext(ctx, e.binaryPath, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("sops encryption failed: %w: %s", err, strings.TrimSpace(string(out))) + } + + return out, nil +} diff --git a/internal/git/sops_encryptor_test.go b/internal/git/sops_encryptor_test.go new file mode 100644 index 00000000..1beaf797 --- /dev/null +++ b/internal/git/sops_encryptor_test.go @@ -0,0 +1,59 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSOPSEncryptorEncrypt(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "sops") + require.NoError(t, os.WriteFile(script, []byte(`#!/usr/bin/env bash +set -euo pipefail +infile="${@: -1}" +cat <<'EOF' +apiVersion: v1 +kind: Secret +sops: + version: 3.9.0 +encrypted_regex: "^(data|stringData)$" +EOF +cat "$infile" >/dev/null +`), 0700)) + + encryptor := NewSOPSEncryptor(script, "") + out, err := encryptor.Encrypt(context.Background(), []byte("apiVersion: v1\nkind: Secret\n"), ResourceMeta{}) + require.NoError(t, err) + assert.Contains(t, string(out), "sops:") + assert.Contains(t, string(out), "encrypted_regex:") +} + +func TestSOPSEncryptorEncryptFailure(t *testing.T) { + encryptor := NewSOPSEncryptor("/does/not/exist/sops", "") + _, err := encryptor.Encrypt(context.Background(), []byte("apiVersion: v1\nkind: Secret\n"), ResourceMeta{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "sops encryption failed") +} diff --git a/internal/metrics/exporter.go b/internal/metrics/exporter.go index bead2d06..a207023b 100644 --- a/internal/metrics/exporter.go +++ b/internal/metrics/exporter.go @@ -77,6 +77,12 @@ var ( WatchDuplicatesSkippedTotal metric.Int64Counter // AuditEventsReceivedTotal counts audit events received from Kubernetes API server. AuditEventsReceivedTotal metric.Int64Counter + // Secret encryption pipeline counters. + SecretEncryptionAttemptsTotal metric.Int64Counter + SecretEncryptionSuccessTotal metric.Int64Counter + SecretEncryptionFailuresTotal metric.Int64Counter + SecretEncryptionCacheHitsTotal metric.Int64Counter + SecretEncryptionMarkerSkipsTotal metric.Int64Counter ) // InitOTLPExporter initializes the OTLP-to-Prometheus bridge. @@ -132,6 +138,11 @@ func InitOTLPExporter(_ context.Context) (func(context.Context) error, error) { {"gitopsreverser_kv_evictions_total", &KVEvictionsTotal}, {"gitopsreverser_watch_duplicates_skipped_total", &WatchDuplicatesSkippedTotal}, {"gitopsreverser_audit_events_received_total", &AuditEventsReceivedTotal}, + {"gitopsreverser_secret_encryption_attempts_total", &SecretEncryptionAttemptsTotal}, + {"gitopsreverser_secret_encryption_success_total", &SecretEncryptionSuccessTotal}, + {"gitopsreverser_secret_encryption_failures_total", &SecretEncryptionFailuresTotal}, + {"gitopsreverser_secret_encryption_cache_hits_total", &SecretEncryptionCacheHitsTotal}, + {"gitopsreverser_secret_encryption_marker_skips_total", &SecretEncryptionMarkerSkipsTotal}, } for _, s := range counters { v, err := otelMeter.Int64Counter(s.name) diff --git a/internal/watch/resource_filter.go b/internal/watch/resource_filter.go index 90b0ebfa..455a2cf8 100644 --- a/internal/watch/resource_filter.go +++ b/internal/watch/resource_filter.go @@ -18,8 +18,6 @@ limitations under the License. package watch -import "strings" - -func shouldIgnoreResource(group, resource string) bool { - return group == "" && strings.EqualFold(resource, "secrets") +func shouldIgnoreResource(_, _ string) bool { + return false } diff --git a/internal/watch/resource_filter_test.go b/internal/watch/resource_filter_test.go index 76763f9f..e7f2b3c1 100644 --- a/internal/watch/resource_filter_test.go +++ b/internal/watch/resource_filter_test.go @@ -29,8 +29,8 @@ func TestShouldIgnoreResource(t *testing.T) { resource string want bool }{ - {name: "core secrets", group: "", resource: "secrets", want: true}, - {name: "core secrets case insensitive", group: "", resource: "Secrets", want: true}, + {name: "core secrets", group: "", resource: "secrets", want: false}, + {name: "core secrets case insensitive", group: "", resource: "Secrets", want: false}, {name: "core configmaps", group: "", resource: "configmaps", want: false}, {name: "non-core secrets", group: "example.com", resource: "secrets", want: false}, } diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index efca3949..4495a4a6 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -521,14 +521,14 @@ var _ = Describe("Manager", Ordered, func() { cleanupGitTarget(destName, namespace) }) - It("should never commit Secret manifests even if WatchRule includes secrets", func() { + It("should commit encrypted Secret manifests when WatchRule includes secrets", func() { gitProviderName := "gitprovider-normal" - watchRuleName := "watchrule-secret-ignore-test" - secretName := "test-secret-ignore" + watchRuleName := "watchrule-secret-encryption-test" + secretName := "test-secret-encryption" By("creating WatchRule that includes secrets") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/secret-ignore-test", "main") + createGitTarget(destName, namespace, gitProviderName, "e2e/secret-encryption-test", "main") data := struct { Name string @@ -553,8 +553,8 @@ var _ = Describe("Manager", Ordered, func() { _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Secret creation should succeed") - By("verifying Secret file never appears in Git repository") - verifySecretNotCommitted := func(g Gomega) { + By("verifying Secret file is committed and does not contain plaintext data") + verifyEncryptedSecretCommitted := func(g Gomega) { pullCmd := exec.Command("git", "pull") pullCmd.Dir = checkoutDir pullOutput, pullErr := pullCmd.CombinedOutput() @@ -564,13 +564,14 @@ var _ = Describe("Manager", Ordered, func() { } expectedFile := filepath.Join(checkoutDir, - "e2e/secret-ignore-test", + "e2e/secret-encryption-test", fmt.Sprintf("v1/secrets/%s/%s.yaml", namespace, secretName)) - _, statErr := os.Stat(expectedFile) - g.Expect(statErr).To(HaveOccurred(), fmt.Sprintf("Secret file must NOT exist at %s", expectedFile)) - g.Expect(os.IsNotExist(statErr)).To(BeTrue(), "Error should be 'file does not exist'") + content, readErr := os.ReadFile(expectedFile) + g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("Secret file must exist at %s", expectedFile)) + g.Expect(string(content)).To(ContainSubstring("sops:")) + g.Expect(string(content)).NotTo(ContainSubstring("do-not-commit")) } - Consistently(verifySecretNotCommitted, "20s", "2s").Should(Succeed()) + Eventually(verifyEncryptedSecretCommitted, "30s", "2s").Should(Succeed()) By("cleaning up test resources") _, _ = utils.Run(exec.Command("kubectl", "delete", "secret", secretName, From dabdf4b3b8e655876096ae36200eae21c86cf6ab Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 08:30:46 +0000 Subject: [PATCH 02/11] chore: Get linting right --- cmd/main.go | 10 +-- cmd/main_audit_server_test.go | 4 +- internal/git/branch_worker.go | 14 +++- internal/git/branch_worker_test.go | 6 +- internal/git/content_writer.go | 56 ++++++++------ internal/git/content_writer_test.go | 46 +++++------ internal/git/encryption.go | 7 +- internal/git/encryption_test.go | 11 +-- internal/git/git.go | 113 +++++++++++++++++++++------- internal/git/secret_write_test.go | 4 - internal/git/sops_encryptor.go | 26 +++---- internal/git/sops_encryptor_test.go | 3 +- internal/git/worker_manager.go | 31 ++++++-- internal/metrics/exporter.go | 14 ++-- 14 files changed, 202 insertions(+), 143 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 47157557..aad3bc0b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -97,12 +97,6 @@ func main() { "webhook-insecure", cfg.webhookInsecure, "audit-insecure", cfg.auditInsecure) - // Configure Secret encryption behavior for git writes. - fatalIfErr(git.ConfigureSecretEncryption(git.EncryptionConfig{ - SOPSBinaryPath: cfg.sopsBinaryPath, - SOPSConfigPath: cfg.sopsConfigPath, - }), "unable to configure Secret encryption") - // Initialize metrics setupCtx := ctrl.SetupSignalHandler() _, err := metrics.InitOTLPExporter(setupCtx) @@ -130,6 +124,10 @@ func main() { // Initialize WorkerManager (manages branch workers) workerManager := git.NewWorkerManager(mgr.GetClient(), ctrl.Log.WithName("worker-manager")) + fatalIfErr(workerManager.ConfigureSecretEncryption(git.EncryptionConfig{ + SOPSBinaryPath: cfg.sopsBinaryPath, + SOPSConfigPath: cfg.sopsConfigPath, + }), "unable to configure Secret encryption") fatalIfErr(mgr.Add(workerManager), "unable to add worker manager to manager") setupLog.Info("WorkerManager initialized and added to manager") diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index 95b1c770..411318e7 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -44,8 +44,8 @@ func TestParseFlagsWithArgs_Defaults(t *testing.T) { assert.Equal(t, 15*time.Second, cfg.auditReadTimeout) assert.Equal(t, 30*time.Second, cfg.auditWriteTimeout) assert.Equal(t, 60*time.Second, cfg.auditIdleTimeout) - assert.Equal(t, "", cfg.sopsBinaryPath) - assert.Equal(t, "", cfg.sopsConfigPath) + assert.Empty(t, cfg.sopsBinaryPath) + assert.Empty(t, cfg.sopsConfigPath) } func TestParseFlagsWithArgs_AuditUnsecure(t *testing.T) { diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index a399fc54..8f9f12ea 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -58,8 +58,9 @@ type BranchWorker struct { Branch string // Dependencies - Client client.Client - Log logr.Logger + Client client.Client + Log logr.Logger + contentWriter *contentWriter // Event processing eventQueue chan Event @@ -82,7 +83,11 @@ func NewBranchWorker( log logr.Logger, providerName, providerNamespace string, branch string, + writer *contentWriter, ) *BranchWorker { + if writer == nil { + writer = newContentWriter() + } return &BranchWorker{ GitProviderRef: providerName, GitProviderNamespace: providerNamespace, @@ -93,7 +98,8 @@ func NewBranchWorker( "namespace", providerNamespace, "branch", branch, ), - eventQueue: make(chan Event, branchWorkerQueueSize), + contentWriter: writer, + eventQueue: make(chan Event, branchWorkerQueueSize), } } @@ -284,7 +290,7 @@ func (w *BranchWorker) commitAndPush( w.GitProviderNamespace, w.GitProviderRef, w.Branch) // Use new WriteEvents abstraction - result, err := WriteEvents(w.ctx, repoPath, events, w.Branch, auth) + result, err := WriteEventsWithContentWriter(w.ctx, w.contentWriter, repoPath, events, w.Branch, auth) if err != nil { log.Error(err, "Failed to write events") return diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 4aa5b423..9ee3f8cc 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -41,7 +41,7 @@ func setupBranchWorkerTest() (*BranchWorker, func()) { client := fake.NewClientBuilder().WithScheme(scheme).Build() log := logr.Discard() - worker := NewBranchWorker(client, log, "test-repo", "gitops-system", "main") + worker := NewBranchWorker(client, log, "test-repo", "gitops-system", "main", nil) cleanup := func() { if worker.started { @@ -170,7 +170,7 @@ func TestBranchWorker_EmptyRepository(t *testing.T) { _ = configv1alpha1.AddToScheme(scheme) client := fake.NewClientBuilder().WithScheme(scheme).Build() logger := logr.Discard() - worker := NewBranchWorker(client, logger, "test-repo", "default", "main") + worker := NewBranchWorker(client, logger, "test-repo", "default", "main", nil) // Create a GitProvider in the fake client pointing to our empty repo repoConfig := &configv1alpha1.GitProvider{ @@ -213,7 +213,7 @@ func TestBranchWorker_IdentityFields(t *testing.T) { client := fake.NewClientBuilder().WithScheme(scheme).Build() log := logr.Discard() - worker := NewBranchWorker(client, log, "my-repo", "my-namespace", "develop") + worker := NewBranchWorker(client, log, "my-repo", "my-namespace", "develop", nil) if worker.GitProviderRef != "my-repo" { t.Errorf("Expected GitProviderRef 'my-repo', got %q", worker.GitProviderRef) diff --git a/internal/git/content_writer.go b/internal/git/content_writer.go index 4f5ad372..be624a0c 100644 --- a/internal/git/content_writer.go +++ b/internal/git/content_writer.go @@ -51,6 +51,10 @@ type contentWriter struct { secretMarker map[string]secretMarker } +type eventContentWriter interface { + buildContentForWrite(ctx context.Context, event Event) ([]byte, error) +} + func newContentWriter() *contentWriter { return &contentWriter{ secretCache: make(map[string][]byte), @@ -64,11 +68,9 @@ func (w *contentWriter) setEncryptor(encryptor Encryptor) { w.encryptor = encryptor } -var defaultContentWriter = newContentWriter() - // buildContentForWrite renders event content to stable ordered YAML and applies // Secret-specific encryption when configured. -func buildContentForWrite(ctx context.Context, event Event) ([]byte, error) { +func (w *contentWriter) buildContentForWrite(ctx context.Context, event Event) ([]byte, error) { content, err := sanitize.MarshalToOrderedYAML(event.Object) if err != nil { return nil, fmt.Errorf("failed to marshal object to YAML: %w", err) @@ -78,7 +80,7 @@ func buildContentForWrite(ctx context.Context, event Event) ([]byte, error) { return content, nil } - return defaultContentWriter.encryptSecretContent(ctx, event, content) + return w.encryptSecretContent(ctx, event, content) } func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, plain []byte) ([]byte, error) { @@ -94,20 +96,11 @@ func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, p w.mu.RLock() encryptor := w.encryptor - lastMarker, markerExists := w.secretMarker[identityKey] - if markerExists && lastMarker == currentMarker { - if cached, ok := w.secretCache[cacheKey]; ok { - if metrics.SecretEncryptionMarkerSkipsTotal != nil { - metrics.SecretEncryptionMarkerSkipsTotal.Add(ctx, 1) - } - if metrics.SecretEncryptionCacheHitsTotal != nil { - metrics.SecretEncryptionCacheHitsTotal.Add(ctx, 1) - } - w.mu.RUnlock() - return append([]byte(nil), cached...), nil - } - } + cached, ok := w.cachedEncryptedSecret(ctx, identityKey, cacheKey, currentMarker) w.mu.RUnlock() + if ok { + return cached, nil + } if encryptor == nil { return nil, errors.New("secret encryption is required but no encryptor is configured") @@ -116,12 +109,7 @@ func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, p if metrics.SecretEncryptionAttemptsTotal != nil { metrics.SecretEncryptionAttemptsTotal.Add(ctx, 1) } - encrypted, err := encryptor.Encrypt(ctx, plain, ResourceMeta{ - Identifier: meta.Identifier, - UID: meta.UID, - ResourceVersion: meta.ResourceVersion, - Generation: meta.Generation, - }) + encrypted, err := encryptor.Encrypt(ctx, plain, ResourceMeta(meta)) if err != nil { if metrics.SecretEncryptionFailuresTotal != nil { metrics.SecretEncryptionFailuresTotal.Add(ctx, 1) @@ -140,6 +128,28 @@ func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, p return encrypted, nil } +func (w *contentWriter) cachedEncryptedSecret( + ctx context.Context, + identityKey, cacheKey string, + currentMarker secretMarker, +) ([]byte, bool) { + lastMarker, markerExists := w.secretMarker[identityKey] + if !markerExists || lastMarker != currentMarker { + return nil, false + } + cached, ok := w.secretCache[cacheKey] + if !ok { + return nil, false + } + if metrics.SecretEncryptionMarkerSkipsTotal != nil { + metrics.SecretEncryptionMarkerSkipsTotal.Add(ctx, 1) + } + if metrics.SecretEncryptionCacheHitsTotal != nil { + metrics.SecretEncryptionCacheHitsTotal.Add(ctx, 1) + } + return append([]byte(nil), cached...), true +} + func buildResourceMeta(event Event) resourceMeta { meta := resourceMeta{ Identifier: event.Identifier, diff --git a/internal/git/content_writer_test.go b/internal/git/content_writer_test.go index 9ac13188..5f14a94f 100644 --- a/internal/git/content_writer_test.go +++ b/internal/git/content_writer_test.go @@ -45,9 +45,7 @@ func (s *stubEncryptor) Encrypt(_ context.Context, _ []byte, _ ResourceMeta) ([] } func TestBuildContentForWrite_MarshalOrderedYAML(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() event := Event{ Object: &unstructured.Unstructured{ @@ -65,7 +63,7 @@ func TestBuildContentForWrite_MarshalOrderedYAML(t *testing.T) { }, } - got, err := buildContentForWrite(context.Background(), event) + got, err := writer.buildContentForWrite(context.Background(), event) require.NoError(t, err) output := string(got) @@ -79,9 +77,7 @@ func TestBuildContentForWrite_MarshalOrderedYAML(t *testing.T) { } func TestBuildContentForWrite_ReturnsMarshalError(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() event := Event{ Object: &unstructured.Unstructured{ @@ -99,15 +95,13 @@ func TestBuildContentForWrite_ReturnsMarshalError(t *testing.T) { }, } - _, err := buildContentForWrite(context.Background(), event) + _, err := writer.buildContentForWrite(context.Background(), event) require.Error(t, err) assert.Contains(t, err.Error(), "failed to marshal object to YAML") } func TestBuildContentForWrite_SecretRequiresEncryptor(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() event := Event{ Identifier: types.ResourceIdentifier{ @@ -132,18 +126,16 @@ func TestBuildContentForWrite_SecretRequiresEncryptor(t *testing.T) { }, } - _, err := buildContentForWrite(context.Background(), event) + _, err := writer.buildContentForWrite(context.Background(), event) require.Error(t, err) assert.Contains(t, err.Error(), "secret encryption is required but no encryptor is configured") } func TestBuildContentForWrite_SecretEncryptionCacheMarkerReuse(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() enc := &stubEncryptor{result: []byte("encrypted: true\nsops:\n version: 3.9.0\n")} - defaultContentWriter.setEncryptor(enc) + writer.setEncryptor(enc) obj := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -173,21 +165,19 @@ func TestBuildContentForWrite_SecretEncryptionCacheMarkerReuse(t *testing.T) { Object: obj, } - first, err := buildContentForWrite(context.Background(), event) + first, err := writer.buildContentForWrite(context.Background(), event) require.NoError(t, err) - second, err := buildContentForWrite(context.Background(), event) + second, err := writer.buildContentForWrite(context.Background(), event) require.NoError(t, err) assert.Equal(t, 1, enc.callCount) assert.Equal(t, first, second) } func TestBuildContentForWrite_SecretUIDChangeForcesReencrypt(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() enc := &stubEncryptor{result: []byte("encrypted: true\nsops:\n version: 3.9.0\n")} - defaultContentWriter.setEncryptor(enc) + writer.setEncryptor(enc) makeSecret := func(uid string) *unstructured.Unstructured { return &unstructured.Unstructured{ @@ -218,21 +208,19 @@ func TestBuildContentForWrite_SecretUIDChangeForcesReencrypt(t *testing.T) { }, Object: makeSecret("uid-1"), } - _, err := buildContentForWrite(context.Background(), event) + _, err := writer.buildContentForWrite(context.Background(), event) require.NoError(t, err) event.Object = makeSecret("uid-2") - _, err = buildContentForWrite(context.Background(), event) + _, err = writer.buildContentForWrite(context.Background(), event) require.NoError(t, err) assert.Equal(t, 2, enc.callCount) } func TestBuildContentForWrite_SecretEncryptionFailure(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) + writer := newContentWriter() - defaultContentWriter.setEncryptor(&stubEncryptor{err: errors.New("boom")}) + writer.setEncryptor(&stubEncryptor{err: errors.New("boom")}) event := Event{ Identifier: types.ResourceIdentifier{ @@ -257,7 +245,7 @@ func TestBuildContentForWrite_SecretEncryptionFailure(t *testing.T) { }, } - _, err := buildContentForWrite(context.Background(), event) + _, err := writer.buildContentForWrite(context.Background(), event) require.Error(t, err) assert.Contains(t, err.Error(), "secret encryption failed") } diff --git a/internal/git/encryption.go b/internal/git/encryption.go index 1b404ac9..73c406ca 100644 --- a/internal/git/encryption.go +++ b/internal/git/encryption.go @@ -47,10 +47,9 @@ type EncryptionConfig struct { SOPSConfigPath string } -// ConfigureSecretEncryption wires Secret encryption for git write paths. -func ConfigureSecretEncryption(cfg EncryptionConfig) error { +func configureSecretEncryptionWriter(writer *contentWriter, cfg EncryptionConfig) error { if strings.TrimSpace(cfg.SOPSBinaryPath) == "" { - defaultContentWriter.setEncryptor(nil) + writer.setEncryptor(nil) return nil } @@ -58,7 +57,7 @@ func ConfigureSecretEncryption(cfg EncryptionConfig) error { return err } - defaultContentWriter.setEncryptor(NewSOPSEncryptor(cfg.SOPSBinaryPath, cfg.SOPSConfigPath)) + writer.setEncryptor(NewSOPSEncryptor(cfg.SOPSBinaryPath, cfg.SOPSConfigPath)) return nil } diff --git a/internal/git/encryption_test.go b/internal/git/encryption_test.go index 01917cfa..26740edf 100644 --- a/internal/git/encryption_test.go +++ b/internal/git/encryption_test.go @@ -62,11 +62,8 @@ func TestEncryptionConfigValidate(t *testing.T) { }) } -func TestConfigureSecretEncryption(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) - - require.NoError(t, ConfigureSecretEncryption(EncryptionConfig{})) - assert.Nil(t, defaultContentWriter.encryptor) +func TestConfigureSecretEncryptionWriter(t *testing.T) { + writer := newContentWriter() + require.NoError(t, configureSecretEncryptionWriter(writer, EncryptionConfig{})) + assert.Nil(t, writer.encryptor) } diff --git a/internal/git/git.go b/internal/git/git.go index 62d72c04..ba24d33c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -176,9 +176,24 @@ func WriteEvents( events []Event, targetBranchName string, auth transport.AuthMethod, +) (*WriteEventsResult, error) { + return WriteEventsWithContentWriter(ctx, newContentWriter(), repoPath, events, targetBranchName, auth) +} + +// WriteEventsWithContentWriter handles writes with explicit content writer dependencies. +func WriteEventsWithContentWriter( + ctx context.Context, + writer eventContentWriter, + repoPath string, + events []Event, + targetBranchName string, + auth transport.AuthMethod, ) (*WriteEventsResult, error) { logger := log.FromContext(ctx) logger.Info("Starting writeEvents operation", "path", repoPath, "events", len(events)) + if writer == nil { + return nil, errors.New("content writer is required") + } repo, err := git.PlainOpen(repoPath) if err != nil { @@ -202,40 +217,69 @@ func WriteEvents( } } - baseBranch, baseHash, err := GetCurrentBranch(repo) + done, err := tryWriteEventsAttempt( + ctx, + logger, + writer, + result, + repo, + events, + targetBranch, + targetBranchName, + auth, + ) if err != nil { return nil, err } - - // If we are not on targetBranch: time to create it - if baseBranch != targetBranch { - if err1 := switchOrCreateBranch(repo, targetBranch, logger, targetBranchName, baseHash); err1 != nil { - return nil, err1 - } + if done { + break } + } - commitsCreated, lastHash, err := generateCommitsFromEvents(ctx, repo, events) - if err != nil { - return nil, fmt.Errorf("failed to generate commits: %w", err) - } + return result, nil +} - // You never know what is pushed on origin: perhaps we now have a working copy that exactly is like it should be (todo: create a nice test) - result.CommitsCreated = commitsCreated - result.LastHash = printSha(lastHash) - if commitsCreated == 0 { - logger.Info("No commits created, no need to push it") - break - } +func tryWriteEventsAttempt( + ctx context.Context, + logger logr.Logger, + writer eventContentWriter, + result *WriteEventsResult, + repo *git.Repository, + events []Event, + targetBranch plumbing.ReferenceName, + targetBranchName string, + auth transport.AuthMethod, +) (bool, error) { + baseBranch, baseHash, err := GetCurrentBranch(repo) + if err != nil { + return false, err + } - // Attempt push with atomic verification - err = PushAtomic(ctx, repo, baseHash, baseBranch, auth) - if err == nil { - logger.Info("All events pushed to remote", "failureCount", result.Failures) - break + if baseBranch != targetBranch { + if err := switchOrCreateBranch(repo, targetBranch, logger, targetBranchName, baseHash); err != nil { + return false, err } } - return result, nil + commitsCreated, lastHash, err := generateCommitsFromEvents(ctx, writer, repo, events) + if err != nil { + return false, fmt.Errorf("failed to generate commits: %w", err) + } + + // You never know what is pushed on origin: perhaps we now have a working copy that exactly is like it should be (todo: create a nice test) + result.CommitsCreated = commitsCreated + result.LastHash = printSha(lastHash) + if commitsCreated == 0 { + logger.Info("No commits created, no need to push it") + return true, nil + } + + if err := PushAtomic(ctx, repo, baseHash, baseBranch, auth); err == nil { + logger.Info("All events pushed to remote", "failureCount", result.Failures) + return true, nil + } + + return false, nil } func tryResolve( @@ -646,7 +690,12 @@ func setHead(r *git.Repository, branchName string) error { } // generateCommitsFromEvents creates commits from the provided events. -func generateCommitsFromEvents(ctx context.Context, repo *git.Repository, events []Event) (int, plumbing.Hash, error) { +func generateCommitsFromEvents( + ctx context.Context, + writer eventContentWriter, + repo *git.Repository, + events []Event, +) (int, plumbing.Hash, error) { logger := log.FromContext(ctx) lastHash := plumbing.ZeroHash worktree, err := repo.Worktree() @@ -656,7 +705,7 @@ func generateCommitsFromEvents(ctx context.Context, repo *git.Repository, events commitsCreated := 0 for _, event := range events { - changesApplied, err := applyEventToWorktree(ctx, worktree, event) + changesApplied, err := applyEventToWorktree(ctx, writer, worktree, event) if err != nil { return commitsCreated, lastHash, err } @@ -675,7 +724,12 @@ func generateCommitsFromEvents(ctx context.Context, repo *git.Repository, events } // applyEventToWorktree applies an event to the worktree, returning true if changes were made. -func applyEventToWorktree(ctx context.Context, worktree *git.Worktree, event Event) (bool, error) { +func applyEventToWorktree( + ctx context.Context, + writer eventContentWriter, + worktree *git.Worktree, + event Event, +) (bool, error) { logger := log.FromContext(ctx) filePath := event.Identifier.ToGitPath() @@ -691,7 +745,7 @@ func applyEventToWorktree(ctx context.Context, worktree *git.Worktree, event Eve return handleDeleteOperation(logger, filePath, fullPath, worktree) } - return handleCreateOrUpdateOperation(ctx, event, filePath, fullPath, worktree) + return handleCreateOrUpdateOperation(ctx, writer, event, filePath, fullPath, worktree) } // handleDeleteOperation removes a file from the repository. @@ -731,11 +785,12 @@ func handleDeleteOperation( // Returns true if changes were made, false if the file already has the desired content. func handleCreateOrUpdateOperation( ctx context.Context, + writer eventContentWriter, event Event, filePath, fullPath string, worktree *git.Worktree, ) (bool, error) { - content, err := buildContentForWrite(ctx, event) + content, err := writer.buildContentForWrite(ctx, event) if err != nil { if isSecretResource(event.Identifier) { log.FromContext(ctx).Info( diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go index a2ce359b..925eec19 100644 --- a/internal/git/secret_write_test.go +++ b/internal/git/secret_write_test.go @@ -33,10 +33,6 @@ import ( ) func TestWriteEvents_SecretEncryptionFailureDoesNotWritePlaintext(t *testing.T) { - originalWriter := defaultContentWriter - defaultContentWriter = newContentWriter() - t.Cleanup(func() { defaultContentWriter = originalWriter }) - repoPath := t.TempDir() _, err := gogit.PlainInit(repoPath, false) require.NoError(t, err) diff --git a/internal/git/sops_encryptor.go b/internal/git/sops_encryptor.go index 19282e3f..bffaa08d 100644 --- a/internal/git/sops_encryptor.go +++ b/internal/git/sops_encryptor.go @@ -19,11 +19,10 @@ limitations under the License. package git import ( + "bytes" "context" "fmt" - "os" "os/exec" - "path/filepath" "strings" ) @@ -41,31 +40,20 @@ func NewSOPSEncryptor(binaryPath, configPath string) *SOPSEncryptor { } } -// Encrypt writes plaintext to a secure temp file, encrypts it with sops, and -// returns encrypted YAML bytes. +// Encrypt streams plaintext YAML to sops over stdin and returns encrypted YAML bytes. func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMeta) ([]byte, error) { - tmpDir, err := os.MkdirTemp("", "gitops-reverser-sops-*") - if err != nil { - return nil, fmt.Errorf("failed to create temp dir for sops: %w", err) - } - defer func() { _ = os.RemoveAll(tmpDir) }() - - inFile := filepath.Join(tmpDir, "secret.yaml") - if writeErr := os.WriteFile(inFile, plain, 0600); writeErr != nil { - return nil, fmt.Errorf("failed to write temp secret file: %w", writeErr) - } - args := []string{ "--encrypt", "--input-type", "yaml", "--output-type", "yaml", - inFile, + "/dev/stdin", } if strings.TrimSpace(e.configPath) != "" { args = append([]string{"--config", e.configPath}, args...) } - cmd := exec.CommandContext(ctx, e.binaryPath, args...) + cmd := newCommandContext(ctx, e.binaryPath, args...) + cmd.Stdin = bytes.NewReader(plain) out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("sops encryption failed: %w: %s", err, strings.TrimSpace(string(out))) @@ -73,3 +61,7 @@ func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMet return out, nil } + +func newCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, name, args...) +} diff --git a/internal/git/sops_encryptor_test.go b/internal/git/sops_encryptor_test.go index 1beaf797..a8c81bab 100644 --- a/internal/git/sops_encryptor_test.go +++ b/internal/git/sops_encryptor_test.go @@ -33,7 +33,6 @@ func TestSOPSEncryptorEncrypt(t *testing.T) { script := filepath.Join(dir, "sops") require.NoError(t, os.WriteFile(script, []byte(`#!/usr/bin/env bash set -euo pipefail -infile="${@: -1}" cat <<'EOF' apiVersion: v1 kind: Secret @@ -41,7 +40,7 @@ sops: version: 3.9.0 encrypted_regex: "^(data|stringData)$" EOF -cat "$infile" >/dev/null +cat >/dev/null `), 0700)) encryptor := NewSOPSEncryptor(script, "") diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index 4104b5ea..42eda663 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -36,20 +36,29 @@ type WorkerManager struct { Client client.Client Log logr.Logger - mu sync.RWMutex - workers map[BranchKey]*BranchWorker - ctx context.Context + contentWriter *contentWriter + mu sync.RWMutex + workers map[BranchKey]*BranchWorker + ctx context.Context } // NewWorkerManager creates a new worker manager. func NewWorkerManager(client client.Client, log logr.Logger) *WorkerManager { return &WorkerManager{ - Client: client, - Log: log, - workers: make(map[BranchKey]*BranchWorker), + Client: client, + Log: log, + contentWriter: newContentWriter(), + workers: make(map[BranchKey]*BranchWorker), } } +// ConfigureSecretEncryption applies Secret write encryption settings for all workers. +func (m *WorkerManager) ConfigureSecretEncryption(cfg EncryptionConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + return configureSecretEncryptionWriter(m.contentWriter, cfg) +} + // RegisterTarget ensures a worker exists for the target's (provider, branch) // and registers the target with that worker. // This is called by GitTarget controller when a target becomes Ready. @@ -71,8 +80,14 @@ func (m *WorkerManager) RegisterTarget( // Get or create worker for this (provider, branch) if _, exists := m.workers[key]; !exists { m.Log.Info("Creating new branch worker", "key", key.String()) - worker := NewBranchWorker(m.Client, m.Log.WithName("branch-worker"), - providerName, providerNamespace, branch) + worker := NewBranchWorker( + m.Client, + m.Log.WithName("branch-worker"), + providerName, + providerNamespace, + branch, + m.contentWriter, + ) if err := worker.Start(m.ctx); err != nil { return fmt.Errorf("failed to start worker for %s: %w", key.String(), err) diff --git a/internal/metrics/exporter.go b/internal/metrics/exporter.go index a207023b..8d83e2e6 100644 --- a/internal/metrics/exporter.go +++ b/internal/metrics/exporter.go @@ -77,11 +77,15 @@ var ( WatchDuplicatesSkippedTotal metric.Int64Counter // AuditEventsReceivedTotal counts audit events received from Kubernetes API server. AuditEventsReceivedTotal metric.Int64Counter - // Secret encryption pipeline counters. - SecretEncryptionAttemptsTotal metric.Int64Counter - SecretEncryptionSuccessTotal metric.Int64Counter - SecretEncryptionFailuresTotal metric.Int64Counter - SecretEncryptionCacheHitsTotal metric.Int64Counter + // SecretEncryptionAttemptsTotal counts total Secret encryption attempts. + SecretEncryptionAttemptsTotal metric.Int64Counter + // SecretEncryptionSuccessTotal counts successful Secret encryptions. + SecretEncryptionSuccessTotal metric.Int64Counter + // SecretEncryptionFailuresTotal counts failed Secret encryptions. + SecretEncryptionFailuresTotal metric.Int64Counter + // SecretEncryptionCacheHitsTotal counts cache hits for encrypted Secret content. + SecretEncryptionCacheHitsTotal metric.Int64Counter + // SecretEncryptionMarkerSkipsTotal counts marker-based skips that reused cached Secret content. SecretEncryptionMarkerSkipsTotal metric.Int64Counter ) From 7b271cfddefb2f94112e7fd02fe499c14de3a66d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 09:00:22 +0000 Subject: [PATCH 03/11] chore: switch to .sops.yaml for encrypted files --- internal/git/git.go | 24 ++++++++- internal/git/helpers.go | 1 + internal/git/helpers_test.go | 25 +++++++++ internal/git/secret_write_test.go | 89 +++++++++++++++++++++++++++++++ test/e2e/e2e_test.go | 2 +- 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index ba24d33c..ae38d64c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -39,6 +39,8 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/ConfigButler/gitops-reverser/internal/types" ) var ( @@ -732,7 +734,7 @@ func applyEventToWorktree( ) (bool, error) { logger := log.FromContext(ctx) - filePath := event.Identifier.ToGitPath() + filePath := generateFilePath(event.Identifier) if event.Path != "" { if bf := sanitizePath(event.Path); bf != "" { filePath = path.Join(bf, filePath) @@ -745,7 +747,14 @@ func applyEventToWorktree( return handleDeleteOperation(logger, filePath, fullPath, worktree) } - return handleCreateOrUpdateOperation(ctx, writer, event, filePath, fullPath, worktree) + return handleCreateOrUpdateOperation( + ctx, + writer, + event, + filePath, + fullPath, + worktree, + ) } // handleDeleteOperation removes a file from the repository. @@ -829,6 +838,17 @@ func handleCreateOrUpdateOperation( return true, nil } +func generateFilePath(id types.ResourceIdentifier) string { + defaultPath := id.ToGitPath() + if !isSecretResource(id) { + return defaultPath + } + if strings.HasSuffix(defaultPath, ".yaml") { + return strings.TrimSuffix(defaultPath, ".yaml") + ".sops.yaml" + } + return defaultPath + ".sops.yaml" +} + // createCommitForEvent creates a commit for the given event. func createCommitForEvent(worktree *git.Worktree, event Event) (plumbing.Hash, error) { commitMessage := GetCommitMessage(event) diff --git a/internal/git/helpers.go b/internal/git/helpers.go index a8671b6d..d9f5503f 100644 --- a/internal/git/helpers.go +++ b/internal/git/helpers.go @@ -70,6 +70,7 @@ func parseIdentifierFromPath(p string) (itypes.ResourceIdentifier, bool) { } last := parts[len(parts)-1] name := strings.TrimSuffix(last, filepath.Ext(last)) + name = strings.TrimSuffix(name, ".sops") var group, version, resource, namespace string switch len(parts) { diff --git a/internal/git/helpers_test.go b/internal/git/helpers_test.go index 46e1fc84..2d0f69e4 100644 --- a/internal/git/helpers_test.go +++ b/internal/git/helpers_test.go @@ -317,3 +317,28 @@ func createTestEvent(tb testing.TB, name string) Event { }, } } + +func TestParseIdentifierFromPath_StripsYAMLExtensions(t *testing.T) { + tests := []struct { + path string + name string + }{ + { + path: "v1/secrets/default/db-secret.yaml", + name: "db-secret", + }, + { + path: "v1/secrets/default/db-secret.sops.yaml", + name: "db-secret", + }, + } + + for _, tt := range tests { + id, ok := parseIdentifierFromPath(tt.path) + require.True(t, ok) + require.Equal(t, "v1", id.Version) + require.Equal(t, "secrets", id.Resource) + require.Equal(t, "default", id.Namespace) + require.Equal(t, tt.name, id.Name) + } +} diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go index 925eec19..0353cfb2 100644 --- a/internal/git/secret_write_test.go +++ b/internal/git/secret_write_test.go @@ -23,8 +23,10 @@ import ( "os" "path/filepath" "testing" + "time" gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -70,5 +72,92 @@ func TestWriteEvents_SecretEncryptionFailureDoesNotWritePlaintext(t *testing.T) secretPath := filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.yaml") _, statErr := os.Stat(secretPath) + require.Error(t, statErr, "Secret file should not be written when encryption fails") + + sopsPath := filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.sops.yaml") + _, statErr = os.Stat(sopsPath) assert.Error(t, statErr, "Secret file should not be written when encryption fails") } + +func TestWriteEvents_SecretWritesSOPSPath(t *testing.T) { + repoPath := t.TempDir() + _, err := gogit.PlainInit(repoPath, false) + require.NoError(t, err) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "test-secret", + "namespace": "default", + }, + "data": map[string]interface{}{ + "password": "ZG8tbm90LWNvbW1pdA==", + }, + }, + }, + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "test-secret", + }, + Operation: "CREATE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + } + + writer := newContentWriter() + writer.setEncryptor(&stubEncryptor{result: []byte("encrypted: true\nsops:\n version: 3.9.0\n")}) + + _, err = WriteEventsWithContentWriter(context.Background(), writer, repoPath, []Event{event}, "master", nil) + require.NoError(t, err) + + sopsPath := filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.sops.yaml") + assert.FileExists(t, sopsPath) +} + +func TestWriteEvents_DeleteSecretRemovesSOPSPath(t *testing.T) { + repoPath := t.TempDir() + repo, err := gogit.PlainInit(repoPath, false) + require.NoError(t, err) + + sopsPath := filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.sops.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(sopsPath), 0750)) + require.NoError(t, os.WriteFile(sopsPath, []byte("encrypted"), 0600)) + + worktree, err := repo.Worktree() + require.NoError(t, err) + _, err = worktree.Add("v1/secrets/default/test-secret.sops.yaml") + require.NoError(t, err) + _, err = worktree.Commit("seed", &gogit.CommitOptions{ + Author: &object.Signature{Name: "seed", Email: "seed@example.com", When: time.Now()}, + }) + require.NoError(t, err) + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + Namespace: "default", + Name: "test-secret", + }, + Operation: "DELETE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + } + + _, err = WriteEvents(context.Background(), repoPath, []Event{event}, "master", nil) + require.NoError(t, err) + + _, statErr := os.Stat(filepath.Join(repoPath, "v1", "secrets", "default", "test-secret.yaml")) + require.Error(t, statErr) + _, statErr = os.Stat(sopsPath) + assert.ErrorIs(t, statErr, os.ErrNotExist) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 4495a4a6..530a364f 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -565,7 +565,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/secret-encryption-test", - fmt.Sprintf("v1/secrets/%s/%s.yaml", namespace, secretName)) + fmt.Sprintf("v1/secrets/%s/%s.sops.yaml", namespace, secretName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("Secret file must exist at %s", expectedFile)) g.Expect(string(content)).To(ContainSubstring("sops:")) From f6d0c522f6b29f8b718151ddf4da05e108057e4d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 20:17:17 +0000 Subject: [PATCH 04/11] chore: bootstrapping per unique path --- Dockerfile | 1 - README.md | 4 +- api/v1alpha1/gitprovider_types.go | 11 + api/v1alpha1/gittarget_types.go | 4 + api/v1alpha1/zz_generated.deepcopy.go | 23 +- .../gitops-reverser/templates/deployment.yaml | 4 - charts/gitops-reverser/values.yaml | 8 - cmd/main.go | 21 -- cmd/main_audit_server_test.go | 25 -- .../crd/bases/configbutler.ai_gittargets.yaml | 35 +++ config/deployment.yaml | 2 - config/sops/.sops.yaml | 3 - docs/SOPS_AGE_GUIDE.md | 128 ++++++++++ .../path-scoped-bootstrap-template-design.md | 75 ++++++ ...otstrap-and-key-management-architecture.md | 76 ++++++ .../sops-repo-bootstrap-out-of-scope.md | 17 ++ go.mod | 1 + go.sum | 2 + internal/controller/constants.go | 2 + .../git/bootstrapped-repo-template/.sops.yaml | 8 + .../git/bootstrapped-repo-template/README.md | 6 + internal/git/bootstrapped_repo_template.go | 233 ++++++++++++++++++ internal/git/branch_worker.go | 149 ++++++++++- internal/git/branch_worker_test.go | 196 ++++++++++++++- internal/git/content_writer.go | 6 +- internal/git/content_writer_test.go | 18 -- internal/git/encryption.go | 167 ++++++++++--- internal/git/encryption_test.go | 208 +++++++++++++--- internal/git/git.go | 11 +- internal/git/git_operations_test.go | 46 ++-- internal/git/secret_write_test.go | 67 +++++ internal/git/sops_encryptor.go | 48 +++- internal/git/types.go | 6 + internal/git/worker_manager.go | 32 ++- internal/git/worker_manager_test.go | 56 +++++ internal/reconcile/git_target_event_stream.go | 3 + internal/types/identifier.go | 5 + internal/types/identifier_test.go | 18 ++ test/e2e/e2e_test.go | 86 +++++++ test/e2e/helpers.go | 51 +++- test/e2e/scripts/setup-gitea.sh | 4 +- test/e2e/templates/gittarget.tmpl | 4 + 42 files changed, 1633 insertions(+), 237 deletions(-) delete mode 100644 config/sops/.sops.yaml create mode 100644 docs/SOPS_AGE_GUIDE.md create mode 100644 docs/design/path-scoped-bootstrap-template-design.md create mode 100644 docs/design/sops-repo-bootstrap-and-key-management-architecture.md create mode 100644 docs/design/sops-repo-bootstrap-out-of-scope.md create mode 100644 internal/git/bootstrapped-repo-template/.sops.yaml create mode 100644 internal/git/bootstrapped-repo-template/README.md create mode 100644 internal/git/bootstrapped_repo_template.go diff --git a/Dockerfile b/Dockerfile index 6e9a2223..657d9c7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,6 @@ FROM gcr.io/distroless/static:debug WORKDIR / COPY --from=builder /workspaces/manager . COPY --from=sops-downloader /usr/local/bin/sops /usr/local/bin/sops -COPY config/sops/.sops.yaml /etc/gitops-reverser/.sops.yaml USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/README.md b/README.md index b8e53ef7..08de47a6 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,8 @@ Avoid infinite loops: Do not point GitOps (Argo CD/Flux) and GitOps Reverser at - GitOps Reverser currently supports only a single controller pod (no multi-pod/HA yet). - `Secret` resources (`core/v1`, `secrets`) are written via the same pipeline, but sensitive values are expected to be encrypted before commit. - Configure encryption via `--sops-binary-path` and optional `--sops-config-path`. - - The container image ships with `/usr/local/bin/sops` and a default config at `/etc/gitops-reverser/.sops.yaml`. - - Override `--sops-config-path` with your own key policy for production environments. + - The container image ships with `/usr/local/bin/sops`. + - Per-path `.sops.yaml` files are bootstrapped in the target repo for SOPS-based secret encryption. - If Secret encryption fails, Secret writes are rejected (no plaintext fallback). - Avoid multiple GitProvider configurations pointing at the same repo to prevent queue collisions. - Queue collisions are possible when multiple configs target the same repository (so don't do that). diff --git a/api/v1alpha1/gitprovider_types.go b/api/v1alpha1/gitprovider_types.go index 796ae3ea..504bb859 100644 --- a/api/v1alpha1/gitprovider_types.go +++ b/api/v1alpha1/gitprovider_types.go @@ -57,6 +57,17 @@ type LocalSecretReference struct { Name string `json:"name"` } +// EncryptionSpec configures Secret encryption behavior for git writes. +type EncryptionSpec struct { + // Provider selects the encryption provider. + // +kubebuilder:default=sops + // +kubebuilder:validation:Enum=sops + Provider string `json:"provider"` + + // SecretRef references namespace-local Secret data used by the encryption provider. + SecretRef LocalSecretReference `json:"secretRef"` +} + // GitProviderStatus defines the observed state of GitProvider. type GitProviderStatus struct { // conditions represent the current state of the GitProvider resource. diff --git a/api/v1alpha1/gittarget_types.go b/api/v1alpha1/gittarget_types.go index 3023209a..ed741b74 100644 --- a/api/v1alpha1/gittarget_types.go +++ b/api/v1alpha1/gittarget_types.go @@ -55,6 +55,10 @@ type GitTargetSpec struct { // Path within the repository to write resources to. // +optional Path string `json:"path,omitempty"` + + // Encryption defines encryption settings for Secret resource writes. + // +optional + Encryption *EncryptionSpec `json:"encryption,omitempty"` } // GitTargetStatus defines the observed state of GitTarget. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index fce3c58f..a2c45247 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -166,6 +166,22 @@ func (in *ClusterWatchRuleStatus) DeepCopy() *ClusterWatchRuleStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EncryptionSpec) DeepCopyInto(out *EncryptionSpec) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EncryptionSpec. +func (in *EncryptionSpec) DeepCopy() *EncryptionSpec { + if in == nil { + return nil + } + out := new(EncryptionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitProvider) DeepCopyInto(out *GitProvider) { *out = *in @@ -297,7 +313,7 @@ func (in *GitTarget) DeepCopyInto(out *GitTarget) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -355,6 +371,11 @@ func (in *GitTargetList) DeepCopyObject() runtime.Object { func (in *GitTargetSpec) DeepCopyInto(out *GitTargetSpec) { *out = *in out.ProviderRef = in.ProviderRef + if in.Encryption != nil { + in, out := &in.Encryption, &out.Encryption + *out = new(EncryptionSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitTargetSpec. diff --git a/charts/gitops-reverser/templates/deployment.yaml b/charts/gitops-reverser/templates/deployment.yaml index b664e4cc..49e8a379 100644 --- a/charts/gitops-reverser/templates/deployment.yaml +++ b/charts/gitops-reverser/templates/deployment.yaml @@ -70,10 +70,6 @@ spec: - --audit-read-timeout={{ .Values.servers.audit.timeouts.read }} - --audit-write-timeout={{ .Values.servers.audit.timeouts.write }} - --audit-idle-timeout={{ .Values.servers.audit.timeouts.idle }} - - --sops-binary-path={{ .Values.sops.binaryPath }} - {{- if .Values.sops.configPath }} - - --sops-config-path={{ .Values.sops.configPath }} - {{- end }} {{- if .Values.logging.level }} - --zap-log-level={{ .Values.logging.level }} {{- end }} diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 8426de96..006c2e15 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -209,11 +209,3 @@ logging: labels: managedBy: Helm # Allows CI generated install.yaml creation (don't change when you are using Helm) - -# Secret encryption settings -sops: - # Absolute path to the sops binary in the controller image. - binaryPath: /usr/local/bin/sops - # Optional absolute path to a mounted .sops.yaml config file. - # Keep this empty unless you explicitly provide your own key policy. - configPath: "" diff --git a/cmd/main.go b/cmd/main.go index aad3bc0b..926618df 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -124,10 +124,6 @@ func main() { // Initialize WorkerManager (manages branch workers) workerManager := git.NewWorkerManager(mgr.GetClient(), ctrl.Log.WithName("worker-manager")) - fatalIfErr(workerManager.ConfigureSecretEncryption(git.EncryptionConfig{ - SOPSBinaryPath: cfg.sopsBinaryPath, - SOPSConfigPath: cfg.sopsConfigPath, - }), "unable to configure Secret encryption") fatalIfErr(mgr.Add(workerManager), "unable to add worker manager to manager") setupLog.Info("WorkerManager initialized and added to manager") @@ -293,8 +289,6 @@ type appConfig struct { auditReadTimeout time.Duration auditWriteTimeout time.Duration auditIdleTimeout time.Duration - sopsBinaryPath string - sopsConfigPath string zapOpts zap.Options } @@ -345,11 +339,6 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { "Write timeout for the dedicated audit ingress HTTPS server.") fs.DurationVar(&cfg.auditIdleTimeout, "audit-idle-timeout", defaultAuditIdleTimeout, "Idle timeout for the dedicated audit ingress HTTPS server.") - fs.StringVar(&cfg.sopsBinaryPath, "sops-binary-path", "", - "Absolute path to the sops binary used to encrypt Secret resources before writing to git.") - fs.StringVar(&cfg.sopsConfigPath, "sops-config-path", "", - "Absolute path to an optional sops config file.") - cfg.zapOpts = zap.Options{ Development: true, // Enable more detailed logging for debugging @@ -364,9 +353,6 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { if err := validateAuditConfig(cfg); err != nil { return appConfig{}, err } - if err := validateSOPSConfig(cfg); err != nil { - return appConfig{}, err - } return cfg, nil } @@ -416,13 +402,6 @@ func validateAuditConfig(cfg appConfig) error { return nil } -func validateSOPSConfig(cfg appConfig) error { - return git.EncryptionConfig{ - SOPSBinaryPath: cfg.sopsBinaryPath, - SOPSConfigPath: cfg.sopsConfigPath, - }.Validate() -} - // fatalIfErr logs and exits the process if err is not nil. func fatalIfErr(err error, msg string, keysAndValues ...any) { if err != nil { diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index 411318e7..a644eab9 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -20,8 +20,6 @@ import ( "flag" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" "time" @@ -44,8 +42,6 @@ func TestParseFlagsWithArgs_Defaults(t *testing.T) { assert.Equal(t, 15*time.Second, cfg.auditReadTimeout) assert.Equal(t, 30*time.Second, cfg.auditWriteTimeout) assert.Equal(t, 60*time.Second, cfg.auditIdleTimeout) - assert.Empty(t, cfg.sopsBinaryPath) - assert.Empty(t, cfg.sopsConfigPath) } func TestParseFlagsWithArgs_AuditUnsecure(t *testing.T) { @@ -127,27 +123,6 @@ func TestParseFlagsWithArgs_InvalidAuditSettings(t *testing.T) { } } -func TestParseFlagsWithArgs_SOPSValidation(t *testing.T) { - t.Run("invalid sops binary path", func(t *testing.T) { - fs := flag.NewFlagSet("test-invalid-sops-bin", flag.ContinueOnError) - _, err := parseFlagsWithArgs(fs, []string{"--sops-binary-path=/does/not/exist/sops"}) - require.Error(t, err) - }) - - t.Run("invalid sops config relative path", func(t *testing.T) { - dir := t.TempDir() - binPath := filepath.Join(dir, "sops") - require.NoError(t, os.WriteFile(binPath, []byte("bin"), 0700)) - - fs := flag.NewFlagSet("test-invalid-sops-config", flag.ContinueOnError) - _, err := parseFlagsWithArgs(fs, []string{ - "--sops-binary-path=" + binPath, - "--sops-config-path=relative/config.yaml", - }) - require.Error(t, err) - }) -} - func TestBuildAuditServeMux_RoutesAuditPaths(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index baf2d5e4..aa159c5a 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -44,6 +44,41 @@ spec: Branch to use for this target. Must be one of the allowed branches in the provider. type: string + encryption: + description: Encryption defines encryption settings for Secret resource + writes. + properties: + provider: + default: sops + description: Provider selects the encryption provider. + enum: + - sops + type: string + secretRef: + description: SecretRef references namespace-local Secret data + used by the encryption provider. + properties: + group: + default: "" + description: Group of the referent. + type: string + kind: + default: Secret + description: Kind of the referent. + enum: + - Secret + type: string + name: + description: Name of the Secret. + minLength: 1 + type: string + required: + - name + type: object + required: + - provider + - secretRef + type: object path: description: Path within the repository to write resources to. type: string diff --git a/config/deployment.yaml b/config/deployment.yaml index 1e391873..eddd54f5 100644 --- a/config/deployment.yaml +++ b/config/deployment.yaml @@ -28,8 +28,6 @@ spec: - --health-probe-bind-address=:8081 - --webhook-cert-path=/tmp/k8s-admission-server/admission-server-certs - --audit-cert-path=/tmp/k8s-audit-server/audit-server-certs - - --sops-binary-path=/usr/local/bin/sops - - --sops-config-path=/etc/gitops-reverser/.sops.yaml command: - /manager env: diff --git a/config/sops/.sops.yaml b/config/sops/.sops.yaml deleted file mode 100644 index 6c5c0ba5..00000000 --- a/config/sops/.sops.yaml +++ /dev/null @@ -1,3 +0,0 @@ -creation_rules: - - encrypted_regex: "^(data|stringData)$" - age: "age1q0u59kpa7qeh2tyktekm8hxxr3q2378wdp9z2xsgfe3xdky6lg6scuuy8p" diff --git a/docs/SOPS_AGE_GUIDE.md b/docs/SOPS_AGE_GUIDE.md new file mode 100644 index 00000000..d507fef3 --- /dev/null +++ b/docs/SOPS_AGE_GUIDE.md @@ -0,0 +1,128 @@ +# SOPS + age Guide + +This is a practical reference for how `sops` and `age` fit together in this repo, and which assets/commands matter. + +## Mental model + +- `age` is the crypto system. +- `sops` is the tool that encrypts/decrypts YAML/JSON/etc. and stores metadata. +- You encrypt *to recipients* (public keys). +- You decrypt with matching private keys. + +For this repo: +- `.sops.yaml` contains recipient rules (public info). +- Private key material is provided at runtime via Kubernetes Secret env entries (for example `SOPS_AGE_KEY`). +- Secret manifests written to Git are stored as `*.sops.yaml`. + +## Important assets + +- `age` private key: secret, never commit. +- `age` recipient (public key): safe to commit/share. +- `.sops.yaml`: encryption policy/rules and recipients (usually committed). +- encrypted `*.sops.yaml` files: committed artifacts. + +## Install tools + +```bash +# macOS +brew install sops age + +# Ubuntu/Debian +sudo apt-get update +sudo apt-get install -y age +# sops may come from distro package or release binary +``` + +## Create a new age keypair + +```bash +umask 077 +age-keygen -o age-key.txt +``` + +This creates: +- private key in `age-key.txt` (line starts with `AGE-SECRET-KEY-...`) +- recipient in a comment line like `# public key: age1...` + +Get recipient from private key file: + +```bash +age-keygen -y age-key.txt +``` + +## Create/update `.sops.yaml` + +Minimal example: + +```yaml +creation_rules: + - path_regex: '.*\.sops\.ya?ml$' + encrypted_regex: '^(data|stringData)$' + key_groups: + - age: + - "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +Notes: +- `path_regex` controls which files are encrypted with this rule. +- `encrypted_regex` keeps Kubernetes Secret payload fields encrypted. +- You can add multiple recipients under `age:`. + +## Encrypt/decrypt with sops + +Encrypt file: + +```bash +sops --encrypt --input-type yaml --output-type yaml \ + secret.yaml > secret.sops.yaml +``` + +Decrypt file: + +```bash +SOPS_AGE_KEY="$(grep '^AGE-SECRET-KEY-' age-key.txt)" \ + sops --decrypt secret.sops.yaml +``` + +Or use key file: + +```bash +export SOPS_AGE_KEY_FILE="$PWD/age-key.txt" +sops --decrypt secret.sops.yaml +``` + +## Key material in Kubernetes + +In this project, encryption env vars are sourced from `GitTarget.spec.encryption.secretRef`. +Create the runtime secret like this (namespace must match the `GitTarget` namespace): + +```bash +kubectl -n sut create secret generic sops-age-key \ + --from-literal=SOPS_AGE_KEY="$(grep '^AGE-SECRET-KEY-' age-key.txt)" +``` + +Then reference it from `GitTarget.spec.encryption.secretRef.name`. + +## Rotation (new recipient/key) + +1. Generate new keypair. +2. Add new recipient to `.sops.yaml`. +3. Re-wrap existing encrypted files: + +```bash +sops updatekeys -y path/to/file.sops.yaml +# or for many files +find . -name '*.sops.yaml' -print0 | xargs -0 -n1 sops updatekeys -y +``` + +4. Deploy new private key to runtime secret. +5. Remove old recipient from `.sops.yaml` and run `updatekeys` again when ready. + +## Repo-specific security note + +The bootstrap template currently contains a static recipient in +`internal/git/bootstrapped-repo-template/.sops.yaml`. + +- This is a public recipient, not a private key. +- It is not auto-replaced by the controller. +- If you need your own keys, update committed `.sops.yaml` in the repo path and re-wrap files with `sops updatekeys`. diff --git a/docs/design/path-scoped-bootstrap-template-design.md b/docs/design/path-scoped-bootstrap-template-design.md new file mode 100644 index 00000000..88b1cb06 --- /dev/null +++ b/docs/design/path-scoped-bootstrap-template-design.md @@ -0,0 +1,75 @@ +# Path-Scoped Bootstrap Template Design + +## Decision + +Use path existence in Git as the bootstrap signal: +- if target path contains at least one file, skip bootstrap +- if target path is empty or missing, apply bootstrap template and push + +No `GitTarget.status` persistence for bootstrap is needed in this increment. + +## Why this is acceptable + +- Cheap check: local filesystem walk inside already-cloned worker repo. +- Simple mental model: Git content is source of truth. +- Survives controller restarts without extra status logic. + +Tradeoff: +- if users manually add any file in the path, bootstrap will be skipped (intentional with this model). + +## Required behavior + +- Bootstrap is path-scoped (`GitTarget.spec.path`), not repo-root scoped. +- Trigger on target registration (or first use), not only on unborn branch init. +- Keep normal event writes focused on resource mirroring; no bootstrap healing loop. + +## Implementation Order + +1. Remove branch-level bootstrap from `PrepareBranch` +- File: `internal/git/git.go` +- Remove `commitBootstrapTemplateIfNeeded(...)` call from unborn-branch flow. +- `PrepareBranch` should only ensure clone/fetch/branch readiness. + +2. Make template writer path-aware +- File: `internal/git/bootstrapped_repo_template.go` +- Rename internals from repo bootstrap to path bootstrap semantics. +- Add function to stage template under a base path: `//...`. +- Keep commit metadata and message explicit, e.g. `chore(bootstrap): initialize path `. + +3. Add worker service for path bootstrap +- File: `internal/git/branch_worker.go` +- Add synchronous method: `EnsurePathBootstrapped(path string) error`. +- Steps inside method: + - ensure repo initialized + - normalize path + - check whether path has any file + - if empty: stage template in that path, commit, push + - if non-empty: return nil (no-op) + +4. Call bootstrap from registration flow +- File: `internal/git/worker_manager.go` +- In `RegisterTarget(...)`, after obtaining/creating worker: + - call `worker.EnsurePathBootstrapped(path)` + - return error if bootstrap fails +- Do not add persisted/in-memory bootstrapped path tracking in this increment. + +5. Keep encryption scope unchanged in this document +- This document only covers bootstrap trigger semantics. +- Encryption scoping work is separate and can be layered afterward. + +6. Tests +- Add/update tests in: + - `internal/git/git_operations_test.go` + - `internal/git/branch_worker_test.go` + - `internal/git/worker_manager_test.go` (or add if missing) +- Must cover: + - empty path bootstraps once + - non-empty path skips bootstrap + - nested distinct paths can both bootstrap when each is empty + - restart/re-register behavior uses Git content check correctly + +## Notes and Edge Cases + +- Path normalization must be shared with existing sanitizer rules. +- Repo root path (`""`) is valid and should use same check. +- No automatic cleanup when target path changes. diff --git a/docs/design/sops-repo-bootstrap-and-key-management-architecture.md b/docs/design/sops-repo-bootstrap-and-key-management-architecture.md new file mode 100644 index 00000000..3e396e8a --- /dev/null +++ b/docs/design/sops-repo-bootstrap-and-key-management-architecture.md @@ -0,0 +1,76 @@ +# SOPS Repo Bootstrap and Key Management Architecture + +## Scope for This Increment + +This document is intentionally narrow. It defines only what we will implement now. + +## Decisions + +- Do not auto-generate key material. +- Do not support a generic controller-level `.sops.yaml` override. +- Ensure root `.sops.yaml` only from the write path, and only for branches we write to. +- For now, only ensure presence of `.sops.yaml`; do not validate content equality. +- "Matches Secret semantics" is defined by `isSecretResource` in `internal/git/content_writer.go`: move this Secret-semantics function to `internal/types/identifier.go` so it can be reused consistently. +- Align naming with Flux-style shape, but mirrored for reverse-gitops encryption: + - use `spec.encryption.provider` + - use `spec.encryption.secretRef.name` + - do not overload existing Git auth `spec.secretRef` +- Do not include `spec.encryption.serviceAccountName` in this increment. + +## Behavior Contract + +- On any write flow to a branch: + - If root `.sops.yaml` is missing on that branch, write it as part of the same commit flow. + - If root `.sops.yaml` exists, leave it unchanged. +- Secret detection for encryption-related behavior uses the same Secret-semantics function everywhere. +- `GitProvider.spec.secretRef` continues to mean Git authentication only. +- Encryption configuration is read from `GitProvider.spec.encryption`. +- Supported provider value in this increment: `sops`. +- Encryption key/credential material is sourced from `spec.encryption.secretRef.name`. +- If encryption reference is absent or invalid, Secret writes fail safely (no plaintext fallback). + +## GitProvider API Direction + +- Keep existing: + - `spec.secretRef`: Git credentials for clone/fetch/push. +- Add Flux-aligned mirrored encryption section: + - `spec.encryption.provider`: required when `spec.encryption` is set; value `sops`. + - `spec.encryption.secretRef.name`: Secret containing SOPS key material or static cloud credentials. +- Resolution and scope: + - Referenced Secret is namespace-local to the `GitProvider`. + - Key material is runtime-only and must never be committed to git. +- Deferred: + - `spec.encryption.serviceAccountName` is intentionally not included yet. +- Validation/status intent: + - Surface clear status/condition errors when encryption reference is missing/invalid and Secret semantics are matched. + +## `.sops.yaml` Definition Strategy + +- For this increment, keep `.sops.yaml` source simple and static: + - ensure presence in repo root during write flow. + - do not attempt to store/transport full `.sops.yaml` content in `GitProvider.spec`. +- Rationale: + - keeps API small and avoids embedding large policy blobs in CRDs. + - lets users customize `.sops.yaml` directly in git after bootstrap. + - avoids introducing policy merge/validation semantics in this phase. +- Revisit later if needed: + - if teams need declarative policy management, add an explicit reference-based source (for example Secret/ConfigMap ref), not an inline multi-line spec blob. + +## Implementation Plan + +1. Add/keep a single ensure helper in the Git write pipeline that checks/creates root `.sops.yaml` for the branch being written. +2. Keep ensure logic "existence only" (no content reconciliation). +3. Add `GitProvider.spec.encryption` wiring with fields aligned to Flux naming (`provider`, `secretRef.name`) and pass resolved encryption config into write workers. +4. Move `isSecretResource` from `internal/git/content_writer.go` to `internal/types/identifier.go` and use it from call sites that need Secret semantics. +5. Add/update tests for: + - `.sops.yaml` created when missing during write. + - `.sops.yaml` untouched when present. + - `GitProvider` Git auth `spec.secretRef` and encryption `spec.encryption.secretRef.name` are independently resolved and validated. + - only `provider: sops` is accepted. + - Secret-semantics helper behavior remains correct after move. + +## References + +- `docs/design/sops-repo-bootstrap-out-of-scope.md` +- `internal/git/content_writer.go` +- `internal/types/identifier.go` diff --git a/docs/design/sops-repo-bootstrap-out-of-scope.md b/docs/design/sops-repo-bootstrap-out-of-scope.md new file mode 100644 index 00000000..1679241d --- /dev/null +++ b/docs/design/sops-repo-bootstrap-out-of-scope.md @@ -0,0 +1,17 @@ +# SOPS Repo Bootstrap: Out of Scope (Current Increment) + +This file tracks explicit non-goals and deferred items, so the main architecture plan stays small and execution-focused. + +## Explicitly Not Doing Now + +- Auto-generating AGE or other key material. +- Generic controller-level `.sops.yaml` override support. +- `.sops.yaml` content reconciliation (enforcing exact template match). +- Workload identity integration. +- Full key lifecycle orchestration (rotation, backup, recovery automation). +- Replacing external `sops` invocation with in-process encryption. + +## Notes + +- `.sops.yaml` remains user-adjustable once present in the repo. +- Future increments can re-introduce any item above with explicit API and operational design. diff --git a/go.mod b/go.mod index e2aecaa1..eb542056 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect + filippo.io/age v1.2.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect diff --git a/go.sum b/go.sum index 67b2050c..7421aec5 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= +filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 6fd27410..a99f613e 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -59,6 +59,8 @@ const ( ReasonSecretMalformed = "SecretMalformed" // ReasonConnectionFailed indicates that the connection to the provider failed. ReasonConnectionFailed = "ConnectionFailed" + // ReasonEncryptionConfigInvalid indicates encryption configuration is invalid. + ReasonEncryptionConfigInvalid = "EncryptionConfigInvalid" ) var ( diff --git a/internal/git/bootstrapped-repo-template/.sops.yaml b/internal/git/bootstrapped-repo-template/.sops.yaml new file mode 100644 index 00000000..f0c02e43 --- /dev/null +++ b/internal/git/bootstrapped-repo-template/.sops.yaml @@ -0,0 +1,8 @@ +# .sops.yaml +creation_rules: + # Encrypt only Kubernetes Secret payloads written as *.sops.yaml files. + - path_regex: '.*\.sops\.ya?ml$' + encrypted_regex: '^(data|stringData)$' + key_groups: + - age: + - "{{ .AgeRecipient }}" diff --git a/internal/git/bootstrapped-repo-template/README.md b/internal/git/bootstrapped-repo-template/README.md new file mode 100644 index 00000000..3da2012e --- /dev/null +++ b/internal/git/bootstrapped-repo-template/README.md @@ -0,0 +1,6 @@ +# GitOps Reverser Bootstrap Files + +This repository was initialized by GitOps Reverser. + +The `.sops.yaml` file is required for SOPS-based secret encryption. +If you remove or change these bootstrap files, secret mirroring may stop working. diff --git a/internal/git/bootstrapped_repo_template.go b/internal/git/bootstrapped_repo_template.go new file mode 100644 index 00000000..fecf1fd0 --- /dev/null +++ b/internal/git/bootstrapped_repo_template.go @@ -0,0 +1,233 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package git + +import ( + "bytes" + "context" + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "text/template" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" +) + +const ( + sopsConfigFileName = ".sops.yaml" + bootstrapTemplateDir = "bootstrapped-repo-template" + bootstrapCommitMessageRoot = "chore(bootstrap): initialize path" + bootstrapCommitAuthorName = "GitOps Reverser" + bootstrapCommitAuthorEmail = "noreply@configbutler.ai" + bootstrapTemplateFilePerm = 0600 +) + +//go:embed bootstrapped-repo-template/* bootstrapped-repo-template/.sops.yaml +var bootstrapTemplateFS embed.FS + +var errFoundFileInPath = errors.New("found file in path") + +type bootstrapTemplateData struct { + AgeRecipient string +} + +type pathBootstrapOptions struct { + TemplateData bootstrapTemplateData + IncludeSOPSConfig bool +} + +func commitPathBootstrapTemplateIfNeeded( + ctx context.Context, + repo *gogit.Repository, + branch plumbing.ReferenceName, + targetPath string, + options pathBootstrapOptions, + auth transport.AuthMethod, +) (plumbing.Hash, bool, error) { + worktree, err := repo.Worktree() + if err != nil { + return plumbing.ZeroHash, false, fmt.Errorf("failed to get worktree: %w", err) + } + + if err := stageBootstrapTemplateInPath(worktree, targetPath, options); err != nil { + return plumbing.ZeroHash, false, err + } + + status, err := worktree.Status() + if err != nil { + return plumbing.ZeroHash, false, fmt.Errorf("failed to get worktree status: %w", err) + } + if status.IsClean() { + return plumbing.ZeroHash, false, nil + } + + baseHash, err := TryReference(repo, branch) + if err != nil { + return plumbing.ZeroHash, false, fmt.Errorf("failed to resolve branch reference: %w", err) + } + + hash, err := worktree.Commit(bootstrapCommitMessage(targetPath), &gogit.CommitOptions{ + Author: &object.Signature{ + Name: bootstrapCommitAuthorName, + Email: bootstrapCommitAuthorEmail, + When: time.Now(), + }, + Committer: &object.Signature{ + Name: bootstrapCommitAuthorName, + Email: bootstrapCommitAuthorEmail, + When: time.Now(), + }, + }) + if err != nil { + return plumbing.ZeroHash, false, fmt.Errorf("failed to commit bootstrap files: %w", err) + } + + if err := PushAtomic(ctx, repo, baseHash, branch, auth); err != nil { + return plumbing.ZeroHash, false, fmt.Errorf("failed to push bootstrap commit: %w", err) + } + + return hash, true, nil +} + +func bootstrapCommitMessage(targetPath string) string { + if targetPath == "" { + return bootstrapCommitMessageRoot + " " + } + return fmt.Sprintf("%s %s", bootstrapCommitMessageRoot, targetPath) +} + +func pathHasAnyFile(repoPath, targetPath string) (bool, error) { + basePath := repoPath + if targetPath != "" { + basePath = filepath.Join(repoPath, targetPath) + } + + err := filepath.WalkDir(basePath, func(currentPath string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if filepath.Clean(currentPath) == filepath.Join(repoPath, ".git") { + return filepath.SkipDir + } + return nil + } + return errFoundFileInPath + }) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + if errors.Is(err, errFoundFileInPath) { + return true, nil + } + return false, err + } + + return false, nil +} + +func stageBootstrapTemplateInPath(worktree *gogit.Worktree, targetPath string, options pathBootstrapOptions) error { + entries, err := fs.ReadDir(bootstrapTemplateFS, bootstrapTemplateDir) + if err != nil { + return fmt.Errorf("failed to read bootstrap template directory: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + root := worktree.Filesystem.Root() + targetDir := root + if targetPath != "" { + targetDir = filepath.Join(root, targetPath) + if err := os.MkdirAll(targetDir, 0750); err != nil { + return fmt.Errorf("failed to create bootstrap target path %s: %w", targetPath, err) + } + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if entry.Name() == sopsConfigFileName && !options.IncludeSOPSConfig { + continue + } + + templatePath := path.Join(bootstrapTemplateDir, entry.Name()) + content, err := bootstrapTemplateFS.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("failed to read bootstrap template %s: %w", entry.Name(), err) + } + if entry.Name() == sopsConfigFileName { + content, err = renderSOPSBootstrapTemplate(content, options.TemplateData) + if err != nil { + return err + } + } + + destinationPath := filepath.Join(targetDir, entry.Name()) + if _, statErr := os.Stat(destinationPath); statErr != nil { + if !os.IsNotExist(statErr) { + return fmt.Errorf("failed to stat bootstrap target %s: %w", entry.Name(), statErr) + } + if err := os.WriteFile(destinationPath, content, bootstrapTemplateFilePerm); err != nil { + return fmt.Errorf("failed to write bootstrap file %s: %w", entry.Name(), err) + } + } + + gitPath := entry.Name() + if targetPath != "" { + gitPath = filepath.ToSlash(filepath.Join(targetPath, entry.Name())) + } + if _, err := worktree.Add(gitPath); err != nil { + return fmt.Errorf("failed to stage bootstrap file %s: %w", entry.Name(), err) + } + } + + return nil +} + +func renderSOPSBootstrapTemplate(raw []byte, data bootstrapTemplateData) ([]byte, error) { + if strings.TrimSpace(data.AgeRecipient) == "" { + return nil, fmt.Errorf("failed to render bootstrap file %s: missing age recipient", sopsConfigFileName) + } + + tmpl, err := template.New(sopsConfigFileName).Parse(string(raw)) + if err != nil { + return nil, fmt.Errorf("failed to parse bootstrap template %s: %w", sopsConfigFileName, err) + } + + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, data); err != nil { + return nil, fmt.Errorf("failed to render bootstrap template %s: %w", sopsConfigFileName, err) + } + + return rendered.Bytes(), nil +} diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 8f9f12ea..3a783377 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -28,6 +28,8 @@ import ( "sync" "time" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -116,13 +118,6 @@ func (w *BranchWorker) Start(parentCtx context.Context) error { w.Log.Info("Starting branch worker") - // Initialize repository and metadata in background - go func() { - if err := w.ensureRepositoryInitialized(w.ctx); err != nil { - w.Log.Error(err, "Failed to initialize repository") - } - }() - w.wg.Add(1) go func() { defer w.wg.Done() @@ -176,6 +171,103 @@ func (w *BranchWorker) ListResourcesInPath(path string) ([]itypes.ResourceIdenti return w.listResourceIdentifiersInPath(repoPath, path) } +// EnsurePathBootstrapped applies bootstrap template to a path when that path has no files. +func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace string) error { + ctx := w.ctx + if ctx == nil { + ctx = context.Background() + } + + normalizedPath := sanitizePath(path) + if strings.TrimSpace(path) != "" && normalizedPath == "" { + return fmt.Errorf("invalid path %q", path) + } + if strings.TrimSpace(targetName) == "" || strings.TrimSpace(targetNamespace) == "" { + return fmt.Errorf("target name and namespace must be set for path bootstrap") + } + + provider, err := w.getGitProvider(ctx) + if err != nil { + return fmt.Errorf("failed to get GitProvider: %w", err) + } + + auth, err := getAuthFromSecret(ctx, w.Client, provider) + if err != nil { + return fmt.Errorf("failed to get auth: %w", err) + } + + targetKey := types.NamespacedName{Name: targetName, Namespace: targetNamespace} + var target configv1alpha1.GitTarget + if err := w.Client.Get(ctx, targetKey, &target); err != nil { + return fmt.Errorf("failed to get GitTarget %s: %w", targetKey.String(), err) + } + encryptionConfig, err := ResolveTargetEncryption(ctx, w.Client, &target) + if err != nil { + return fmt.Errorf("failed to resolve target encryption: %w", err) + } + + bootstrapOptions := pathBootstrapOptions{} + if encryptionConfig != nil { + sopsKey := strings.TrimSpace(encryptionConfig.Environment[sopsAgeKeyEnvVar]) + if sopsKey == "" { + return fmt.Errorf("missing %s in target encryption secret", sopsAgeKeyEnvVar) + } + recipient, deriveErr := deriveAgeRecipientFromSOPSKey(sopsKey) + if deriveErr != nil { + return fmt.Errorf("failed to derive age recipient: %w", deriveErr) + } + bootstrapOptions.IncludeSOPSConfig = true + bootstrapOptions.TemplateData.AgeRecipient = recipient + } + + repoPath := filepath.Join("/tmp", "gitops-reverser-workers", + w.GitProviderNamespace, w.GitProviderRef, w.Branch) + + pullReport, err := PrepareBranch(ctx, provider.Spec.URL, repoPath, w.Branch, auth) + if err != nil { + return fmt.Errorf("failed to prepare repository: %w", err) + } + w.updateBranchMetadataFromPullReport(pullReport) + + hasFiles, err := pathHasAnyFile(repoPath, normalizedPath) + if err != nil { + return fmt.Errorf("failed to check path contents: %w", err) + } + if hasFiles { + w.Log.V(1).Info("Skipping bootstrap for non-empty path", "path", normalizedPath) + return nil + } + + repo, err := gogit.PlainOpen(repoPath) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + hash, committed, err := commitPathBootstrapTemplateIfNeeded( + ctx, + repo, + plumbing.NewBranchReferenceName(w.Branch), + normalizedPath, + bootstrapOptions, + auth, + ) + if err != nil { + return fmt.Errorf("failed to bootstrap path %q: %w", normalizedPath, err) + } + if !committed { + return nil + } + + w.metaMu.Lock() + w.branchExists = true + w.lastCommitSHA = printSha(hash) + w.lastFetchTime = time.Now() + w.metaMu.Unlock() + + w.Log.Info("Bootstrapped path", "path", normalizedPath, "commit", printSha(hash)) + return nil +} + // listResourceIdentifiersInPath lists resource identifiers in a specific path. func (w *BranchWorker) listResourceIdentifiersInPath( repoPath, path string, @@ -289,10 +381,45 @@ func (w *BranchWorker) commitAndPush( repoPath := filepath.Join("/tmp", "gitops-reverser-workers", w.GitProviderNamespace, w.GitProviderRef, w.Branch) - // Use new WriteEvents abstraction - result, err := WriteEventsWithContentWriter(w.ctx, w.contentWriter, repoPath, events, w.Branch, auth) - if err != nil { - log.Error(err, "Failed to write events") + var result *WriteEventsResult + for _, event := range events { + eventLog := log.WithValues( + "targetNamespace", event.GitTargetNamespace, + "targetName", event.GitTargetName, + ) + + var encryptionConfig *ResolvedEncryptionConfig + if event.GitTargetName != "" && event.GitTargetNamespace != "" { + var target configv1alpha1.GitTarget + targetKey := types.NamespacedName{ + Name: event.GitTargetName, + Namespace: event.GitTargetNamespace, + } + if err := w.Client.Get(w.ctx, targetKey, &target); err != nil { + eventLog.Error(err, "Failed to resolve GitTarget for encryption") + continue + } + + encryptionConfig, err = ResolveTargetEncryption(w.ctx, w.Client, &target) + if err != nil { + eventLog.Error(err, "Failed to resolve target encryption configuration") + continue + } + } + + encryptionWorkDir := filepath.Join(repoPath, event.Path) + if err := configureSecretEncryptionWriter(w.contentWriter, encryptionWorkDir, encryptionConfig); err != nil { + eventLog.Error(err, "Failed to configure secret encryptor") + continue + } + + result, err = WriteEventsWithContentWriter(w.ctx, w.contentWriter, repoPath, []Event{event}, w.Branch, auth) + if err != nil { + eventLog.Error(err, "Failed to write event") + continue + } + } + if result == nil { return } diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 9ee3f8cc..c77a8723 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -20,15 +20,21 @@ package git import ( "context" + "os" "path/filepath" "testing" + "filippo.io/age" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" @@ -187,10 +193,10 @@ func TestBranchWorker_EmptyRepository(t *testing.T) { err = worker.ensureRepositoryInitialized(ctx) require.NoError(t, err, "ensureRepositoryInitialized should succeed with empty repository") - // Test GetBranchMetadata - should return branchExists=false for empty repo + // Test GetBranchMetadata - branch should still be unborn until first write/bootstrap exists, sha, fetchTime := worker.GetBranchMetadata() - assert.False(t, exists, "Branch should not exist in empty repository") - assert.Empty(t, sha, "SHA should be empty for empty repository") + assert.False(t, exists, "Branch should not exist remotely for empty repository") + assert.Empty(t, sha, "SHA should be empty while branch is unborn") assert.False(t, fetchTime.IsZero(), "Fetch time should be set") // Test ListResourcesInPath - should work with empty repo @@ -200,9 +206,9 @@ func TestBranchWorker_EmptyRepository(t *testing.T) { // Verify metadata was updated after ListResourcesInPath exists2, sha2, fetchTime2 := worker.GetBranchMetadata() - assert.False(t, exists2, "Branch should still not exist after listing") - assert.Empty(t, sha2, "SHA should still be empty after listing") - assert.True(t, fetchTime2.After(fetchTime), "Fetch time should be updated after listing") + assert.False(t, exists2, "Branch should remain unborn after listing") + assert.Empty(t, sha2, "SHA should remain empty after listing") + assert.False(t, fetchTime2.Before(fetchTime), "Fetch time should not move backwards") } // TestBranchWorker_IdentityFields verifies worker identity is set correctly. @@ -225,3 +231,181 @@ func TestBranchWorker_IdentityFields(t *testing.T) { t.Errorf("Expected Branch 'develop', got %q", worker.Branch) } } + +func TestBranchWorker_EnsurePathBootstrapped_EmptyPathCreatesTemplate(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + serverRepo := createBareRepo(t, remotePath) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + createTargetWithEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) + + ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash()), "Bootstrap should only commit once for same path") + + clonePath := filepath.Join(tempDir, "inspect") + _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + require.NoError(t, err) +} + +func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathSkipsTemplate(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + serverRepo := createBareRepo(t, remotePath) + seedPath := filepath.Join(tempDir, "seed") + seedRepo, seedWorktree := initLocalRepo(t, seedPath, remoteURL, "main") + require.NoError(t, os.MkdirAll(filepath.Join(seedPath, "clusters/prod"), 0750)) + commitFileChange(t, seedWorktree, seedPath, "clusters/prod/existing.txt", "already populated") + require.NoError(t, seedRepo.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("refs/heads/main:refs/heads/main"), + }, + })) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + createTargetWithEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) + + ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash()), "Existing path content should skip bootstrap commit") + + clonePath := filepath.Join(tempDir, "inspect") + _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "README.md")) + assert.True(t, os.IsNotExist(err), "Bootstrap README should not be added to non-empty path") + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should not be added to non-empty path") +} + +func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + _ = createBareRepo(t, remotePath) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + createTargetWithoutEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/dev") + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) + + clonePath := filepath.Join(tempDir, "inspect") + _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped when encryption is not configured") +} + +func createTargetWithEncryption( + t *testing.T, + ctx context.Context, + k8sClient client.Client, + name, namespace, providerName, branch, path string, +) { + t.Helper() + identity, err := age.GenerateX25519Identity() + require.NoError(t, err) + + encryptionSecret := &corev1.Secret{} + encryptionSecret.Name = "sops-age-key" + encryptionSecret.Namespace = namespace + encryptionSecret.Data = map[string][]byte{ + sopsAgeKeyEnvVar: []byte(identity.String()), + } + require.NoError(t, k8sClient.Create(ctx, encryptionSecret)) + + target := &configv1alpha1.GitTarget{} + target.Name = name + target.Namespace = namespace + target.Spec.ProviderRef = configv1alpha1.GitProviderReference{ + Kind: "GitProvider", + Name: providerName, + } + target.Spec.Branch = branch + target.Spec.Path = path + target.Spec.Encryption = &configv1alpha1.EncryptionSpec{ + Provider: "sops", + SecretRef: configv1alpha1.LocalSecretReference{ + Name: "sops-age-key", + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) +} + +func createTargetWithoutEncryption( + t *testing.T, + ctx context.Context, + k8sClient client.Client, + name, namespace, providerName, branch, path string, +) { + t.Helper() + target := &configv1alpha1.GitTarget{} + target.Name = name + target.Namespace = namespace + target.Spec.ProviderRef = configv1alpha1.GitProviderReference{ + Kind: "GitProvider", + Name: providerName, + } + target.Spec.Branch = branch + target.Spec.Path = path + require.NoError(t, k8sClient.Create(ctx, target)) +} diff --git a/internal/git/content_writer.go b/internal/git/content_writer.go index be624a0c..8882e6ef 100644 --- a/internal/git/content_writer.go +++ b/internal/git/content_writer.go @@ -76,7 +76,7 @@ func (w *contentWriter) buildContentForWrite(ctx context.Context, event Event) ( return nil, fmt.Errorf("failed to marshal object to YAML: %w", err) } - if !isSecretResource(event.Identifier) { + if !types.IsSecretResource(event.Identifier) { return content, nil } @@ -166,7 +166,3 @@ func buildResourceMeta(event Event) resourceMeta { func secretIdentityKey(id types.ResourceIdentifier) string { return fmt.Sprintf("%s/%s/%s/%s/%s", id.Group, id.Version, id.Resource, id.Namespace, id.Name) } - -func isSecretResource(id types.ResourceIdentifier) bool { - return id.Group == "" && id.Version == "v1" && id.Resource == "secrets" -} diff --git a/internal/git/content_writer_test.go b/internal/git/content_writer_test.go index 5f14a94f..c627704d 100644 --- a/internal/git/content_writer_test.go +++ b/internal/git/content_writer_test.go @@ -249,21 +249,3 @@ func TestBuildContentForWrite_SecretEncryptionFailure(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "secret encryption failed") } - -func TestIsSecretResource(t *testing.T) { - assert.True(t, isSecretResource(types.ResourceIdentifier{ - Group: "", - Version: "v1", - Resource: "secrets", - })) - assert.False(t, isSecretResource(types.ResourceIdentifier{ - Group: "", - Version: "v1", - Resource: "configmaps", - })) - assert.False(t, isSecretResource(types.ResourceIdentifier{ - Group: "example.com", - Version: "v1", - Resource: "secrets", - })) -} diff --git a/internal/git/encryption.go b/internal/git/encryption.go index 73c406ca..60c9b711 100644 --- a/internal/git/encryption.go +++ b/internal/git/encryption.go @@ -21,16 +21,21 @@ package git import ( "context" "fmt" - "os" - "path/filepath" + "regexp" "strings" - "github.com/ConfigButler/gitops-reverser/internal/types" + "filippo.io/age" + corev1 "k8s.io/api/core/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/ConfigButler/gitops-reverser/api/v1alpha1" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) // ResourceMeta is passed to encryptors for context and diagnostics. type ResourceMeta struct { - Identifier types.ResourceIdentifier + Identifier itypes.ResourceIdentifier UID string ResourceVersion string Generation int64 @@ -41,53 +46,139 @@ type Encryptor interface { Encrypt(ctx context.Context, plain []byte, meta ResourceMeta) ([]byte, error) } -// EncryptionConfig controls runtime encryption behavior for Secret writes. -type EncryptionConfig struct { - SOPSBinaryPath string - SOPSConfigPath string +const ( + // EncryptionProviderSOPS is the only supported provider in this increment. + EncryptionProviderSOPS = "sops" + // defaultSOPSBinaryPath is resolved from PATH in the controller runtime image. + defaultSOPSBinaryPath = "sops" + // sopsAgeKeyEnvVar is the required secret key for age-based SOPS encryption. + sopsAgeKeyEnvVar = "SOPS_AGE_KEY" +) + +var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// ResolvedEncryptionConfig contains runtime encryption settings resolved from GitTarget spec. +type ResolvedEncryptionConfig struct { + Provider string + Environment map[string]string +} + +// ResolveTargetEncryption resolves and validates GitTarget encryption configuration. +func ResolveTargetEncryption( + ctx context.Context, + k8sClient client.Client, + target *v1alpha1.GitTarget, +) (*ResolvedEncryptionConfig, error) { + if target.Spec.Encryption == nil { + return nil, nil //nolint:nilnil // nil means encryption disabled + } + + encryptionSpec := target.Spec.Encryption + providerName := strings.TrimSpace(encryptionSpec.Provider) + if providerName == "" { + providerName = EncryptionProviderSOPS + } + if providerName != EncryptionProviderSOPS { + return nil, fmt.Errorf("unsupported encryption provider %q", encryptionSpec.Provider) + } + + secretKind := strings.TrimSpace(encryptionSpec.SecretRef.Kind) + if secretKind != "" && secretKind != "Secret" { + return nil, fmt.Errorf("encryption.secretRef.kind must be Secret, got %q", encryptionSpec.SecretRef.Kind) + } + + secretName := strings.TrimSpace(encryptionSpec.SecretRef.Name) + if secretName == "" { + return nil, fmt.Errorf("encryption.secretRef.name must be set for provider %q", providerName) + } + + var secret corev1.Secret + secretKey := k8stypes.NamespacedName{ + Name: secretName, + Namespace: target.Namespace, + } + if err := k8sClient.Get(ctx, secretKey, &secret); err != nil { + return nil, fmt.Errorf("failed to fetch encryption secret %s: %w", secretKey, err) + } + + environment := toSOPSEnvironment(secret.Data) + if len(environment) == 0 { + return nil, fmt.Errorf( + "encryption secret %s must contain at least one valid environment variable entry", + secretKey, + ) + } + + return &ResolvedEncryptionConfig{ + Provider: providerName, + Environment: environment, + }, nil } -func configureSecretEncryptionWriter(writer *contentWriter, cfg EncryptionConfig) error { - if strings.TrimSpace(cfg.SOPSBinaryPath) == "" { +func configureSecretEncryptionWriter( + writer *contentWriter, + workDir string, + cfg *ResolvedEncryptionConfig, +) error { + if cfg == nil { writer.setEncryptor(nil) return nil } - if err := cfg.Validate(); err != nil { - return err + switch cfg.Provider { + case EncryptionProviderSOPS: + writer.setEncryptor(NewSOPSEncryptorWithEnv(defaultSOPSBinaryPath, "", workDir, cfg.Environment)) + return nil + default: + return fmt.Errorf("unsupported encryption provider %q", cfg.Provider) } - - writer.setEncryptor(NewSOPSEncryptor(cfg.SOPSBinaryPath, cfg.SOPSConfigPath)) - return nil } -// Validate verifies encryption config values. Empty SOPSBinaryPath is allowed -// and means encryption is not configured (Secret writes will be rejected). -func (c EncryptionConfig) Validate() error { - binPath := strings.TrimSpace(c.SOPSBinaryPath) - if binPath != "" { - info, err := os.Stat(binPath) - if err != nil { - return fmt.Errorf("invalid sops-binary-path %q: %w", binPath, err) - } - if info.IsDir() { - return fmt.Errorf("invalid sops-binary-path %q: path is a directory", binPath) - } +func toSOPSEnvironment(secretData map[string][]byte) map[string]string { + if len(secretData) == 0 { + return nil } - configPath := strings.TrimSpace(c.SOPSConfigPath) - if configPath != "" { - info, err := os.Stat(configPath) - if err != nil { - return fmt.Errorf("invalid sops-config-path %q: %w", configPath, err) + environment := make(map[string]string, len(secretData)) + for key, value := range secretData { + if !envVarNamePattern.MatchString(key) { + continue } - if info.IsDir() { - return fmt.Errorf("invalid sops-config-path %q: path is a directory", configPath) - } - if !filepath.IsAbs(configPath) { - return fmt.Errorf("invalid sops-config-path %q: must be an absolute path", configPath) + environment[key] = string(value) + } + + if len(environment) == 0 { + return nil + } + + return environment +} + +func deriveAgeRecipientFromSOPSKey(secretValue string) (string, error) { + lines := strings.Split(secretValue, "\n") + identities := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue } + identities = append(identities, trimmed) + } + + if len(identities) == 0 { + return "", fmt.Errorf("%s must contain one AGE-SECRET-KEY identity", sopsAgeKeyEnvVar) + } + if len(identities) > 1 { + return "", fmt.Errorf("%s must contain exactly one AGE-SECRET-KEY identity", sopsAgeKeyEnvVar) + } + if !strings.HasPrefix(identities[0], "AGE-SECRET-KEY-") { + return "", fmt.Errorf("%s must contain AGE-SECRET-KEY identity", sopsAgeKeyEnvVar) + } + + identity, err := age.ParseX25519Identity(identities[0]) + if err != nil { + return "", fmt.Errorf("invalid %s identity: %w", sopsAgeKeyEnvVar, err) } - return nil + return identity.Recipient().String(), nil } diff --git a/internal/git/encryption_test.go b/internal/git/encryption_test.go index 26740edf..b8c242b6 100644 --- a/internal/git/encryption_test.go +++ b/internal/git/encryption_test.go @@ -19,51 +19,199 @@ limitations under the License. package git import ( - "os" - "path/filepath" + "context" + "strings" "testing" + "filippo.io/age" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/ConfigButler/gitops-reverser/api/v1alpha1" ) -func TestEncryptionConfigValidate(t *testing.T) { - t.Run("empty config is allowed", func(t *testing.T) { - cfg := EncryptionConfig{} - require.NoError(t, cfg.Validate()) +func TestResolveTargetEncryption(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + + t.Run("returns nil when encryption is not configured", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + } + + resolved, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.NoError(t, err) + assert.Nil(t, resolved) }) - t.Run("invalid binary path fails", func(t *testing.T) { - cfg := EncryptionConfig{SOPSBinaryPath: "/does/not/exist/sops"} - require.Error(t, cfg.Validate()) + t.Run("fails when provider is unsupported", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + Provider: "kms", + SecretRef: v1alpha1.LocalSecretReference{ + Name: "enc-secret", + }, + }, + }, + } + + _, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported encryption provider") }) - t.Run("relative config path fails", func(t *testing.T) { - f := filepath.Join(t.TempDir(), "sops") - require.NoError(t, os.WriteFile(f, []byte("binary"), 0700)) - cfg := EncryptionConfig{ - SOPSBinaryPath: f, - SOPSConfigPath: "relative/path", + t.Run("fails when secret name is missing", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + Provider: EncryptionProviderSOPS, + SecretRef: v1alpha1.LocalSecretReference{}, + }, + }, } - require.Error(t, cfg.Validate()) + + _, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "encryption.secretRef.name must be set") }) - t.Run("absolute paths pass", func(t *testing.T) { - dir := t.TempDir() - bin := filepath.Join(dir, "sops") - cfgPath := filepath.Join(dir, ".sops.yaml") - require.NoError(t, os.WriteFile(bin, []byte("binary"), 0700)) - require.NoError(t, os.WriteFile(cfgPath, []byte("creation_rules: []"), 0600)) - cfg := EncryptionConfig{ - SOPSBinaryPath: bin, - SOPSConfigPath: cfgPath, + t.Run("fails when secret is missing", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + Provider: EncryptionProviderSOPS, + SecretRef: v1alpha1.LocalSecretReference{ + Name: "enc-secret", + }, + }, + }, } - require.NoError(t, cfg.Validate()) + + _, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch encryption secret") + }) + + t.Run("fails when secret has no valid environment keys", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "enc-secret", Namespace: "default"}, + Data: map[string][]byte{ + "invalid.key": []byte("value"), + }, + }).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + Provider: EncryptionProviderSOPS, + SecretRef: v1alpha1.LocalSecretReference{ + Name: "enc-secret", + }, + }, + }, + } + + _, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "must contain at least one valid environment variable entry") + }) + + t.Run("returns resolved environment for valid sops config", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "enc-secret", Namespace: "default"}, + Data: map[string][]byte{ + "SOPS_AGE_KEY": []byte("AGE-SECRET-KEY-1example"), + }, + }).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + Provider: EncryptionProviderSOPS, + SecretRef: v1alpha1.LocalSecretReference{ + Name: "enc-secret", + }, + }, + }, + } + + resolved, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, EncryptionProviderSOPS, resolved.Provider) + assert.Equal(t, "AGE-SECRET-KEY-1example", resolved.Environment["SOPS_AGE_KEY"]) + }) + + t.Run("defaults provider to sops when omitted", func(t *testing.T) { + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "enc-secret", Namespace: "default"}, + Data: map[string][]byte{ + "SOPS_AGE_KEY": []byte("AGE-SECRET-KEY-1example"), + }, + }).Build() + target := &v1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, + Spec: v1alpha1.GitTargetSpec{ + Encryption: &v1alpha1.EncryptionSpec{ + SecretRef: v1alpha1.LocalSecretReference{ + Name: "enc-secret", + }, + }, + }, + } + + resolved, err := ResolveTargetEncryption(context.Background(), k8sClient, target) + require.NoError(t, err) + require.NotNil(t, resolved) + assert.Equal(t, EncryptionProviderSOPS, resolved.Provider) }) } -func TestConfigureSecretEncryptionWriter(t *testing.T) { - writer := newContentWriter() - require.NoError(t, configureSecretEncryptionWriter(writer, EncryptionConfig{})) - assert.Nil(t, writer.encryptor) +func TestDeriveAgeRecipientFromSOPSKey(t *testing.T) { + t.Run("returns recipient for single valid identity", func(t *testing.T) { + identity, err := age.GenerateX25519Identity() + require.NoError(t, err) + + recipient, err := deriveAgeRecipientFromSOPSKey(identity.String()) + require.NoError(t, err) + assert.Equal(t, identity.Recipient().String(), recipient) + }) + + t.Run("fails when identity is missing", func(t *testing.T) { + _, err := deriveAgeRecipientFromSOPSKey("") + require.Error(t, err) + assert.Contains(t, err.Error(), "must contain one AGE-SECRET-KEY identity") + }) + + t.Run("fails when identity is invalid", func(t *testing.T) { + _, err := deriveAgeRecipientFromSOPSKey("AGE-SECRET-KEY-1invalid") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid SOPS_AGE_KEY identity") + }) + + t.Run("fails when multiple identities are present", func(t *testing.T) { + first, err := age.GenerateX25519Identity() + require.NoError(t, err) + second, err := age.GenerateX25519Identity() + require.NoError(t, err) + + combined := strings.Join([]string{first.String(), second.String()}, "\n") + _, err = deriveAgeRecipientFromSOPSKey(combined) + require.Error(t, err) + assert.Contains(t, err.Error(), "must contain exactly one AGE-SECRET-KEY identity") + }) } diff --git a/internal/git/git.go b/internal/git/git.go index ae38d64c..a99479d7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -155,7 +155,12 @@ func PrepareBranch( } targetBranch := plumbing.NewBranchReferenceName(targetBranchName) - return syncToRemote(ctx, repo, targetBranch, auth) + pullReport, err := syncToRemote(ctx, repo, targetBranch, auth) + if err != nil { + return nil, err + } + + return pullReport, nil } // TryReference will resolve in all likely scenarios, if it's there and if it's not. In that case you get plumbing.ZeroHash and no error. @@ -801,7 +806,7 @@ func handleCreateOrUpdateOperation( ) (bool, error) { content, err := writer.buildContentForWrite(ctx, event) if err != nil { - if isSecretResource(event.Identifier) { + if types.IsSecretResource(event.Identifier) { log.FromContext(ctx).Info( "Secret write skipped because encryption failed", "resource", event.Identifier.String(), @@ -840,7 +845,7 @@ func handleCreateOrUpdateOperation( func generateFilePath(id types.ResourceIdentifier) string { defaultPath := id.ToGitPath() - if !isSecretResource(id) { + if !types.IsSecretResource(id) { return defaultPath } if strings.HasSuffix(defaultPath, ".yaml") { diff --git a/internal/git/git_operations_test.go b/internal/git/git_operations_test.go index 0de1ea4a..8ae001e7 100644 --- a/internal/git/git_operations_test.go +++ b/internal/git/git_operations_test.go @@ -178,11 +178,9 @@ func TestCheckRepo_PublicConnectivityEmpty(t *testing.T) { localPath := filepath.Join(tempDir, "local") pullReport, err := PrepareBranch(context.Background(), remoteURL, localPath, "cool-test", nil) require.NoError(t, err) - - require.False(t, pullReport.ExistsOnRemote) - require.True(t, pullReport.HEAD.Unborn) - require.Empty(t, pullReport.HEAD.Sha) - require.Equal(t, "cool-test", pullReport.HEAD.ShortName) + require.NotNil(t, pullReport) + assert.False(t, pullReport.ExistsOnRemote) + assert.True(t, pullReport.HEAD.Unborn) // Verify repository was cloned localRepo, err := git.PlainOpen(localPath) @@ -335,6 +333,7 @@ func TestWriteEvents_FirstCommitOnEmptyRepo(t *testing.T) { require.NoError(t, err) require.False(t, pullReport.ExistsOnRemote) require.True(t, pullReport.HEAD.Unborn) + require.Empty(t, pullReport.HEAD.Sha) // Create test event event := createTestEvent(t, "test-pod") @@ -754,15 +753,14 @@ func TestPullBranch_DanglingHead_NewOrphan(t *testing.T) { targetBranch := "new-feature" // PrepareBranch logic: - // SmartFetch sees HEAD is broken. It sees target is missing. Returns "". - // syncToRemote sees "", calls makeHeadUnborn(). + // SmartFetch sees HEAD is broken. It sees target is missing and leaves HEAD unborn. pullReport, err := PrepareBranch(context.Background(), remoteURL, localPath, targetBranch, nil) require.NoError(t, err) // Verify Report - assert.False(t, pullReport.ExistsOnRemote, "Branch should not exist on remote") - assert.True(t, pullReport.HEAD.Unborn, "Should be in Unborn/Orphan state because no valid base was found") - assert.False(t, pullReport.IncomingChanges, "No incoming changes possible on an empty base") + assert.False(t, pullReport.ExistsOnRemote, "PrepareBranch should not bootstrap or create remote branch") + assert.True(t, pullReport.HEAD.Unborn, "Target branch should stay unborn until first write/bootstrap") + assert.Empty(t, pullReport.HEAD.Sha) // 4. Write Events event := createTestEvent(t, "orphan-resource") @@ -771,7 +769,7 @@ func TestPullBranch_DanglingHead_NewOrphan(t *testing.T) { assert.Equal(t, 1, writeEventsResult.CommitsCreated) // 5. Verify Topology on Server - // The new commit should be a ROOT commit (0 parents) because we couldn't find 'main' to branch off. + // The event commit should be the root commit in the new branch. // Fetch the commit from the server serverRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName(targetBranch), true) @@ -781,12 +779,7 @@ func TestPullBranch_DanglingHead_NewOrphan(t *testing.T) { require.NoError(t, err) // The Critical Assertion: - assert.Equal( - t, - 0, - commitObj.NumParents(), - "This should be a root commit (orphan) because the default branch was missing", - ) + assert.Equal(t, 0, commitObj.NumParents(), "First event on unborn branch should be a root commit") assert.Contains(t, commitObj.Message, "orphan-resource") assert.Contains(t, commitObj.Message, "[CREATE]") } @@ -887,11 +880,11 @@ func TestPullBranch_WhipedRepo(t *testing.T) { assert.True(t, pullReport.ExistsOnRemote) assert.False(t, pullReport.IncomingChanges) - // Now execute the same with the empty remote, effectively this is just somebody that deleted our stuff. Since we also won't have main, we expect an unborn branch + // Now execute the same with the empty remote: since no base is available, PrepareBranch keeps target branch unborn. pullReport, err = PrepareBranch(context.Background(), remoteURLEmpty, localPath, "feature", nil) require.NoError(t, err) assert.False(t, pullReport.ExistsOnRemote) - assert.True(t, pullReport.IncomingChanges) // This is big: we do expect this certainly to be true! + assert.True(t, pullReport.IncomingChanges) assert.True(t, pullReport.HEAD.Unborn) assert.Empty(t, pullReport.HEAD.Sha) assert.Equal(t, "feature", pullReport.HEAD.ShortName) @@ -905,16 +898,11 @@ func TestPullBranch_WhipedRepo(t *testing.T) { require.NoError(t, err) assert.True(t, status.IsClean(), "Working copy should be clean after switching to empty remote") - // Verify no tracked files remain (except .git directory) - entries, err := os.ReadDir(localPath) - require.NoError(t, err) - var nonGitFiles []string - for _, entry := range entries { - if entry.Name() != ".git" { - nonGitFiles = append(nonGitFiles, entry.Name()) - } - } - assert.Empty(t, nonGitFiles, "No files should exist in working copy except .git directory") + // No bootstrap files should be auto-created by PrepareBranch anymore. + _, err = os.Stat(filepath.Join(localPath, "README.md")) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(localPath, sopsConfigFileName)) + assert.True(t, os.IsNotExist(err)) } // Benchmark for prepareBranch shallow clone performance. diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go index 0353cfb2..4256cd90 100644 --- a/internal/git/secret_write_test.go +++ b/internal/git/secret_write_test.go @@ -26,6 +26,7 @@ import ( "time" gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -161,3 +162,69 @@ func TestWriteEvents_DeleteSecretRemovesSOPSPath(t *testing.T) { _, statErr = os.Stat(sopsPath) assert.ErrorIs(t, statErr, os.ErrNotExist) } + +func TestWriteEvents_DoesNotBootstrapRootSOPSConfig(t *testing.T) { + repoPath := t.TempDir() + _, err := gogit.PlainInit(repoPath, false) + require.NoError(t, err) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "sample-config", + "namespace": "default", + }, + "data": map[string]interface{}{ + "key": "value", + }, + }, + }, + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "default", + Name: "sample-config", + }, + Operation: "CREATE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + } + + _, err = WriteEvents(context.Background(), repoPath, []Event{event}, "master", nil) + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(repoPath, sopsConfigFileName)) + assert.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestWriteEvents_DoesNotCreateBootstrapOnlyCommit(t *testing.T) { + repoPath := t.TempDir() + repo, err := gogit.PlainInit(repoPath, false) + require.NoError(t, err) + + event := Event{ + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "default", + Name: "does-not-exist", + }, + Operation: "DELETE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + } + + result, err := WriteEvents(context.Background(), repoPath, []Event{event}, "master", nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.CommitsCreated) + + _, err = repo.Reference(plumbing.NewBranchReferenceName("master"), true) + assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound) +} diff --git a/internal/git/sops_encryptor.go b/internal/git/sops_encryptor.go index bffaa08d..20c3d9a4 100644 --- a/internal/git/sops_encryptor.go +++ b/internal/git/sops_encryptor.go @@ -22,7 +22,9 @@ import ( "bytes" "context" "fmt" + "os" "os/exec" + "slices" "strings" ) @@ -30,22 +32,36 @@ import ( type SOPSEncryptor struct { binaryPath string configPath string + workDir string + env map[string]string } // NewSOPSEncryptor creates an Encryptor that shells out to sops. func NewSOPSEncryptor(binaryPath, configPath string) *SOPSEncryptor { + return NewSOPSEncryptorWithEnv(binaryPath, configPath, "", nil) +} + +// NewSOPSEncryptorWithEnv creates an Encryptor that shells out to sops with additional environment variables. +func NewSOPSEncryptorWithEnv(binaryPath, configPath, workDir string, env map[string]string) *SOPSEncryptor { + copiedEnv := make(map[string]string, len(env)) + for key, value := range env { + copiedEnv[key] = value + } return &SOPSEncryptor{ binaryPath: binaryPath, configPath: configPath, + workDir: workDir, + env: copiedEnv, } } // Encrypt streams plaintext YAML to sops over stdin and returns encrypted YAML bytes. -func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMeta) ([]byte, error) { +func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, meta ResourceMeta) ([]byte, error) { args := []string{ "--encrypt", "--input-type", "yaml", "--output-type", "yaml", + "--filename-override", sopsFilenameOverride(meta), "/dev/stdin", } if strings.TrimSpace(e.configPath) != "" { @@ -53,6 +69,10 @@ func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMet } cmd := newCommandContext(ctx, e.binaryPath, args...) + if strings.TrimSpace(e.workDir) != "" { + cmd.Dir = e.workDir + } + cmd.Env = buildCommandEnvironment(e.env) cmd.Stdin = bytes.NewReader(plain) out, err := cmd.CombinedOutput() if err != nil { @@ -65,3 +85,29 @@ func (e *SOPSEncryptor) Encrypt(ctx context.Context, plain []byte, _ ResourceMet func newCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.CommandContext(ctx, name, args...) } + +func buildCommandEnvironment(extra map[string]string) []string { + environment := slices.Clone(os.Environ()) + if len(extra) == 0 { + return environment + } + + for key, value := range extra { + environment = append(environment, fmt.Sprintf("%s=%s", key, value)) + } + + return environment +} + +func sopsFilenameOverride(meta ResourceMeta) string { + if meta.Identifier.Name == "" { + return "resource.sops.yaml" + } + + path := meta.Identifier.ToGitPath() + if strings.HasSuffix(path, ".yaml") { + return strings.TrimSuffix(path, ".yaml") + ".sops.yaml" + } + + return path + ".sops.yaml" +} diff --git a/internal/git/types.go b/internal/git/types.go index 985f3418..a490022d 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -105,4 +105,10 @@ type Event struct { // This comes from the GitTarget that triggered this event. // Empty string means write to repository root. Path string + + // GitTargetName is the target owning this event. + GitTargetName string + + // GitTargetNamespace is the namespace of the target owning this event. + GitTargetNamespace string } diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index 42eda663..5b2b98d0 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -36,35 +36,26 @@ type WorkerManager struct { Client client.Client Log logr.Logger - contentWriter *contentWriter - mu sync.RWMutex - workers map[BranchKey]*BranchWorker - ctx context.Context + mu sync.RWMutex + workers map[BranchKey]*BranchWorker + ctx context.Context } // NewWorkerManager creates a new worker manager. func NewWorkerManager(client client.Client, log logr.Logger) *WorkerManager { return &WorkerManager{ - Client: client, - Log: log, - contentWriter: newContentWriter(), - workers: make(map[BranchKey]*BranchWorker), + Client: client, + Log: log, + workers: make(map[BranchKey]*BranchWorker), } } -// ConfigureSecretEncryption applies Secret write encryption settings for all workers. -func (m *WorkerManager) ConfigureSecretEncryption(cfg EncryptionConfig) error { - m.mu.Lock() - defer m.mu.Unlock() - return configureSecretEncryptionWriter(m.contentWriter, cfg) -} - // RegisterTarget ensures a worker exists for the target's (provider, branch) // and registers the target with that worker. // This is called by GitTarget controller when a target becomes Ready. func (m *WorkerManager) RegisterTarget( _ context.Context, - _ string, targetNamespace string, + targetName, targetNamespace string, providerName, providerNamespace string, branch, path string, ) error { @@ -86,7 +77,7 @@ func (m *WorkerManager) RegisterTarget( providerName, providerNamespace, branch, - m.contentWriter, + newContentWriter(), ) if err := worker.Start(m.ctx); err != nil { @@ -96,8 +87,13 @@ func (m *WorkerManager) RegisterTarget( m.workers[key] = worker } + worker := m.workers[key] + if err := worker.EnsurePathBootstrapped(path, targetName, targetNamespace); err != nil { + return fmt.Errorf("failed to ensure path bootstrap for %s/%s: %w", targetNamespace, targetName, err) + } + m.Log.Info("GitTarget registered with branch worker", - "target", fmt.Sprintf("%s/%s", targetNamespace, ""), + "target", fmt.Sprintf("%s/%s", targetNamespace, targetName), "workerKey", key.String(), "path", path) diff --git a/internal/git/worker_manager_test.go b/internal/git/worker_manager_test.go index 6a7fc624..8b6f948b 100644 --- a/internal/git/worker_manager_test.go +++ b/internal/git/worker_manager_test.go @@ -20,12 +20,15 @@ package git import ( "context" + "path/filepath" "testing" "time" "github.com/go-logr/logr" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" @@ -38,6 +41,46 @@ func setupScheme() *runtime.Scheme { return scheme } +func createProviderWithLocalRepo( + t *testing.T, + ctx context.Context, + k8sClient client.Client, + namespace, name string, +) { + t.Helper() + + remotePath := filepath.Join(t.TempDir(), name+".git") + createBareRepo(t, remotePath) + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: "file://" + remotePath, + }, + } + provider.Name = name + provider.Namespace = namespace + require.NoError(t, k8sClient.Create(ctx, provider)) +} + +func createTargetForRegister( + t *testing.T, + ctx context.Context, + k8sClient client.Client, + namespace, name, providerName, branch, path string, +) { + t.Helper() + target := &configv1alpha1.GitTarget{} + target.Name = name + target.Namespace = namespace + target.Spec.ProviderRef = configv1alpha1.GitProviderReference{ + Kind: "GitProvider", + Name: providerName, + } + target.Spec.Branch = branch + target.Spec.Path = path + require.NoError(t, k8sClient.Create(ctx, target)) +} + // TestWorkerManagerRegisterTarget verifies worker registration. func TestWorkerManagerRegisterTarget(t *testing.T) { scheme := setupScheme() @@ -53,6 +96,8 @@ func TestWorkerManagerRegisterTarget(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) // Allow manager to start + createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") + createTargetForRegister(t, ctx, client, "default", "target1", "repo1", "main", "clusters/prod") // Register first target err := manager.RegisterTarget(ctx, @@ -105,6 +150,9 @@ func TestWorkerManagerMultipleTargetsSameBranch(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) + createProviderWithLocalRepo(t, ctx, client, "gitops-system", "shared-repo") + createTargetForRegister(t, ctx, client, "default", "target-apps", "shared-repo", "main", "apps/") + createTargetForRegister(t, ctx, client, "default", "target-infra", "shared-repo", "main", "infra/") // Register two targets for same repo+branch, different paths err := manager.RegisterTarget(ctx, @@ -156,6 +204,9 @@ func TestWorkerManagerDifferentBranches(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) + createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") + createTargetForRegister(t, ctx, client, "default", "target-main", "repo1", "main", "base/") + createTargetForRegister(t, ctx, client, "default", "target-dev", "repo1", "develop", "base/") // Register targets for same repo, different branches err := manager.RegisterTarget(ctx, @@ -212,6 +263,9 @@ func TestWorkerManagerUnregisterTarget(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) + createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") + createTargetForRegister(t, ctx, client, "default", "target1", "repo1", "main", "apps/") + createTargetForRegister(t, ctx, client, "default", "target2", "repo1", "main", "infra/") // Register two targets _ = manager.RegisterTarget(ctx, @@ -279,6 +333,8 @@ func TestWorkerManagerConcurrentRegistration(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) + createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") + createTargetForRegister(t, ctx, client, "default", "target", "repo1", "main", "base/") // Concurrently register multiple targets done := make(chan bool, 10) diff --git a/internal/reconcile/git_target_event_stream.go b/internal/reconcile/git_target_event_stream.go index cb2b3b3b..88ede507 100644 --- a/internal/reconcile/git_target_event_stream.go +++ b/internal/reconcile/git_target_event_stream.go @@ -144,6 +144,9 @@ func (s *GitTargetEventStream) OnReconciliationComplete() { // processEvent forwards the event to BranchWorker and updates deduplication state. func (s *GitTargetEventStream) processEvent(event git.Event, eventHash, resourceKey string) { + event.GitTargetName = s.gitTargetName + event.GitTargetNamespace = s.gitTargetNamespace + // Forward to BranchWorker s.branchWorker.Enqueue(event) diff --git a/internal/types/identifier.go b/internal/types/identifier.go index 8847d985..7170f9eb 100644 --- a/internal/types/identifier.go +++ b/internal/types/identifier.go @@ -91,3 +91,8 @@ func (r ResourceIdentifier) String() string { } return fmt.Sprintf("%s/%s/%s/%s", r.Group, r.Version, r.Resource, r.Name) } + +// IsSecretResource returns true when the identifier points to a core/v1 Secret resource. +func IsSecretResource(id ResourceIdentifier) bool { + return id.Group == "" && id.Version == "v1" && id.Resource == "secrets" +} diff --git a/internal/types/identifier_test.go b/internal/types/identifier_test.go index 169f64a7..53e598e3 100644 --- a/internal/types/identifier_test.go +++ b/internal/types/identifier_test.go @@ -296,3 +296,21 @@ func TestFromAdmissionRequest(t *testing.T) { }) } } + +func TestIsSecretResource(t *testing.T) { + assert.True(t, IsSecretResource(ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "secrets", + })) + assert.False(t, IsSecretResource(ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + })) + assert.False(t, IsSecretResource(ResourceIdentifier{ + Group: "example.com", + Version: "v1", + Resource: "secrets", + })) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 530a364f..0503a98a 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -19,6 +19,7 @@ limitations under the License. package e2e import ( + "bytes" "encoding/json" "fmt" "os" @@ -27,6 +28,7 @@ import ( "strings" "time" + "filippo.io/age" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -37,6 +39,7 @@ import ( // giteaRepoURLTemplate is the URL template for test Gitea repositories. const giteaRepoURLTemplate = "http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/%s.git" const giteaSSHURLTemplate = "ssh://git@gitea-ssh.gitea-e2e.svc.cluster.local:2222/testorg/%s.git" +const e2eAgeKeyPath = "/tmp/e2e-age-key.txt" var testRepoName string var checkoutDir string @@ -87,6 +90,8 @@ var _ = Describe("Manager", Ordered, func() { _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + setupSOPSAgeSecret(e2eAgeKeyPath) + By("installing CRDs") cmd = exec.Command("make", "install") _, err = utils.Run(cmd) @@ -543,6 +548,7 @@ var _ = Describe("Manager", Ordered, func() { err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, namespace) Expect(err).NotTo(HaveOccurred(), "Failed to apply WatchRule") verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") By("creating Secret in watched namespace") _, _ = utils.Run(exec.Command("kubectl", "delete", "secret", secretName, @@ -553,6 +559,14 @@ var _ = Describe("Manager", Ordered, func() { _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Secret creation should succeed") + By("patching Secret once to avoid informer start race and force an update event") + patchCmd := exec.Command( + "kubectl", "patch", "secret", secretName, "-n", namespace, + "--type=merge", "--patch", `{"stringData":{"password":"never-commit-this"}}`, + ) + _, err = utils.Run(patchCmd) + Expect(err).NotTo(HaveOccurred(), "Secret patch should succeed") + By("verifying Secret file is committed and does not contain plaintext data") verifyEncryptedSecretCommitted := func(g Gomega) { pullCmd := exec.Command("git", "pull") @@ -570,6 +584,19 @@ var _ = Describe("Manager", Ordered, func() { g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("Secret file must exist at %s", expectedFile)) g.Expect(string(content)).To(ContainSubstring("sops:")) g.Expect(string(content)).NotTo(ContainSubstring("do-not-commit")) + + bootstrapSOPSFile := filepath.Join(checkoutDir, "e2e/secret-encryption-test", ".sops.yaml") + bootstrapContent, bootstrapErr := os.ReadFile(bootstrapSOPSFile) + g.Expect(bootstrapErr).NotTo(HaveOccurred(), fmt.Sprintf(".sops.yaml must exist at %s", bootstrapSOPSFile)) + ageKey, ageKeyErr := readSOPSAgeKeyFromFile(e2eAgeKeyPath) + g.Expect(ageKeyErr).NotTo(HaveOccurred(), "Should read age private key") + recipient, recipientErr := deriveAgeRecipient(ageKey) + g.Expect(recipientErr).NotTo(HaveOccurred(), "Should derive age recipient") + g.Expect(string(bootstrapContent)).To(ContainSubstring(recipient)) + + decryptedOutput, decryptErr := decryptWithControllerSOPS(content, ageKey) + g.Expect(decryptErr).NotTo(HaveOccurred(), "Should decrypt committed secret via controller sops binary") + g.Expect(decryptedOutput).To(ContainSubstring("bmV2ZXItY29tbWl0LXRoaXM=")) } Eventually(verifyEncryptedSecretCommitted, "30s", "2s").Should(Succeed()) @@ -605,6 +632,7 @@ var _ = Describe("Manager", Ordered, func() { By("verifying WatchRule is ready") verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") By("creating test ConfigMap to trigger Git commit") configMapData := struct { @@ -731,6 +759,7 @@ var _ = Describe("Manager", Ordered, func() { By("verifying WatchRule is ready") verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") By("creating test ConfigMap to trigger Git commit") configMapData := struct { @@ -834,6 +863,7 @@ var _ = Describe("Manager", Ordered, func() { By("verifying ClusterWatchRule is ready") verifyResourceStatus("clusterwatchrule", clusterWatchRuleName, "", "True", "Ready", "") + verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") By("installing the IceCreamOrder CRD to trigger Git commit") cmd := exec.Command("kubectl", "apply", "-f", "test/e2e/templates/icecreamorder-crd.yaml") @@ -927,6 +957,7 @@ var _ = Describe("Manager", Ordered, func() { By("verifying WatchRule is ready") verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") By("creating CR with labels and annotations to trigger Git commit") crdInstanceData := struct { @@ -1394,6 +1425,61 @@ var _ = Describe("Manager", Ordered, func() { }) }) +func readSOPSAgeKeyFromFile(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read age key file: %w", err) + } + + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "AGE-SECRET-KEY-") { + return trimmed, nil + } + } + + return "", fmt.Errorf("no AGE-SECRET-KEY found in %s", path) +} + +func deriveAgeRecipient(identityString string) (string, error) { + identity, err := age.ParseX25519Identity(strings.TrimSpace(identityString)) + if err != nil { + return "", fmt.Errorf("parse age identity: %w", err) + } + return identity.Recipient().String(), nil +} + +func decryptWithControllerSOPS(ciphertext []byte, ageKey string) (string, error) { + podCmd := exec.Command( + "kubectl", "get", "pods", + "-l", "control-plane=controller-manager", + "-n", namespace, + "-o", "jsonpath={.items[0].metadata.name}", + ) + podOutput, err := utils.Run(podCmd) + if err != nil { + return "", fmt.Errorf("get controller pod: %w", err) + } + podName := strings.TrimSpace(podOutput) + if podName == "" { + return "", fmt.Errorf("controller pod name is empty") + } + + cmd := exec.Command( + "kubectl", "exec", "-i", + podName, "-n", namespace, "--", + "env", fmt.Sprintf("SOPS_AGE_KEY=%s", ageKey), + "/usr/local/bin/sops", "--decrypt", "--input-type", "yaml", "--output-type", "yaml", "/dev/stdin", + ) + cmd.Stdin = bytes.NewReader(ciphertext) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("decrypt with controller sops failed: %w: %s", err, strings.TrimSpace(string(output))) + } + + return string(output), nil +} + // createGitProviderWithURL creates a GitProvider resource with the specified URL. func createGitProviderWithURL(name, branch, secretName, repoURL string) { By(fmt.Sprintf("creating GitProvider '%s' with branch '%s', secret '%s' and URL '%s'", diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index c58798eb..26a15142 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -26,11 +26,13 @@ import ( "bytes" "context" "fmt" + "os" "os/exec" "strings" "text/template" "time" + "filippo.io/age" . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // Ginkgo standard practice . "github.com/onsi/gomega" //nolint:staticcheck // Ginkgo standard practice "github.com/prometheus/client_golang/api" @@ -43,6 +45,7 @@ import ( // namespace where the project is deployed in. const namespace = "sut" const metricWaitDefaultTimeout = 30 * time.Second +const e2eEncryptionSecretName = "sops-age-key" // controllerServiceName is the single Service name used by the controller. const controllerServiceName = "gitops-reverser-service" @@ -205,17 +208,19 @@ func createGitTarget(name, namespace, providerName, path, branch string) { name, namespace, providerName, path)) data := struct { - Name string - Namespace string - ProviderName string - Branch string - Path string + Name string + Namespace string + ProviderName string + Branch string + Path string + EncryptionSecretName string }{ - Name: name, - Namespace: namespace, - ProviderName: providerName, - Branch: branch, - Path: path, + Name: name, + Namespace: namespace, + ProviderName: providerName, + Branch: branch, + Path: path, + EncryptionSecretName: e2eEncryptionSecretName, } err := applyFromTemplate("test/e2e/templates/gittarget.tmpl", data, namespace) @@ -253,3 +258,29 @@ func cleanupClusterWatchRule(name string) { "--ignore-not-found=true") _, _ = utils.Run(cmd) } + +func setupSOPSAgeSecret(keyPath string) { + By("creating SOPS age key and encryption secret for e2e") + + identity, err := age.GenerateX25519Identity() + Expect(err).NotTo(HaveOccurred(), "Failed to generate age identity") + + // Keep file format compatible with existing key reader in e2e tests. + keyFileContent := fmt.Sprintf("# created: e2e\n# public key: %s\n%s\n", identity.Recipient(), identity) + err = os.WriteFile(keyPath, []byte(keyFileContent), 0600) + Expect(err).NotTo(HaveOccurred(), "Failed to write e2e age key file") + + cmd := exec.Command( + "kubectl", "create", "secret", "generic", e2eEncryptionSecretName, + "--namespace", namespace, + "--from-literal=SOPS_AGE_KEY="+identity.String(), + "--dry-run=client", "-o", "yaml", + ) + createOutput, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to render SOPS encryption secret") + + applyCmd := exec.Command("kubectl", "apply", "-f", "-") + applyCmd.Stdin = strings.NewReader(createOutput) + _, err = utils.Run(applyCmd) + Expect(err).NotTo(HaveOccurred(), "Failed to apply SOPS encryption secret") +} diff --git a/test/e2e/scripts/setup-gitea.sh b/test/e2e/scripts/setup-gitea.sh index e95e3016..95d9f553 100755 --- a/test/e2e/scripts/setup-gitea.sh +++ b/test/e2e/scripts/setup-gitea.sh @@ -269,6 +269,7 @@ setup_credentials() { --dry-run=client -o yaml | kubectl apply -f - echo "✅ Invalid credentials secret ($TARGET_NAMESPACE/${SECRET_NAME}-invalid) created for testing purposes" + } # Function to checkout repository with authentication @@ -359,4 +360,5 @@ echo " " # Cleanup temporary files -rm -f /tmp/org_response.json /tmp/repo_response.json /tmp/token_response.json /tmp/token_list.json /tmp/ssh_key_response.json \ No newline at end of file +rm -f /tmp/org_response.json /tmp/repo_response.json /tmp/token_response.json /tmp/token_list.json \ + /tmp/ssh_key_response.json diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index e2b85fac..74ba2ca4 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -9,3 +9,7 @@ spec: name: {{ .ProviderName }} branch: {{ .Branch }} path: {{ .Path }} + encryption: + provider: sops + secretRef: + name: {{ .EncryptionSecretName }} From bef2cc7b1d145802cc0f71fe8a4b60bc652d038d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 20:23:33 +0000 Subject: [PATCH 05/11] fix: linting --- .../path-scoped-bootstrap-template-design.md | 285 +++++++++++++----- internal/git/bootstrapped_repo_template.go | 127 +++++--- internal/git/branch_worker.go | 141 ++++++--- internal/git/branch_worker_test.go | 10 +- internal/git/worker_manager_test.go | 43 +-- test/e2e/e2e_test.go | 8 +- test/e2e/helpers.go | 28 +- 7 files changed, 450 insertions(+), 192 deletions(-) diff --git a/docs/design/path-scoped-bootstrap-template-design.md b/docs/design/path-scoped-bootstrap-template-design.md index 88b1cb06..c22b1666 100644 --- a/docs/design/path-scoped-bootstrap-template-design.md +++ b/docs/design/path-scoped-bootstrap-template-design.md @@ -1,75 +1,210 @@ -# Path-Scoped Bootstrap Template Design - -## Decision - -Use path existence in Git as the bootstrap signal: -- if target path contains at least one file, skip bootstrap -- if target path is empty or missing, apply bootstrap template and push - -No `GitTarget.status` persistence for bootstrap is needed in this increment. - -## Why this is acceptable - -- Cheap check: local filesystem walk inside already-cloned worker repo. -- Simple mental model: Git content is source of truth. -- Survives controller restarts without extra status logic. - -Tradeoff: -- if users manually add any file in the path, bootstrap will be skipped (intentional with this model). - -## Required behavior - -- Bootstrap is path-scoped (`GitTarget.spec.path`), not repo-root scoped. -- Trigger on target registration (or first use), not only on unborn branch init. -- Keep normal event writes focused on resource mirroring; no bootstrap healing loop. - -## Implementation Order - -1. Remove branch-level bootstrap from `PrepareBranch` -- File: `internal/git/git.go` -- Remove `commitBootstrapTemplateIfNeeded(...)` call from unborn-branch flow. -- `PrepareBranch` should only ensure clone/fetch/branch readiness. - -2. Make template writer path-aware -- File: `internal/git/bootstrapped_repo_template.go` -- Rename internals from repo bootstrap to path bootstrap semantics. -- Add function to stage template under a base path: `//...`. -- Keep commit metadata and message explicit, e.g. `chore(bootstrap): initialize path `. - -3. Add worker service for path bootstrap -- File: `internal/git/branch_worker.go` -- Add synchronous method: `EnsurePathBootstrapped(path string) error`. -- Steps inside method: - - ensure repo initialized - - normalize path - - check whether path has any file - - if empty: stage template in that path, commit, push - - if non-empty: return nil (no-op) - -4. Call bootstrap from registration flow -- File: `internal/git/worker_manager.go` -- In `RegisterTarget(...)`, after obtaining/creating worker: - - call `worker.EnsurePathBootstrapped(path)` - - return error if bootstrap fails -- Do not add persisted/in-memory bootstrapped path tracking in this increment. - -5. Keep encryption scope unchanged in this document -- This document only covers bootstrap trigger semantics. -- Encryption scoping work is separate and can be layered afterward. - -6. Tests -- Add/update tests in: - - `internal/git/git_operations_test.go` - - `internal/git/branch_worker_test.go` - - `internal/git/worker_manager_test.go` (or add if missing) -- Must cover: - - empty path bootstraps once - - non-empty path skips bootstrap - - nested distinct paths can both bootstrap when each is empty - - restart/re-register behavior uses Git content check correctly - -## Notes and Edge Cases - -- Path normalization must be shared with existing sanitizer rules. -- Repo root path (`""`) is valid and should use same check. -- No automatic cleanup when target path changes. +# Path-Scoped Bootstrap Template Design (Direction Change) + +## Why this document + +This documents: +1. What was just implemented. +2. Why that direction is wrong for multi-`GitTarget` setups. +3. The new design direction for bootstrap files per `GitTarget.spec.path`. +4. Encryption ownership and runtime behavior for multi-path repos. + +## Current Implementation State (as of now) + +Recent changes introduced a repository-level bootstrap template: +- `internal/git/bootstrapped-repo-template/.sops.yaml` +- `internal/git/bootstrapped-repo-template/README.md` + +And wired bootstrap into branch initialization: +- `PrepareBranch(...)` in `internal/git/git.go` now creates and pushes a bootstrap commit on unborn/empty branches. +- Normal write path no longer ensures `.sops.yaml`. + +### What this means behavior-wise + +- Bootstrap is currently **branch-scoped** and **repo-root scoped**. +- It does not distinguish multiple `GitTarget` paths sharing the same `(GitProvider, branch)`. + +## Why this does not match the required model + +You are correct: the system supports multiple `GitTarget`s on the same repo/branch, each with its own folder (`spec.path`). + +Current bootstrap behavior is wrong because: +- It initializes only repo root, not each target folder. +- `BranchWorker` is the sharing boundary (per provider+branch), so path-level intent should be handled there. +- A new `GitTarget` path can be introduced long after branch creation, but current bootstrap logic only runs during initial branch preparation. + +## Requirement interpretation + +Requested behavior: +- When a **new unique `GitTarget` folder path** is introduced, copy `bootstrapped-repo-template` into that folder. +- This is path-level bootstrap, not branch-root bootstrap. + +This makes architectural sense in this codebase. + +## Encryption placement decision + +Encryption behavior must be configured at `GitTarget`, not `GitProvider`. + +Reason: +- multiple `GitTarget`s can share one repo/branch but represent different environments (`test`, `prod`). +- those environments may require different key material. +- `sops` already supports path-based key selection via nearest `.sops.yaml`. + +Decision: +- `GitProvider` remains read-only transport/auth concerns (clone/fetch/push). +- remove encryption configuration from `GitProvider` in this direction. +- add encryption configuration to `GitTarget`: + - `spec.encryption.provider` (`sops` for now) + - `spec.encryption.secretRef.name` (namespace-local secret with sops credentials/env) + +Runtime behavior: +- when writing an event for a `GitTarget`, the writer uses that target's encryption credentials. +- `sops` resolves rules from the path-scoped `.sops.yaml` created in the target folder. +- no repo-wide default encryption in this increment (explicitly out of scope). + +## Final semantic choice + +Bootstrap is **one-time per path registration**. + +Meaning: +- when a normalized path is first registered and not yet marked bootstrapped, initialize it. +- after success, persist that this path is bootstrapped. +- do not continuously heal/restore files during normal write flow. + +## Proposed design direction + +## 1) Move bootstrap trigger from `PrepareBranch` to path-aware registration flow + +Remove bootstrap commit logic from `PrepareBranch`. + +Bootstrap should happen when a new path is registered for a worker: +- Entry point: `WorkerManager.RegisterTarget(...)` +- Worker key: `(providerNamespace, providerName, branch)` +- Path argument already exists in `RegisterTarget` and is currently only logged. + +## 2) Add a BranchWorker service: `EnsurePathBootstrapped(path string)` + +`BranchWorker` should expose a synchronous method that: +- ensures local repo is initialized (`PrepareBranch` without bootstrap side effects) +- writes template files under `/` (or repo root if path is empty) +- commits and pushes only if files were newly added + +Template source should be renamed to: +- `internal/git/path-bootstrap-template/` + +Rationale: +- name reflects path-scoped behavior (not repo-wide bootstrap). +- clear ownership to GitTarget bootstrap concern. + +Should these files live under `internal/`? +- yes, for this increment. +- they are controller runtime assets, not external API or user library surface. +- if we later support user-provided templates, that can become a separate API/reference mechanism. + +## 3) Track unique bootstrapped paths per worker + +`WorkerManager` should track per worker which paths are already bootstrapped (in-memory map keyed by normalized path). + +On `RegisterTarget(...)`: +- If path is new for this worker: call `EnsurePathBootstrapped(path)`. +- If already seen: skip. + +This keeps steady-state cheap and avoids per-event checks. + +Persistence is required (see section below), so in-memory cache is only a fast mirror of persisted status. + +## 4) Resolve encryption per GitTarget (not per worker) + +`BranchWorker` is shared by `(provider, branch)`, so one shared encryptor is unsafe. + +Required change: +- stop caching encryption config as one worker-global writer state. +- for each event batch (or each event), resolve encryption from the owning `GitTarget`. +- use a writer/encryptor scoped to that `GitTarget` credentials. + +This prevents cross-target credential bleed when multiple paths share a branch worker. + +## 5) Keep normal event write path focused on mirroring resources + +`WriteEvents(...)` remains path-aware for resource files only. +No bootstrap enforcement in write loop. + +## 6) Path normalization rules (must be shared) + +Reuse existing path sanitizer behavior (or equivalent) so these are treated consistently: +- `"clusters/prod"` +- `"/clusters/prod/"` (if ever accepted upstream) +- `""` (repo root) + +Use one canonical key for uniqueness tracking. + +## 7) Commit strategy + +Bootstrap commit should be small and explicit, e.g.: +- `chore(bootstrap): initialize path ` + +If multiple files are added for same path, keep them in one commit. + +## Edge cases to handle explicitly + +1. Nested paths (`clusters` and `clusters/prod`): +- Current webhook uniqueness appears to prevent exact duplicates, not prefix overlaps. +- For now: implement it so that we don't accept overlaps. + +2. Path updates on an existing `GitTarget`: +- Treat new path as newly introduced and bootstrap it. +- Old path is not cleaned up automatically. + +3. Deleted/recreated targets: +- Recreated target with same path should bootstrap only if path has not been seen in current process. +- On controller restart, behavior depends on whether we persist path-bootstrap state. + +4. Manual deletion of bootstrap files: +- If we keep one-time semantics, we do not continuously restore them. + +## Persistence choice (recommended) + +Use `GitTarget.status` for persistence. + +Proposed status field: +- `status.bootstrappedPaths: []string` + - normalized path values successfully bootstrapped by this target. + - include `""` for repo-root path. + +Why list instead of bool: +- supports path changes over time without losing history. +- keeps one-time semantics explicit per normalized path. + +Controller startup check (required): +- on startup, list GitTargets and load `status.bootstrappedPaths`. +- seed worker/path bootstrap cache from persisted status. +- for each active GitTarget: + - normalize `spec.path` + - if path already present in `status.bootstrappedPaths`, skip bootstrap + - if missing, run bootstrap once and update status on success + +Status update contract: +- append path only after bootstrap commit+push succeeded. +- do not append on partial failure. +- use status patch with retry/conflict handling. + +## Implementation impact summary + +Will need updates in: +- `internal/git/git.go` (remove branch-level bootstrap from `PrepareBranch`) +- `internal/git/bootstrapped_repo_template.go` (support writing template into arbitrary base path; rename template dir usage) +- `internal/git/worker_manager.go` (track unique paths per worker, call bootstrap) +- `internal/git/branch_worker.go` (new synchronous bootstrap service method) +- `internal/git/encryption.go` (resolve from `GitTarget` instead of `GitProvider`) +- `internal/controller/gittarget_controller.go` and related flows (pass/resolve target encryption context) +- `api/v1alpha1/gittarget_types.go` + CRDs (add `spec.encryption`, add `status.bootstrappedPaths`) +- `api/v1alpha1/gitprovider_types.go` + CRDs (remove/deprecate encryption there in this direction) +- `internal/controller/gittarget_controller.go` (ensure registration/bootstrap is triggered at the right lifecycle point and rehydrated at startup) +- tests in `internal/git/*_test.go`, controller tests, and e2e tests + +## Conclusion + +Your requested direction is valid and better aligned with the actual multi-target architecture. + +Key corrections: +- bootstrap must be **path-scoped at worker registration time**, not **repo-root at branch initialization time**. +- encryption must be **GitTarget-scoped**, with no repo-wide default in this increment. diff --git a/internal/git/bootstrapped_repo_template.go b/internal/git/bootstrapped_repo_template.go index fecf1fd0..980cd9d7 100644 --- a/internal/git/bootstrapped_repo_template.go +++ b/internal/git/bootstrapped_repo_template.go @@ -163,52 +163,105 @@ func stageBootstrapTemplateInPath(worktree *gogit.Worktree, targetPath string, o return entries[i].Name() < entries[j].Name() }) - root := worktree.Filesystem.Root() - targetDir := root - if targetPath != "" { - targetDir = filepath.Join(root, targetPath) - if err := os.MkdirAll(targetDir, 0750); err != nil { - return fmt.Errorf("failed to create bootstrap target path %s: %w", targetPath, err) - } + targetDir, err := bootstrapTargetDirectory(worktree, targetPath) + if err != nil { + return err } for _, entry := range entries { - if entry.IsDir() { + if shouldSkipBootstrapEntry(entry, options) { continue } - if entry.Name() == sopsConfigFileName && !options.IncludeSOPSConfig { - continue + if err := stageBootstrapTemplateEntry(worktree, targetPath, targetDir, entry.Name(), options); err != nil { + return err } + } - templatePath := path.Join(bootstrapTemplateDir, entry.Name()) - content, err := bootstrapTemplateFS.ReadFile(templatePath) - if err != nil { - return fmt.Errorf("failed to read bootstrap template %s: %w", entry.Name(), err) - } - if entry.Name() == sopsConfigFileName { - content, err = renderSOPSBootstrapTemplate(content, options.TemplateData) - if err != nil { - return err - } - } + return nil +} - destinationPath := filepath.Join(targetDir, entry.Name()) - if _, statErr := os.Stat(destinationPath); statErr != nil { - if !os.IsNotExist(statErr) { - return fmt.Errorf("failed to stat bootstrap target %s: %w", entry.Name(), statErr) - } - if err := os.WriteFile(destinationPath, content, bootstrapTemplateFilePerm); err != nil { - return fmt.Errorf("failed to write bootstrap file %s: %w", entry.Name(), err) - } - } +func bootstrapTargetDirectory(worktree *gogit.Worktree, targetPath string) (string, error) { + root := worktree.Filesystem.Root() + if targetPath == "" { + return root, nil + } - gitPath := entry.Name() - if targetPath != "" { - gitPath = filepath.ToSlash(filepath.Join(targetPath, entry.Name())) - } - if _, err := worktree.Add(gitPath); err != nil { - return fmt.Errorf("failed to stage bootstrap file %s: %w", entry.Name(), err) - } + targetDir := filepath.Join(root, targetPath) + if err := os.MkdirAll(targetDir, 0750); err != nil { + return "", fmt.Errorf("failed to create bootstrap target path %s: %w", targetPath, err) + } + + return targetDir, nil +} + +func shouldSkipBootstrapEntry(entry fs.DirEntry, options pathBootstrapOptions) bool { + if entry.IsDir() { + return true + } + return entry.Name() == sopsConfigFileName && !options.IncludeSOPSConfig +} + +func stageBootstrapTemplateEntry( + worktree *gogit.Worktree, + targetPath string, + targetDir string, + entryName string, + options pathBootstrapOptions, +) error { + content, err := readBootstrapTemplateContent(entryName, options) + if err != nil { + return err + } + + destinationPath := filepath.Join(targetDir, entryName) + if err := writeBootstrapFileIfMissing(destinationPath, entryName, content); err != nil { + return err + } + + return stageBootstrapFile(worktree, targetPath, entryName) +} + +func readBootstrapTemplateContent(entryName string, options pathBootstrapOptions) ([]byte, error) { + templatePath := path.Join(bootstrapTemplateDir, entryName) + content, err := bootstrapTemplateFS.ReadFile(templatePath) + if err != nil { + return nil, fmt.Errorf("failed to read bootstrap template %s: %w", entryName, err) + } + + if entryName != sopsConfigFileName { + return content, nil + } + + rendered, err := renderSOPSBootstrapTemplate(content, options.TemplateData) + if err != nil { + return nil, err + } + + return rendered, nil +} + +func writeBootstrapFileIfMissing(destinationPath, entryName string, content []byte) error { + if _, err := os.Stat(destinationPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat bootstrap target %s: %w", entryName, err) + } + + if err := os.WriteFile(destinationPath, content, bootstrapTemplateFilePerm); err != nil { + return fmt.Errorf("failed to write bootstrap file %s: %w", entryName, err) + } + + return nil +} + +func stageBootstrapFile(worktree *gogit.Worktree, targetPath, entryName string) error { + gitPath := entryName + if targetPath != "" { + gitPath = filepath.ToSlash(filepath.Join(targetPath, entryName)) + } + + if _, err := worktree.Add(gitPath); err != nil { + return fmt.Errorf("failed to stage bootstrap file %s: %w", entryName, err) } return nil diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 3a783377..23132c95 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -30,6 +30,7 @@ import ( gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -178,69 +179,137 @@ func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace ctx = context.Background() } - normalizedPath := sanitizePath(path) - if strings.TrimSpace(path) != "" && normalizedPath == "" { - return fmt.Errorf("invalid path %q", path) + normalizedPath, err := normalizeBootstrapPath(path) + if err != nil { + return err } - if strings.TrimSpace(targetName) == "" || strings.TrimSpace(targetNamespace) == "" { - return fmt.Errorf("target name and namespace must be set for path bootstrap") + if err := validateBootstrapTarget(targetName, targetNamespace); err != nil { + return err } - provider, err := w.getGitProvider(ctx) + bootstrapOptions, err := w.resolveBootstrapOptions(ctx, targetName, targetNamespace) if err != nil { - return fmt.Errorf("failed to get GitProvider: %w", err) + return err } - auth, err := getAuthFromSecret(ctx, w.Client, provider) + repoPath, auth, err := w.prepareBootstrapRepository(ctx) if err != nil { - return fmt.Errorf("failed to get auth: %w", err) + return err + } + + commitHash, committed, err := w.bootstrapPathIfEmpty(ctx, repoPath, normalizedPath, bootstrapOptions, auth) + if err != nil { + return err + } + if !committed { + return nil + } + + w.metaMu.Lock() + w.branchExists = true + w.lastCommitSHA = printSha(commitHash) + w.lastFetchTime = time.Now() + w.metaMu.Unlock() + + w.Log.Info("Bootstrapped path", "path", normalizedPath, "commit", printSha(commitHash)) + return nil +} + +func normalizeBootstrapPath(path string) (string, error) { + normalizedPath := sanitizePath(path) + if strings.TrimSpace(path) != "" && normalizedPath == "" { + return "", fmt.Errorf("invalid path %q", path) + } + return normalizedPath, nil +} + +func validateBootstrapTarget(targetName, targetNamespace string) error { + if strings.TrimSpace(targetName) == "" || strings.TrimSpace(targetNamespace) == "" { + return errors.New("target name and namespace must be set for path bootstrap") } + return nil +} +func (w *BranchWorker) resolveBootstrapOptions( + ctx context.Context, + targetName string, + targetNamespace string, +) (pathBootstrapOptions, error) { targetKey := types.NamespacedName{Name: targetName, Namespace: targetNamespace} var target configv1alpha1.GitTarget if err := w.Client.Get(ctx, targetKey, &target); err != nil { - return fmt.Errorf("failed to get GitTarget %s: %w", targetKey.String(), err) + return pathBootstrapOptions{}, fmt.Errorf("failed to get GitTarget %s: %w", targetKey.String(), err) } + encryptionConfig, err := ResolveTargetEncryption(ctx, w.Client, &target) if err != nil { - return fmt.Errorf("failed to resolve target encryption: %w", err) + return pathBootstrapOptions{}, fmt.Errorf("failed to resolve target encryption: %w", err) + } + if encryptionConfig == nil { + return pathBootstrapOptions{}, nil } - bootstrapOptions := pathBootstrapOptions{} - if encryptionConfig != nil { - sopsKey := strings.TrimSpace(encryptionConfig.Environment[sopsAgeKeyEnvVar]) - if sopsKey == "" { - return fmt.Errorf("missing %s in target encryption secret", sopsAgeKeyEnvVar) - } - recipient, deriveErr := deriveAgeRecipientFromSOPSKey(sopsKey) - if deriveErr != nil { - return fmt.Errorf("failed to derive age recipient: %w", deriveErr) - } - bootstrapOptions.IncludeSOPSConfig = true - bootstrapOptions.TemplateData.AgeRecipient = recipient + sopsKey := strings.TrimSpace(encryptionConfig.Environment[sopsAgeKeyEnvVar]) + if sopsKey == "" { + return pathBootstrapOptions{}, fmt.Errorf("missing %s in target encryption secret", sopsAgeKeyEnvVar) + } + + recipient, err := deriveAgeRecipientFromSOPSKey(sopsKey) + if err != nil { + return pathBootstrapOptions{}, fmt.Errorf("failed to derive age recipient: %w", err) + } + + return pathBootstrapOptions{ + IncludeSOPSConfig: true, + TemplateData: bootstrapTemplateData{ + AgeRecipient: recipient, + }, + }, nil +} + +func (w *BranchWorker) prepareBootstrapRepository( + ctx context.Context, +) (string, transport.AuthMethod, error) { + provider, err := w.getGitProvider(ctx) + if err != nil { + return "", nil, fmt.Errorf("failed to get GitProvider: %w", err) + } + + auth, err := getAuthFromSecret(ctx, w.Client, provider) + if err != nil { + return "", nil, fmt.Errorf("failed to get auth: %w", err) } repoPath := filepath.Join("/tmp", "gitops-reverser-workers", w.GitProviderNamespace, w.GitProviderRef, w.Branch) - pullReport, err := PrepareBranch(ctx, provider.Spec.URL, repoPath, w.Branch, auth) if err != nil { - return fmt.Errorf("failed to prepare repository: %w", err) + return "", nil, fmt.Errorf("failed to prepare repository: %w", err) } w.updateBranchMetadataFromPullReport(pullReport) + return repoPath, auth, nil +} + +func (w *BranchWorker) bootstrapPathIfEmpty( + ctx context.Context, + repoPath string, + normalizedPath string, + options pathBootstrapOptions, + auth transport.AuthMethod, +) (plumbing.Hash, bool, error) { hasFiles, err := pathHasAnyFile(repoPath, normalizedPath) if err != nil { - return fmt.Errorf("failed to check path contents: %w", err) + return plumbing.ZeroHash, false, fmt.Errorf("failed to check path contents: %w", err) } if hasFiles { w.Log.V(1).Info("Skipping bootstrap for non-empty path", "path", normalizedPath) - return nil + return plumbing.ZeroHash, false, nil } repo, err := gogit.PlainOpen(repoPath) if err != nil { - return fmt.Errorf("failed to open repository: %w", err) + return plumbing.ZeroHash, false, fmt.Errorf("failed to open repository: %w", err) } hash, committed, err := commitPathBootstrapTemplateIfNeeded( @@ -248,24 +317,14 @@ func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace repo, plumbing.NewBranchReferenceName(w.Branch), normalizedPath, - bootstrapOptions, + options, auth, ) if err != nil { - return fmt.Errorf("failed to bootstrap path %q: %w", normalizedPath, err) - } - if !committed { - return nil + return plumbing.ZeroHash, false, fmt.Errorf("failed to bootstrap path %q: %w", normalizedPath, err) } - w.metaMu.Lock() - w.branchExists = true - w.lastCommitSHA = printSha(hash) - w.lastFetchTime = time.Now() - w.metaMu.Unlock() - - w.Log.Info("Bootstrapped path", "path", normalizedPath, "commit", printSha(hash)) - return nil + return hash, committed, nil } // listResourceIdentifiersInPath lists resource identifiers in a specific path. diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index c77a8723..2ffd0b9b 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -252,7 +252,7 @@ func TestBranchWorker_EnsurePathBootstrapped_EmptyPathCreatesTemplate(t *testing provider.Name = "test-repo" provider.Namespace = "default" require.NoError(t, k8sClient.Create(ctx, provider)) - createTargetWithEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") + createTargetWithEncryption(ctx, t, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) @@ -301,7 +301,7 @@ func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathSkipsTemplate(t *testin provider.Name = "test-repo" provider.Namespace = "default" require.NoError(t, k8sClient.Create(ctx, provider)) - createTargetWithEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") + createTargetWithEncryption(ctx, t, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) @@ -340,7 +340,7 @@ func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *test provider.Name = "test-repo" provider.Namespace = "default" require.NoError(t, k8sClient.Create(ctx, provider)) - createTargetWithoutEncryption(t, ctx, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/dev") + createTargetWithoutEncryption(ctx, t, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/dev") worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) @@ -356,8 +356,8 @@ func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *test } func createTargetWithEncryption( - t *testing.T, ctx context.Context, + t *testing.T, k8sClient client.Client, name, namespace, providerName, branch, path string, ) { @@ -392,8 +392,8 @@ func createTargetWithEncryption( } func createTargetWithoutEncryption( - t *testing.T, ctx context.Context, + t *testing.T, k8sClient client.Client, name, namespace, providerName, branch, path string, ) { diff --git a/internal/git/worker_manager_test.go b/internal/git/worker_manager_test.go index 8b6f948b..aab12c0d 100644 --- a/internal/git/worker_manager_test.go +++ b/internal/git/worker_manager_test.go @@ -34,6 +34,11 @@ import ( configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" ) +const ( + testProviderNamespace = "gitops-system" + testTargetNamespace = "default" +) + func setupScheme() *runtime.Scheme { scheme := runtime.NewScheme() _ = clientgoscheme.AddToScheme(scheme) @@ -42,10 +47,10 @@ func setupScheme() *runtime.Scheme { } func createProviderWithLocalRepo( - t *testing.T, ctx context.Context, + t *testing.T, k8sClient client.Client, - namespace, name string, + name string, ) { t.Helper() @@ -58,20 +63,20 @@ func createProviderWithLocalRepo( }, } provider.Name = name - provider.Namespace = namespace + provider.Namespace = testProviderNamespace require.NoError(t, k8sClient.Create(ctx, provider)) } func createTargetForRegister( - t *testing.T, ctx context.Context, + t *testing.T, k8sClient client.Client, - namespace, name, providerName, branch, path string, + name, providerName, branch, path string, ) { t.Helper() target := &configv1alpha1.GitTarget{} target.Name = name - target.Namespace = namespace + target.Namespace = testTargetNamespace target.Spec.ProviderRef = configv1alpha1.GitProviderReference{ Kind: "GitProvider", Name: providerName, @@ -96,8 +101,8 @@ func TestWorkerManagerRegisterTarget(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) // Allow manager to start - createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") - createTargetForRegister(t, ctx, client, "default", "target1", "repo1", "main", "clusters/prod") + createProviderWithLocalRepo(ctx, t, client, "repo1") + createTargetForRegister(ctx, t, client, "target1", "repo1", "main", "clusters/prod") // Register first target err := manager.RegisterTarget(ctx, @@ -150,9 +155,9 @@ func TestWorkerManagerMultipleTargetsSameBranch(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) - createProviderWithLocalRepo(t, ctx, client, "gitops-system", "shared-repo") - createTargetForRegister(t, ctx, client, "default", "target-apps", "shared-repo", "main", "apps/") - createTargetForRegister(t, ctx, client, "default", "target-infra", "shared-repo", "main", "infra/") + createProviderWithLocalRepo(ctx, t, client, "shared-repo") + createTargetForRegister(ctx, t, client, "target-apps", "shared-repo", "main", "apps/") + createTargetForRegister(ctx, t, client, "target-infra", "shared-repo", "main", "infra/") // Register two targets for same repo+branch, different paths err := manager.RegisterTarget(ctx, @@ -204,9 +209,9 @@ func TestWorkerManagerDifferentBranches(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) - createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") - createTargetForRegister(t, ctx, client, "default", "target-main", "repo1", "main", "base/") - createTargetForRegister(t, ctx, client, "default", "target-dev", "repo1", "develop", "base/") + createProviderWithLocalRepo(ctx, t, client, "repo1") + createTargetForRegister(ctx, t, client, "target-main", "repo1", "main", "base/") + createTargetForRegister(ctx, t, client, "target-dev", "repo1", "develop", "base/") // Register targets for same repo, different branches err := manager.RegisterTarget(ctx, @@ -263,9 +268,9 @@ func TestWorkerManagerUnregisterTarget(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) - createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") - createTargetForRegister(t, ctx, client, "default", "target1", "repo1", "main", "apps/") - createTargetForRegister(t, ctx, client, "default", "target2", "repo1", "main", "infra/") + createProviderWithLocalRepo(ctx, t, client, "repo1") + createTargetForRegister(ctx, t, client, "target1", "repo1", "main", "apps/") + createTargetForRegister(ctx, t, client, "target2", "repo1", "main", "infra/") // Register two targets _ = manager.RegisterTarget(ctx, @@ -333,8 +338,8 @@ func TestWorkerManagerConcurrentRegistration(t *testing.T) { _ = manager.Start(ctx) }() time.Sleep(100 * time.Millisecond) - createProviderWithLocalRepo(t, ctx, client, "gitops-system", "repo1") - createTargetForRegister(t, ctx, client, "default", "target", "repo1", "main", "base/") + createProviderWithLocalRepo(ctx, t, client, "repo1") + createTargetForRegister(ctx, t, client, "target", "repo1", "main", "base/") // Concurrently register multiple targets done := make(chan bool, 10) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 0503a98a..3f6647f1 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -21,6 +21,7 @@ package e2e import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -587,7 +588,10 @@ var _ = Describe("Manager", Ordered, func() { bootstrapSOPSFile := filepath.Join(checkoutDir, "e2e/secret-encryption-test", ".sops.yaml") bootstrapContent, bootstrapErr := os.ReadFile(bootstrapSOPSFile) - g.Expect(bootstrapErr).NotTo(HaveOccurred(), fmt.Sprintf(".sops.yaml must exist at %s", bootstrapSOPSFile)) + g.Expect(bootstrapErr).NotTo( + HaveOccurred(), + fmt.Sprintf(".sops.yaml must exist at %s", bootstrapSOPSFile), + ) ageKey, ageKeyErr := readSOPSAgeKeyFromFile(e2eAgeKeyPath) g.Expect(ageKeyErr).NotTo(HaveOccurred(), "Should read age private key") recipient, recipientErr := deriveAgeRecipient(ageKey) @@ -1462,7 +1466,7 @@ func decryptWithControllerSOPS(ciphertext []byte, ageKey string) (string, error) } podName := strings.TrimSpace(podOutput) if podName == "" { - return "", fmt.Errorf("controller pod name is empty") + return "", errors.New("controller pod name is empty") } cmd := exec.Command( diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 26a15142..0682c506 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -45,7 +45,7 @@ import ( // namespace where the project is deployed in. const namespace = "sut" const metricWaitDefaultTimeout = 30 * time.Second -const e2eEncryptionSecretName = "sops-age-key" +const e2eEncryptionRefName = "sops-age-key" // controllerServiceName is the single Service name used by the controller. const controllerServiceName = "gitops-reverser-service" @@ -220,7 +220,7 @@ func createGitTarget(name, namespace, providerName, path, branch string) { ProviderName: providerName, Branch: branch, Path: path, - EncryptionSecretName: e2eEncryptionSecretName, + EncryptionSecretName: e2eEncryptionRefName, } err := applyFromTemplate("test/e2e/templates/gittarget.tmpl", data, namespace) @@ -270,17 +270,19 @@ func setupSOPSAgeSecret(keyPath string) { err = os.WriteFile(keyPath, []byte(keyFileContent), 0600) Expect(err).NotTo(HaveOccurred(), "Failed to write e2e age key file") - cmd := exec.Command( - "kubectl", "create", "secret", "generic", e2eEncryptionSecretName, - "--namespace", namespace, - "--from-literal=SOPS_AGE_KEY="+identity.String(), - "--dry-run=client", "-o", "yaml", - ) - createOutput, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to render SOPS encryption secret") - - applyCmd := exec.Command("kubectl", "apply", "-f", "-") - applyCmd.Stdin = strings.NewReader(createOutput) + manifest := fmt.Sprintf(`apiVersion: v1 +kind: Secret +metadata: + name: %s + namespace: %s +type: Opaque +stringData: + SOPS_AGE_KEY: %q +`, e2eEncryptionRefName, namespace, identity.String()) + + ctx := context.Background() + applyCmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") + applyCmd.Stdin = strings.NewReader(manifest) _, err = utils.Run(applyCmd) Expect(err).NotTo(HaveOccurred(), "Failed to apply SOPS encryption secret") } From c09dc746b1dc7ed5ca805a5f54b68e3354ffe452 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 20:47:04 +0000 Subject: [PATCH 06/11] chore: Templating a on a per file basis --- internal/git/bootstrapped_repo_template.go | 34 --- internal/git/branch_worker.go | 29 ++- internal/git/branch_worker_test.go | 270 ++++++++++++++++++++- 3 files changed, 280 insertions(+), 53 deletions(-) diff --git a/internal/git/bootstrapped_repo_template.go b/internal/git/bootstrapped_repo_template.go index 980cd9d7..9e618813 100644 --- a/internal/git/bootstrapped_repo_template.go +++ b/internal/git/bootstrapped_repo_template.go @@ -22,7 +22,6 @@ import ( "bytes" "context" "embed" - "errors" "fmt" "io/fs" "os" @@ -51,8 +50,6 @@ const ( //go:embed bootstrapped-repo-template/* bootstrapped-repo-template/.sops.yaml var bootstrapTemplateFS embed.FS -var errFoundFileInPath = errors.New("found file in path") - type bootstrapTemplateData struct { AgeRecipient string } @@ -122,37 +119,6 @@ func bootstrapCommitMessage(targetPath string) string { return fmt.Sprintf("%s %s", bootstrapCommitMessageRoot, targetPath) } -func pathHasAnyFile(repoPath, targetPath string) (bool, error) { - basePath := repoPath - if targetPath != "" { - basePath = filepath.Join(repoPath, targetPath) - } - - err := filepath.WalkDir(basePath, func(currentPath string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - if filepath.Clean(currentPath) == filepath.Join(repoPath, ".git") { - return filepath.SkipDir - } - return nil - } - return errFoundFileInPath - }) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - if errors.Is(err, errFoundFileInPath) { - return true, nil - } - return false, err - } - - return false, nil -} - func stageBootstrapTemplateInPath(worktree *gogit.Worktree, targetPath string, options pathBootstrapOptions) error { entries, err := fs.ReadDir(bootstrapTemplateFS, bootstrapTemplateDir) if err != nil { diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 23132c95..8011489f 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -172,7 +172,8 @@ func (w *BranchWorker) ListResourcesInPath(path string) ([]itypes.ResourceIdenti return w.listResourceIdentifiersInPath(repoPath, path) } -// EnsurePathBootstrapped applies bootstrap template to a path when that path has no files. +// EnsurePathBootstrapped applies bootstrap templates to a path. +// Existing files are preserved, and only missing template files are added. func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace string) error { ctx := w.ctx if ctx == nil { @@ -197,7 +198,7 @@ func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace return err } - commitHash, committed, err := w.bootstrapPathIfEmpty(ctx, repoPath, normalizedPath, bootstrapOptions, auth) + commitHash, committed, err := w.bootstrapPathIfNeeded(ctx, repoPath, normalizedPath, bootstrapOptions, auth) if err != nil { return err } @@ -243,7 +244,9 @@ func (w *BranchWorker) resolveBootstrapOptions( encryptionConfig, err := ResolveTargetEncryption(ctx, w.Client, &target) if err != nil { - return pathBootstrapOptions{}, fmt.Errorf("failed to resolve target encryption: %w", err) + w.Log.Error(err, "Skipping SOPS bootstrap due to invalid target encryption configuration", + "target", targetKey.String()) + return pathBootstrapOptions{}, nil } if encryptionConfig == nil { return pathBootstrapOptions{}, nil @@ -251,12 +254,17 @@ func (w *BranchWorker) resolveBootstrapOptions( sopsKey := strings.TrimSpace(encryptionConfig.Environment[sopsAgeKeyEnvVar]) if sopsKey == "" { - return pathBootstrapOptions{}, fmt.Errorf("missing %s in target encryption secret", sopsAgeKeyEnvVar) + w.Log.Info("Skipping SOPS bootstrap due to missing encryption key in target secret", + "target", targetKey.String(), + "requiredKey", sopsAgeKeyEnvVar) + return pathBootstrapOptions{}, nil } recipient, err := deriveAgeRecipientFromSOPSKey(sopsKey) if err != nil { - return pathBootstrapOptions{}, fmt.Errorf("failed to derive age recipient: %w", err) + w.Log.Error(err, "Skipping SOPS bootstrap due to invalid encryption key", + "target", targetKey.String()) + return pathBootstrapOptions{}, nil } return pathBootstrapOptions{ @@ -291,22 +299,13 @@ func (w *BranchWorker) prepareBootstrapRepository( return repoPath, auth, nil } -func (w *BranchWorker) bootstrapPathIfEmpty( +func (w *BranchWorker) bootstrapPathIfNeeded( ctx context.Context, repoPath string, normalizedPath string, options pathBootstrapOptions, auth transport.AuthMethod, ) (plumbing.Hash, bool, error) { - hasFiles, err := pathHasAnyFile(repoPath, normalizedPath) - if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to check path contents: %w", err) - } - if hasFiles { - w.Log.V(1).Info("Skipping bootstrap for non-empty path", "path", normalizedPath) - return plumbing.ZeroHash, false, nil - } - repo, err := gogit.PlainOpen(repoPath) if err != nil { return plumbing.ZeroHash, false, fmt.Errorf("failed to open repository: %w", err) diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 2ffd0b9b..f127dd0a 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -272,7 +272,7 @@ func TestBranchWorker_EnsurePathBootstrapped_EmptyPathCreatesTemplate(t *testing require.NoError(t, err) } -func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathSkipsTemplate(t *testing.T) { +func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathBootstrapsMissingFiles(t *testing.T) { ctx := context.Background() tempDir := t.TempDir() remotePath := filepath.Join(tempDir, "remote.git") @@ -305,19 +305,22 @@ func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathSkipsTemplate(t *testin worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) require.NoError(t, err) - assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash()), "Existing path content should skip bootstrap commit") + assert.Equal(t, 2, countDepth(t, serverRepo, ref.Hash()), "Missing bootstrap files should be added once") clonePath := filepath.Join(tempDir, "inspect") _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) require.NoError(t, err) _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "README.md")) - assert.True(t, os.IsNotExist(err), "Bootstrap README should not be added to non-empty path") + require.NoError(t, err) _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) - assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should not be added to non-empty path") + require.NoError(t, err) + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "existing.txt")) + require.NoError(t, err) } func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *testing.T) { @@ -355,6 +358,203 @@ func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *test assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped when encryption is not configured") } +func TestBranchWorker_EnsurePathBootstrapped_ExistingFileNotOverwritten(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + _ = createBareRepo(t, remotePath) + + seedPath := filepath.Join(tempDir, "seed") + seedRepo, seedWorktree := initLocalRepo(t, seedPath, remoteURL, "main") + require.NoError(t, os.MkdirAll(filepath.Join(seedPath, "clusters/prod"), 0750)) + customREADME := "# custom readme\n" + commitFileChange(t, seedWorktree, seedPath, "clusters/prod/README.md", customREADME) + require.NoError(t, seedRepo.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("refs/heads/main:refs/heads/main"), + }, + })) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + createTargetWithEncryption(ctx, t, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/prod") + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) + + clonePath := filepath.Join(tempDir, "inspect") + _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + readmeContent, err := os.ReadFile(filepath.Join(clonePath, "clusters/prod", "README.md")) + require.NoError(t, err) + assert.Equal(t, customREADME, string(readmeContent), "Bootstrap must not overwrite existing files") + _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + require.NoError(t, err) +} + +func TestBranchWorker_EnsurePathBootstrapped_EnableEncryptionLaterAddsSOPSConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + serverRepo := createBareRepo(t, remotePath) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + createTargetWithoutEncryption(ctx, t, k8sClient, "bootstrap-target", "default", "test-repo", "main", "clusters/dev") + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) + + cloneBeforePath := filepath.Join(tempDir, "inspect-before") + _, err := PrepareBranch(ctx, remoteURL, cloneBeforePath, "main", nil) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(cloneBeforePath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(cloneBeforePath, "clusters/dev", sopsConfigFileName)) + assert.True(t, os.IsNotExist(err), "SOPS config should not exist before encryption is configured") + + attachEncryptionToTarget(ctx, t, k8sClient, "bootstrap-target", "default") + require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) + + ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal( + t, + 2, + countDepth(t, serverRepo, ref.Hash()), + "Enabling encryption later should add one bootstrap commit", + ) + + cloneAfterPath := filepath.Join(tempDir, "inspect-after") + _, err = PrepareBranch(ctx, remoteURL, cloneAfterPath, "main", nil) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(cloneAfterPath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(cloneAfterPath, "clusters/dev", sopsConfigFileName)) + require.NoError(t, err) +} + +func TestBranchWorker_EnsurePathBootstrapped_InvalidEncryptionSecretSkipsSOPSConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + _ = createBareRepo(t, remotePath) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + + createTargetWithEncryptionSecretData( + ctx, + t, + k8sClient, + "bootstrap-target", + "default", + "test-repo", + "main", + "clusters/dev", + map[string][]byte{ + sopsAgeKeyEnvVar: []byte("not-an-age-identity"), + }, + ) + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) + + clonePath := filepath.Join(tempDir, "inspect") + _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped for invalid encryption secret") +} + +func TestBranchWorker_EnsurePathBootstrapped_MissingSOPSKeySkipsSOPSConfig(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + _ = createBareRepo(t, remotePath) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + + createTargetWithEncryptionSecretData( + ctx, + t, + k8sClient, + "bootstrap-target", + "default", + "test-repo", + "main", + "clusters/dev", + map[string][]byte{ + "OTHER_ENV": []byte("value"), + }, + ) + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) + + clonePath := filepath.Join(tempDir, "inspect") + _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped when SOPS_AGE_KEY is missing") +} + func createTargetWithEncryption( ctx context.Context, t *testing.T, @@ -409,3 +609,65 @@ func createTargetWithoutEncryption( target.Spec.Path = path require.NoError(t, k8sClient.Create(ctx, target)) } + +func createTargetWithEncryptionSecretData( + ctx context.Context, + t *testing.T, + k8sClient client.Client, + name, namespace, providerName, branch, path string, + secretData map[string][]byte, +) { + t.Helper() + + encryptionSecret := &corev1.Secret{} + encryptionSecret.Name = "sops-age-key" + encryptionSecret.Namespace = namespace + encryptionSecret.Data = secretData + require.NoError(t, k8sClient.Create(ctx, encryptionSecret)) + + target := &configv1alpha1.GitTarget{} + target.Name = name + target.Namespace = namespace + target.Spec.ProviderRef = configv1alpha1.GitProviderReference{ + Kind: "GitProvider", + Name: providerName, + } + target.Spec.Branch = branch + target.Spec.Path = path + target.Spec.Encryption = &configv1alpha1.EncryptionSpec{ + Provider: "sops", + SecretRef: configv1alpha1.LocalSecretReference{ + Name: encryptionSecret.Name, + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) +} + +func attachEncryptionToTarget( + ctx context.Context, + t *testing.T, + k8sClient client.Client, + targetName, targetNamespace string, +) { + t.Helper() + identity, err := age.GenerateX25519Identity() + require.NoError(t, err) + + encryptionSecret := &corev1.Secret{} + encryptionSecret.Name = "sops-age-key" + encryptionSecret.Namespace = targetNamespace + encryptionSecret.Data = map[string][]byte{ + sopsAgeKeyEnvVar: []byte(identity.String()), + } + require.NoError(t, k8sClient.Create(ctx, encryptionSecret)) + + target := &configv1alpha1.GitTarget{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: targetName, Namespace: targetNamespace}, target)) + target.Spec.Encryption = &configv1alpha1.EncryptionSpec{ + Provider: "sops", + SecretRef: configv1alpha1.LocalSecretReference{ + Name: encryptionSecret.Name, + }, + } + require.NoError(t, k8sClient.Update(ctx, target)) +} From 0100dfc7e06db24b104e4d1a93ab1d48f332aca5 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 21:30:37 +0000 Subject: [PATCH 07/11] chore: Bump versions --- .devcontainer/Dockerfile | 6 ++--- .devcontainer/README.md | 2 +- .devcontainer/post-create.sh | 21 ++++++++++++++++++ .github/workflows/ci.yml | 2 +- .golangci.yml | 4 ++-- Dockerfile | 2 +- cmd/main.go | 6 ++--- docs/ci/CI_NON_ROOT_USER_ANALYSIS.md | 4 ++-- go.mod | 2 +- internal/git/branch_worker.go | 8 +++---- internal/git/content_writer.go | 22 +++++++++---------- internal/{metrics => telemetry}/exporter.go | 4 ++-- .../{metrics => telemetry}/exporter_test.go | 5 +++-- internal/watch/informers.go | 10 ++++----- internal/watch/manager.go | 16 +++++++------- internal/webhook/audit_handler.go | 6 ++--- internal/webhook/audit_handler_test.go | 4 ++-- internal/webhook/event_handler.go | 6 ++--- internal/webhook/event_handler_test.go | 20 ++++++++--------- 19 files changed, 86 insertions(+), 64 deletions(-) rename internal/{metrics => telemetry}/exporter.go (98%) rename internal/{metrics => telemetry}/exporter_test.go (99%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 736e77c2..a02b8944 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for GitOps Reverser # Stage 1: CI base image with essential build tools -FROM golang:1.25.6-bookworm AS ci +FROM golang:1.26.0-bookworm AS ci # Avoid warnings by switching to noninteractive ENV DEBIAN_FRONTEND=noninteractive @@ -24,9 +24,9 @@ RUN apt-get update \ # Tool versions - centralized for easy updates ENV KUBECTL_VERSION=v1.35.0 \ - KUSTOMIZE_VERSION=5.8.0 \ + KUSTOMIZE_VERSION=5.8.1 \ KUBEBUILDER_VERSION=4.11.1 \ - GOLANGCI_LINT_VERSION=v2.8.0 \ + GOLANGCI_LINT_VERSION=v2.10.1 \ HELM_VERSION=v4.0.0 # Install kubectl diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 84ce5c4a..43de3dcf 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -12,7 +12,7 @@ Quick-start development environment with all tools pre-installed. ### Verify ```bash -go version # 1.25.6 +go version # 1.26.0 kind version # v0.30.0 kubectl version # v1.32.3 golangci-lint version # v2.4.0 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 7f30b16d..3aa5c29e 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -23,6 +23,27 @@ if [ -f /home/vscode/.gitconfig-host ]; then fi fi +# Ensure SSH commit signing works inside the container +log "Configuring SSH commit signing (Git) inside container" + +git config --global gpg.format ssh +git config --global commit.gpgsign true + +mkdir -p /home/vscode/.config/git /home/vscode/.ssh + +# Create allowed signers file if missing +if [ ! -s /home/vscode/.config/git/allowed_signers ]; then + ssh-add -L | head -n 1 | awk '{print "simonkoudijs@gmail.com "$0}' > /home/vscode/.config/git/allowed_signers +fi +git config --global gpg.ssh.allowedSignersFile /home/vscode/.config/git/allowed_signers + +# Create a stable public key file to point Git at +if [ ! -s /home/vscode/.ssh/signing_key.pub ]; then + ssh-add -L | head -n 1 > /home/vscode/.ssh/signing_key.pub +fi +git config --global user.signingkey /home/vscode/.ssh/signing_key.pub + + # Ensure Go-related caches exist and are writable by vscode log "Ensuring Go cache directories exist" sudo mkdir -p \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c1d0f4..b41015a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: branches: [main, develop] env: - GO_VERSION: "1.25.6" + GO_VERSION: "1.26.0" REGISTRY: ghcr.io IMAGE_NAME: configbutler/gitops-reverser IMAGE_TAG: ci-${{ github.sha }} diff --git a/.golangci.yml b/.golangci.yml index 233af2c9..c212c0a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -347,7 +347,7 @@ linters: linters: [gochecknoglobals, gochecknoinits] - path: 'cmd/main\.go' linters: [gochecknoglobals, cyclop, gochecknoinits, gocognit, funlen] - - path: 'internal/metrics/.*\.go' + - path: 'internal/telemetry/.*\.go' linters: [gochecknoglobals] # Allow test suite global variables and patterns - path: '_test\.go' @@ -374,7 +374,7 @@ linters: - path: 'test/e2e/.*\.go' linters: [forbidigo] # Allow fmt.Println in metrics exporter (initialization logs) - - path: 'internal/metrics/.*\.go' + - path: 'internal/telemetry/.*\.go' linters: [forbidigo] # Relax godot for test helpers and utility functions - path: '(test/|helpers\.go)' diff --git a/Dockerfile b/Dockerfile index 657d9c7a..1c54d450 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.25.6 AS builder +FROM golang:1.26.0 AS builder # Automatic platform arguments provided by Docker BuildKit ARG TARGETOS diff --git a/cmd/main.go b/cmd/main.go index 926618df..880189be 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -52,9 +52,9 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/controller" "github.com/ConfigButler/gitops-reverser/internal/correlation" "github.com/ConfigButler/gitops-reverser/internal/git" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/reconcile" "github.com/ConfigButler/gitops-reverser/internal/rulestore" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" "github.com/ConfigButler/gitops-reverser/internal/watch" webhookhandler "github.com/ConfigButler/gitops-reverser/internal/webhook" // +kubebuilder:scaffold:imports @@ -99,7 +99,7 @@ func main() { // Initialize metrics setupCtx := ctrl.SetupSignalHandler() - _, err := metrics.InitOTLPExporter(setupCtx) + _, err := telemetry.InitOTLPExporter(setupCtx) fatalIfErr(err, "unable to initialize metrics exporter") // TLS/options @@ -130,7 +130,7 @@ func main() { // Initialize correlation store for webhook→watch enrichment correlationStore := correlation.NewStore(correlationTTL, correlationMaxEntries) correlationStore.SetEvictionCallback(func() { - metrics.KVEvictionsTotal.Add(context.Background(), 1) + telemetry.KVEvictionsTotal.Add(context.Background(), 1) }) setupLog.Info("Correlation store initialized", "ttl", correlationTTL, diff --git a/docs/ci/CI_NON_ROOT_USER_ANALYSIS.md b/docs/ci/CI_NON_ROOT_USER_ANALYSIS.md index 39e1366d..b2c8cd52 100644 --- a/docs/ci/CI_NON_ROOT_USER_ANALYSIS.md +++ b/docs/ci/CI_NON_ROOT_USER_ANALYSIS.md @@ -186,7 +186,7 @@ If organizational policy requires non-root CI, here's the implementation strateg #### Option 1: Hybrid Approach (Recommended) ```dockerfile # Build stage: root for tool installation -FROM golang:1.25.6 AS ci-builder +FROM golang:1.26.0 AS ci-builder RUN apt-get update && apt-get install... RUN curl -LO kubectl... @@ -200,7 +200,7 @@ USER ciuser #### Option 2: Rootless with Capabilities ```dockerfile # Install tools as root -FROM golang:1.25.6 AS ci +FROM golang:1.26.0 AS ci RUN apt-get update... # Create non-root user with specific capabilities diff --git a/go.mod b/go.mod index eb542056..2933fa12 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ConfigButler/gitops-reverser -go 1.25.6 +go 1.26.0 require ( github.com/cespare/xxhash/v2 v2.3.0 diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 8011489f..821b515b 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -36,8 +36,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -493,9 +493,9 @@ func (w *BranchWorker) commitAndPush( } // Metrics - metrics.GitOperationsTotal.Add(w.ctx, int64(len(events))) - metrics.CommitsTotal.Add(w.ctx, 1) - metrics.ObjectsWrittenTotal.Add(w.ctx, int64(len(events))) + telemetry.GitOperationsTotal.Add(w.ctx, int64(len(events))) + telemetry.CommitsTotal.Add(w.ctx, 1) + telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(events))) } // handleShutdown finalizes processing when context is canceled. diff --git a/internal/git/content_writer.go b/internal/git/content_writer.go index 8882e6ef..7357e7be 100644 --- a/internal/git/content_writer.go +++ b/internal/git/content_writer.go @@ -25,8 +25,8 @@ import ( "fmt" "sync" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -106,18 +106,18 @@ func (w *contentWriter) encryptSecretContent(ctx context.Context, event Event, p return nil, errors.New("secret encryption is required but no encryptor is configured") } - if metrics.SecretEncryptionAttemptsTotal != nil { - metrics.SecretEncryptionAttemptsTotal.Add(ctx, 1) + if telemetry.SecretEncryptionAttemptsTotal != nil { + telemetry.SecretEncryptionAttemptsTotal.Add(ctx, 1) } encrypted, err := encryptor.Encrypt(ctx, plain, ResourceMeta(meta)) if err != nil { - if metrics.SecretEncryptionFailuresTotal != nil { - metrics.SecretEncryptionFailuresTotal.Add(ctx, 1) + if telemetry.SecretEncryptionFailuresTotal != nil { + telemetry.SecretEncryptionFailuresTotal.Add(ctx, 1) } return nil, fmt.Errorf("secret encryption failed: %w", err) } - if metrics.SecretEncryptionSuccessTotal != nil { - metrics.SecretEncryptionSuccessTotal.Add(ctx, 1) + if telemetry.SecretEncryptionSuccessTotal != nil { + telemetry.SecretEncryptionSuccessTotal.Add(ctx, 1) } w.mu.Lock() @@ -141,11 +141,11 @@ func (w *contentWriter) cachedEncryptedSecret( if !ok { return nil, false } - if metrics.SecretEncryptionMarkerSkipsTotal != nil { - metrics.SecretEncryptionMarkerSkipsTotal.Add(ctx, 1) + if telemetry.SecretEncryptionMarkerSkipsTotal != nil { + telemetry.SecretEncryptionMarkerSkipsTotal.Add(ctx, 1) } - if metrics.SecretEncryptionCacheHitsTotal != nil { - metrics.SecretEncryptionCacheHitsTotal.Add(ctx, 1) + if telemetry.SecretEncryptionCacheHitsTotal != nil { + telemetry.SecretEncryptionCacheHitsTotal.Add(ctx, 1) } return append([]byte(nil), cached...), true } diff --git a/internal/metrics/exporter.go b/internal/telemetry/exporter.go similarity index 98% rename from internal/metrics/exporter.go rename to internal/telemetry/exporter.go index 8d83e2e6..564d17d9 100644 --- a/internal/metrics/exporter.go +++ b/internal/telemetry/exporter.go @@ -17,10 +17,10 @@ limitations under the License. */ /* -Package metrics provides the OpenTelemetry-based metrics exporter for GitOps Reverser. +Package telemetry provides the OpenTelemetry-based metrics exporter for GitOps Reverser. It configures Prometheus-compatible metrics collection for monitoring controller operations. */ -package metrics +package telemetry import ( "context" diff --git a/internal/metrics/exporter_test.go b/internal/telemetry/exporter_test.go similarity index 99% rename from internal/metrics/exporter_test.go rename to internal/telemetry/exporter_test.go index b3e57f41..bab61c98 100644 --- a/internal/metrics/exporter_test.go +++ b/internal/telemetry/exporter_test.go @@ -16,10 +16,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package metrics +package telemetry import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -313,7 +314,7 @@ func TestHistogramMetricBehavior(t *testing.T) { } for _, value := range testValues { - t.Run("Duration_"+string(rune(int(value*1000))), func(t *testing.T) { + t.Run(fmt.Sprintf("Duration_%g", value), func(t *testing.T) { assert.NotPanics(t, func() { GitPushDurationSeconds.Record(ctx, value) }) diff --git a/internal/watch/informers.go b/internal/watch/informers.go index d7adba2f..3507589c 100644 --- a/internal/watch/informers.go +++ b/internal/watch/informers.go @@ -27,8 +27,8 @@ import ( configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" "github.com/ConfigButler/gitops-reverser/internal/git" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -94,7 +94,7 @@ func (m *Manager) handleEvent(obj interface{}, g GVR, op configv1alpha1.Operatio if op != configv1alpha1.OperationDelete && m.isDuplicateContent(ctx, sanitized, id) { m.Log.V(1).Info("Skipping duplicate sanitized content (likely status-only change)", "identifier", id.String()) - metrics.WatchDuplicatesSkippedTotal.Add(ctx, 1) + telemetry.WatchDuplicatesSkippedTotal.Add(ctx, 1) return } @@ -103,11 +103,11 @@ func (m *Manager) handleEvent(obj interface{}, g GVR, op configv1alpha1.Operatio // Emit basic metrics for watcher path (mirrors webhook semantics). // Count each watched object processed by the informer path. - metrics.ObjectsScannedTotal.Add(ctx, 1) + telemetry.ObjectsScannedTotal.Add(ctx, 1) enqueueCount := int64(len(wrRules) + len(cwrRules)) if enqueueCount > 0 { - metrics.EventsProcessedTotal.Add(ctx, enqueueCount) - metrics.GitCommitQueueSize.Add(ctx, enqueueCount) + telemetry.EventsProcessedTotal.Add(ctx, enqueueCount) + telemetry.GitCommitQueueSize.Add(ctx, enqueueCount) } // WatchRule matches - route to workers diff --git a/internal/watch/manager.go b/internal/watch/manager.go index 204e88cc..e725a917 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -43,9 +43,9 @@ import ( configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" "github.com/ConfigButler/gitops-reverser/internal/correlation" "github.com/ConfigButler/gitops-reverser/internal/git" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/rulestore" "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -225,7 +225,7 @@ func (m *Manager) enqueueMatches( if m.isDuplicateContent(ctx, sanitized, id) { m.Log.V(1).Info("Skipping duplicate sanitized content (likely status-only change)", "identifier", id.String()) - metrics.WatchDuplicatesSkippedTotal.Add(ctx, 1) + telemetry.WatchDuplicatesSkippedTotal.Add(ctx, 1) return } @@ -339,13 +339,13 @@ func (m *Manager) tryEnrichFromCorrelation( key := correlation.GenerateKey(id, operation, sanitizedYAML) entry, found := m.CorrelationStore.Get(key) if !found { - metrics.EnrichMissesTotal.Add(ctx, 1) + telemetry.EnrichMissesTotal.Add(ctx, 1) log.V(1).Info("No correlation match", "identifier", id.String(), "key", key) return userInfo } userInfo.Username = entry.Username - metrics.EnrichHitsTotal.Add(ctx, 1) + telemetry.EnrichHitsTotal.Add(ctx, 1) log.V(1).Info("Enriched with username", "identifier", id.String(), "username", entry.Username, @@ -551,7 +551,7 @@ func (m *Manager) seedListAndProcess( return } - metrics.ObjectsScannedTotal.Add(ctx, int64(len(list.Items))) + telemetry.ObjectsScannedTotal.Add(ctx, int64(len(list.Items))) for i := range list.Items { m.processListedObject(ctx, &list.Items[i], g) } @@ -570,7 +570,7 @@ func (m *Manager) seedListAndProcess( m.processListedObject(ctx, &list.Items[i], g) } } - metrics.ObjectsScannedTotal.Add(ctx, int64(totalItems)) + telemetry.ObjectsScannedTotal.Add(ctx, int64(totalItems)) log.V(1).Info("Seeded namespaced resources", "namespaces", len(namespacesToList), "totalItems", totalItems) } } @@ -605,8 +605,8 @@ func (m *Manager) processListedObject( enq := int64(len(wrRules) + len(cwrRules)) if enq > 0 { - metrics.EventsProcessedTotal.Add(ctx, enq) - metrics.GitCommitQueueSize.Add(ctx, enq) + telemetry.EventsProcessedTotal.Add(ctx, enq) + telemetry.GitCommitQueueSize.Add(ctx, enq) } } diff --git a/internal/webhook/audit_handler.go b/internal/webhook/audit_handler.go index fd12aadb..cdedd158 100644 --- a/internal/webhook/audit_handler.go +++ b/internal/webhook/audit_handler.go @@ -40,7 +40,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/ConfigButler/gitops-reverser/internal/metrics" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" ) const ( @@ -63,7 +63,7 @@ type AuditHandlerConfig struct { MaxRequestBodyBytes int64 } -// AuditHandler handles incoming audit events and collects metrics. +// AuditHandler handles incoming audit events and collects telemetry. type AuditHandler struct { scheme *runtime.Scheme deserializer runtime.Decoder @@ -202,7 +202,7 @@ func (h *AuditHandler) processEvents(ctx context.Context, clusterID string, even user = auditEvent.ImpersonatedUser.Username } - metrics.AuditEventsReceivedTotal.Add(ctx, 1, metric.WithAttributes( + telemetry.AuditEventsReceivedTotal.Add(ctx, 1, metric.WithAttributes( attribute.String("cluster_id", clusterIDMetric), attribute.String("gvr", gvr), attribute.String("action", action), diff --git a/internal/webhook/audit_handler_test.go b/internal/webhook/audit_handler_test.go index 40a2c20a..f3e6a709 100644 --- a/internal/webhook/audit_handler_test.go +++ b/internal/webhook/audit_handler_test.go @@ -32,7 +32,7 @@ import ( auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" "sigs.k8s.io/yaml" - "github.com/ConfigButler/gitops-reverser/internal/metrics" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,7 +41,7 @@ import ( func TestMain(m *testing.M) { // Initialize metrics for tests - _, err := metrics.InitOTLPExporter(context.Background()) + _, err := telemetry.InitOTLPExporter(context.Background()) if err != nil { panic("Failed to initialize metrics: " + err.Error()) } diff --git a/internal/webhook/event_handler.go b/internal/webhook/event_handler.go index 244bf99f..436be02f 100644 --- a/internal/webhook/event_handler.go +++ b/internal/webhook/event_handler.go @@ -40,8 +40,8 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/ConfigButler/gitops-reverser/internal/correlation" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/sanitize" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -65,7 +65,7 @@ func (h *EventHandler) Handle(ctx context.Context, req admission.Request) admiss // Metrics: attribute role based on pod labels roleAttr := h.getPodRoleAttribute(ctx) - metrics.EventsReceivedTotal.Add(ctx, 1, metric.WithAttributes(roleAttr)) + telemetry.EventsReceivedTotal.Add(ctx, 1, metric.WithAttributes(roleAttr)) // Safety: require decoder if h.Decoder == nil { @@ -143,7 +143,7 @@ func (h *EventHandler) storeCorrelation( // Increment webhook-specific correlation metric roleAttr := h.getPodRoleAttribute(ctx) - metrics.WebhookCorrelationsTotal.Add(ctx, 1, metric.WithAttributes(roleAttr)) + telemetry.WebhookCorrelationsTotal.Add(ctx, 1, metric.WithAttributes(roleAttr)) log.V(1).Info("Stored correlation entry (unfiltered)", "kind", obj.GetKind(), diff --git a/internal/webhook/event_handler_test.go b/internal/webhook/event_handler_test.go index c5208674..ceae3b48 100644 --- a/internal/webhook/event_handler_test.go +++ b/internal/webhook/event_handler_test.go @@ -39,14 +39,14 @@ import ( configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" "github.com/ConfigButler/gitops-reverser/internal/correlation" - "github.com/ConfigButler/gitops-reverser/internal/metrics" "github.com/ConfigButler/gitops-reverser/internal/rulestore" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" ) func TestEventHandler_Handle_MatchingRule(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -139,7 +139,7 @@ func TestEventHandler_Handle_MatchingRule(t *testing.T) { func TestEventHandler_Handle_NoMatchingRule(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -220,7 +220,7 @@ func TestEventHandler_Handle_NoMatchingRule(t *testing.T) { func TestEventHandler_Handle_MultipleMatchingRules(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -320,7 +320,7 @@ func TestEventHandler_Handle_MultipleMatchingRules(t *testing.T) { func TestEventHandler_Handle_ExcludedByLabels(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -412,7 +412,7 @@ func TestEventHandler_Handle_ExcludedByLabels(t *testing.T) { func TestEventHandler_Handle_InvalidJSON(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -457,7 +457,7 @@ func TestEventHandler_Handle_InvalidJSON(t *testing.T) { func TestEventHandler_Handle_NamespacedIngressResource(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -553,7 +553,7 @@ func TestEventHandler_Handle_DifferentOperations(t *testing.T) { t.Run(string(operation), func(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -635,7 +635,7 @@ func TestEventHandler_Handle_DifferentOperations(t *testing.T) { func TestEventHandler_Handle_ClusterScopedResource(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() @@ -727,7 +727,7 @@ func TestEventHandler_InjectDecoder(t *testing.T) { func TestEventHandler_Handle_SanitizationApplied(t *testing.T) { // Setup ctx := context.Background() - _, err := metrics.InitOTLPExporter(ctx) + _, err := telemetry.InitOTLPExporter(ctx) require.NoError(t, err) scheme := runtime.NewScheme() From 3de58f41970b50303babe1ba4a6878f4528509c6 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 21:45:38 +0000 Subject: [PATCH 08/11] fix: Let's fix it! --- Makefile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 326c4eb5..393fed92 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,7 @@ e2e-build-load-image: ## Build local image and load it into the Kind cluster use fi .PHONY: test-e2e -test-e2e: setup-cluster cleanup-webhook setup-e2e manifests setup-port-forwards ## Run end-to-end tests in Kind cluster, note that vet, fmt and generate are not run! +test-e2e: setup-cluster cleanup-webhook setup-e2e wait-cert-manager manifests setup-port-forwards ## Run end-to-end tests in Kind cluster, note that vet, fmt and generate are not run! @echo "ℹ️ test-e2e reuses the existing Kind cluster (no cluster cleanup in this target)"; \ if [ -n "$(PROJECT_IMAGE)" ]; then \ echo "ℹ️ Entry point selected pre-built image (CI-friendly): $(PROJECT_IMAGE)"; \ @@ -242,7 +242,7 @@ setup-gitea-e2e: ## Set up Gitea for e2e testing .PHONY: setup-cert-manager setup-cert-manager: - @echo "🚀 Setup cert-manager (no wait needed)" + @echo "🚀 Setting up cert-manager..." @$(KUBECTL) apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml | grep -v "unchanged" .PHONY: setup-port-forwards @@ -282,7 +282,11 @@ setup-e2e: setup-cert-manager setup-gitea-e2e setup-prometheus-e2e ## Setup all .PHONY: wait-cert-manager wait-cert-manager: setup-cert-manager ## Wait for cert-manager pods to become ready - @$(KUBECTL) wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s + @echo "⏳ Waiting for cert-manager deployments to become available..." + @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager --timeout=300s + @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager-webhook --timeout=300s + @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=300s + @echo "✅ cert-manager is ready" ## Smoke test: install from local Helm chart and verify rollout .PHONY: test-e2e-install From 14708d6381ec9a8ca232abe810f913fac636eda4 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 21:53:05 +0000 Subject: [PATCH 09/11] chore: Upgrade deps --- go.mod | 87 ++++++++++++++++++++------------- go.sum | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 2933fa12..b4403e70 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,26 @@ module github.com/ConfigButler/gitops-reverser go 1.26.0 require ( + filippo.io/age v1.3.1 github.com/cespare/xxhash/v2 v2.3.0 - github.com/go-git/go-git/v5 v5.16.4 + github.com/go-git/go-git/v5 v5.16.5 github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.5 github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.39.0 - go.opentelemetry.io/otel/exporters/prometheus v0.61.0 - go.opentelemetry.io/otel/metric v1.39.0 - go.opentelemetry.io/otel/sdk/metric v1.39.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/prometheus v0.62.0 + go.opentelemetry.io/otel/metric v1.40.0 + go.opentelemetry.io/otel/sdk/metric v1.40.0 go.uber.org/zap v1.27.1 - golang.org/x/crypto v0.47.0 - k8s.io/api v0.35.0 - k8s.io/apiextensions-apiserver v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/apiserver v0.35.0 - k8s.io/client-go v0.35.0 + golang.org/x/crypto v0.48.0 + k8s.io/api v0.35.1 + k8s.io/apiextensions-apiserver v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/apiserver v0.35.1 + k8s.io/client-go v0.35.1 sigs.k8s.io/controller-runtime v0.23.1 sigs.k8s.io/yaml v1.6.0 ) @@ -29,15 +30,19 @@ require ( require ( cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect - filippo.io/age v1.2.1 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/chzyer/readline v1.5.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -65,22 +70,30 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -92,41 +105,47 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + go.etcd.io/etcd/api/v3 v3.6.8 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect + go.etcd.io/etcd/client/v3 v3.6.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.42.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/component-base v0.35.0 // indirect + k8s.io/component-base v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kms v0.35.1 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect - k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index 7421aec5..a69635e0 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,23 @@ +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -25,8 +34,18 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= @@ -65,6 +84,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -110,6 +131,9 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -129,10 +153,22 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -145,6 +181,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -162,6 +202,8 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -172,6 +214,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -225,26 +269,62 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= +go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= +go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= +go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0= go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -257,49 +337,98 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -310,6 +439,8 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -318,22 +449,38 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= +k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= +k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.1 h1:kjv2r9g1mY7uL+l1RhyAZvWVZIA/4qIfBHXyjFGLRhU= +k8s.io/kms v0.35.1/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= 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.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= @@ -344,5 +491,7 @@ sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 4f5143e7cf59e6237a0c72cd4f2c72ff1b62fcf1 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 21:58:34 +0000 Subject: [PATCH 10/11] ci: Let's see if we can get this green again --- Makefile | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 393fed92..9118f598 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,7 @@ test: manifests generate fmt vet setup-envtest ## Run tests. KIND_CLUSTER ?= gitops-reverser-test-e2e E2E_LOCAL_IMAGE ?= gitops-reverser:e2e-local +CERT_MANAGER_WAIT_TIMEOUT ?= 600s .PHONY: setup-cluster setup-cluster: ## Set up a Kind cluster for e2e tests if it does not exist @@ -281,11 +282,22 @@ setup-e2e: setup-cert-manager setup-gitea-e2e setup-prometheus-e2e ## Setup all @echo "✅ E2E infrastructure initialized" .PHONY: wait-cert-manager -wait-cert-manager: setup-cert-manager ## Wait for cert-manager pods to become ready - @echo "⏳ Waiting for cert-manager deployments to become available..." - @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager --timeout=300s - @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager-webhook --timeout=300s - @$(KUBECTL) -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=300s +wait-cert-manager: ## Wait for cert-manager pods to become ready + @echo "⏳ Waiting for cert-manager deployments to become available (timeout=$(CERT_MANAGER_WAIT_TIMEOUT))..." + @set -e; \ + for dep in cert-manager cert-manager-webhook cert-manager-cainjector; do \ + echo " - waiting for deployment/$$dep"; \ + if ! $(KUBECTL) -n cert-manager rollout status deploy/$$dep --timeout=$(CERT_MANAGER_WAIT_TIMEOUT); then \ + echo "❌ Timed out waiting for cert-manager readiness (deployment=$$dep)"; \ + echo "📋 cert-manager deployments and pods:"; \ + $(KUBECTL) -n cert-manager get deploy,pod -o wide || true; \ + echo "📋 recent cert-manager events:"; \ + $(KUBECTL) -n cert-manager get events --sort-by=.metadata.creationTimestamp | tail -n 80 || true; \ + echo "📋 recent cert-manager logs:"; \ + $(KUBECTL) -n cert-manager logs -l app.kubernetes.io/instance=cert-manager --all-containers=true --tail=120 || true; \ + exit 1; \ + fi; \ + done @echo "✅ cert-manager is ready" ## Smoke test: install from local Helm chart and verify rollout From 8f58e035199532972d738adaa23a855ec78f180d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Feb 2026 22:25:15 +0000 Subject: [PATCH 11/11] ci: Ok quai.io was down: let's not overdo it. --- Makefile | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 9118f598..50d8a192 100644 --- a/Makefile +++ b/Makefile @@ -71,6 +71,8 @@ test: manifests generate fmt vet setup-envtest ## Run tests. KIND_CLUSTER ?= gitops-reverser-test-e2e E2E_LOCAL_IMAGE ?= gitops-reverser:e2e-local CERT_MANAGER_WAIT_TIMEOUT ?= 600s +CERT_MANAGER_VERSION ?= v1.19.1 +CERT_MANAGER_MANIFEST_URL ?= https://github.com/cert-manager/cert-manager/releases/download/$(CERT_MANAGER_VERSION)/cert-manager.yaml .PHONY: setup-cluster setup-cluster: ## Set up a Kind cluster for e2e tests if it does not exist @@ -104,7 +106,7 @@ e2e-build-load-image: ## Build local image and load it into the Kind cluster use fi .PHONY: test-e2e -test-e2e: setup-cluster cleanup-webhook setup-e2e wait-cert-manager manifests setup-port-forwards ## Run end-to-end tests in Kind cluster, note that vet, fmt and generate are not run! +test-e2e: setup-cluster cleanup-webhook setup-e2e check-cert-manager manifests setup-port-forwards ## Run end-to-end tests in Kind cluster, note that vet, fmt and generate are not run! @echo "ℹ️ test-e2e reuses the existing Kind cluster (no cluster cleanup in this target)"; \ if [ -n "$(PROJECT_IMAGE)" ]; then \ echo "ℹ️ Entry point selected pre-built image (CI-friendly): $(PROJECT_IMAGE)"; \ @@ -244,7 +246,7 @@ setup-gitea-e2e: ## Set up Gitea for e2e testing .PHONY: setup-cert-manager setup-cert-manager: @echo "🚀 Setting up cert-manager..." - @$(KUBECTL) apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml | grep -v "unchanged" + @$(KUBECTL) apply -f $(CERT_MANAGER_MANIFEST_URL) | grep -v "unchanged" .PHONY: setup-port-forwards setup-port-forwards: ## Start all port-forwards in background @@ -300,6 +302,10 @@ wait-cert-manager: ## Wait for cert-manager pods to become ready done @echo "✅ cert-manager is ready" +.PHONY: check-cert-manager +check-cert-manager: wait-cert-manager ## Explicit readiness check for cert-manager + @echo "✅ cert-manager check passed" + ## Smoke test: install from local Helm chart and verify rollout .PHONY: test-e2e-install test-e2e-install: ## Smoke test install with E2E_INSTALL_MODE=helm|manifest @@ -312,13 +318,13 @@ test-e2e-install: ## Smoke test install with E2E_INSTALL_MODE=helm|manifest if [ -n "$$PROJECT_IMAGE_VALUE" ]; then \ echo "ℹ️ Entry point selected pre-built image (probably running in CI): $$PROJECT_IMAGE_VALUE"; \ echo "ℹ️ Skipping cluster cleanup for pre-built image path"; \ - KIND_CLUSTER=$(KIND_CLUSTER) $(MAKE) setup-cluster setup-e2e wait-cert-manager; \ + KIND_CLUSTER=$(KIND_CLUSTER) $(MAKE) setup-cluster setup-e2e check-cert-manager; \ else \ PROJECT_IMAGE_VALUE="$(E2E_LOCAL_IMAGE)"; \ echo "🧹 Local fallback path: cleaning cluster to test a clean install"; \ KIND_CLUSTER=$(KIND_CLUSTER) $(MAKE) cleanup-cluster; \ echo "ℹ️ Entry point selected local fallback image: $$PROJECT_IMAGE_VALUE"; \ - KIND_CLUSTER=$(KIND_CLUSTER) PROJECT_IMAGE="$$PROJECT_IMAGE_VALUE" $(MAKE) setup-cluster setup-e2e wait-cert-manager e2e-build-load-image; \ + KIND_CLUSTER=$(KIND_CLUSTER) PROJECT_IMAGE="$$PROJECT_IMAGE_VALUE" $(MAKE) setup-cluster setup-e2e check-cert-manager e2e-build-load-image; \ fi; \ echo "ℹ️ Running install smoke mode: $$MODE"; \ PROJECT_IMAGE="$$PROJECT_IMAGE_VALUE" bash test/e2e/scripts/install-smoke.sh "$$MODE"; \