Skip to content

Rewrite: bash → Go, 13 engines + operator hardening (CRD-driven, secure, observable) - #8

Open
igorguedesrodrigues wants to merge 55 commits into
cloudscript-technology:mainfrom
igorguedesrodrigues:feat/go-rewrite
Open

Rewrite: bash → Go, 13 engines + operator hardening (CRD-driven, secure, observable)#8
igorguedesrodrigues wants to merge 55 commits into
cloudscript-technology:mainfrom
igorguedesrodrigues:feat/go-rewrite

Conversation

@igorguedesrodrigues

@igorguedesrodrigues igorguedesrodrigues commented Apr 23, 2026

Copy link
Copy Markdown

Summary

Complete bash → Go rewrite of dumpscript, plus a major hardening pass on
the binary and the Kubebuilder operator. The operator now exposes ~25 new
type-safe CRD fields covering every binary knob — no more extraEnv
incantations for the common path.

What's new in this push (on top of the original rewrite)

Security

  • Credential leak fix: mongodump/mongorestore now read the password
    from a temp YAML file (--config, mongo-tools 100.7+ format) instead
    of --password argv. clickhouse-client reads CLICKHOUSE_PASSWORD
    env. mssql-scripter is documented as the one remaining limitation.
  • Log redaction: slog handler masks attrs whose key matches
    password|secret|token|credential|api_key|*_key. Applies to both JSON
    and console formats.

Reliability

  • Dump retry decorator (internal/dumper/retry.go) — exponential
    backoff (3×, 5s→5m), bypassed on context cancellation. Wired in
    cli/dump.go.
  • Notifier retry decorator (internal/notify/retry.go) — every
    enabled notifier (Slack/Discord/Teams/Webhook/Stdout) wraps in a
    Retrying decorator.
  • Stale-lock recovery: lock.AcquireWithGrace takes over locks older
    than LOCK_GRACE_PERIOD (default 24h). Disabled with grace=0.
  • Post-restore reachability verifier: verifier.PostRestore TCP-dials
    the configured DB endpoint after restore so a successful phase only
    flips when the engine is actually answering connections.

Features

  • zstd compression (COMPRESSION_TYPE=zstd) — ~30% smaller dumps
    at ~2x the throughput. mongo/etcd keep gzip natively.
  • --dry-run mode for dump and cleanup.
  • S3 SSE-KMS via S3_SSE + S3_SSE_KMS_KEY_ID.
  • Object tagging: every uploaded blob carries managed_by=dumpscript,
    engine=<type>, periodicity=<value> (S3 Tagging, GCS/Azure
    Metadata).
  • /metrics HTTP endpoint in the binary (METRICS_LISTEN=:9090)
    for direct Prometheus scrape.
  • Artifact.Checksum SHA-256 computed during dump.

Operator — CRD expansion (~25 typed fields)

BackupScheduleSpec: dryRun, compression, dumpTimeout,
lockGracePeriod, verifyContent, workDir, logLevel, logFormat,
metricsListen, dumpRetry{maxAttempts,initialBackoff,maxBackoff},
prometheus{enabled,pushgatewayURL,jobName,instance,logOnExit},
imagePullPolicy, imagePullSecrets, concurrencyPolicy,
startingDeadlineSeconds, backoffLimit, activeDeadlineSeconds,
resources, nodeSelector, tolerations, affinity,
priorityClassName, extraEnv.

RestoreSpec: same runtime fields, with restoreTimeout instead of
dumpTimeout and no dumpRetry.

StorageSpec.S3Storage: sse, sseKMSKeyID.

DatabaseSpec engine sub-blocks (mirroring mongodb.authSource):
postgresql.version, mysql.version, mariadb.version. Plus
database.volume{mountPath, persistentVolumeClaim|emptyDir|configMap|secret}
which unblocks SQLite e2e.

CEL validation rules: storage.s3 required when backend=s3 (same for
gcs/azure); volume requires at least one source.

Operator — controllers, status, metrics

  • Conditions populated: standard Ready condition reflects the most
    recent terminal Job. Same on Restore.
  • Events emitted: Reconciled, LastRunSucceeded, LastRunFailed,
    CronJobError (BackupSchedule); RestoreRunning/Succeeded/Failed/JobError
    (Restore). Visible via kubectl describe.
  • Status enrichment: lastJobName, lastDurationSeconds, totalRuns,
    consecutiveFailures, observedGeneration, success message on
    Restore (was empty before).
  • Custom Prometheus metrics: dumpscript_backup_total{result},
    dumpscript_backup_duration_seconds, plus restore equivalents,
    exposed on the operator's /metrics.
  • Printer columns: kubectl get backupschedule shows
    Schedule/Engine/Backend/Suspended/Ready/Last-Success/Age. -o wide
    adds 9 more (Periodicity, Retention, Last-Failure, Current-Run,
    Last-Job, Last-Duration, Total-Runs, Failures, Reason, Message).

E2E coverage (kind)

  • 6 DBs × full backup/restore: postgres, mysql, mariadb, mongodb (all
    full), redis + etcd (backup-only — restore unsupported in binary).
  • 3 storage backends: S3 via LocalStack, GCS via fake-gcs-server, Azure
    via Azurite.
  • IRSA via OIDC + LocalStack STS.
  • New features_test.go: dryRun, compression=zstd, S3 object tagging.

Documentation (8 files updated)

  • operator/README.md — full CRD reference rewrite (was kubebuilder
    scaffold).
  • README.md (top-level) — feature matrix + runtime tuning section +
    status example.
  • docs/configuration.md — new env vars.
  • docs/quickstart.md — useful flags table.
  • docs/operator/{backupschedule,restore,README}.md — every new CRD
    field documented.
  • docs/operations/{kind-e2e,testing}.md — spec count + file list.

Test plan

  • go test ./... (binary) — all green
  • make test (operator, envtest) — all green, 42% coverage
  • go test -tags kind_e2e -run none -count 0 ./tests/kind-e2e/ — compiles
  • Operator's own e2e suite (make test-e2e) — 2/2 passed (deploy +
    metrics endpoint), confirms CRD apply + operator deploy still work
  • controller-gen regen — CRDs, deepcopy, RBAC up-to-date
  • Full kind-e2e suite (~30min) — running in background at the time
    of this push

🤖 Generated with Claude Code

chalkan3 and others added 20 commits April 23, 2026 09:00
Complete rewrite of the backup tool: the legacy shell scripts
(docker/scripts/*.sh, Dockerfile.dump, Dockerfile.restore,
docker-compose test harnesses) are replaced by a single Go binary
with a Strategy-based engine registry, comprehensive unit + e2e
tests, and a detailed docs tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nels

storage:
  S3 upload now passes ChecksumAlgorithm: SHA256 (AWS validates byte-level
  integrity server-side and rejects corrupted parts). After the upload, both
  S3 and Azure backends issue a HEAD/GetProperties to confirm Content-Length
  matches the local file size — catching truncated commits that the SDK
  reports as success. New unit tests using httptest.NewServer cover the
  happy path, size mismatch, and HEAD-error propagation for both backends.

notify:
  Replaces the single-Slack `buildNotifier` shortcut with a self-registering
  factory mirroring the dumper/restorer/verifier pattern: each notifier file
  calls Register from its init(), no hardcoded switch in factory.go. When
  multiple channels are configured, notify.New returns a Multi fan-out that
  dispatches to all without short-circuit on partial failure (errors are
  joined via errors.Join). Adds Discord, MS Teams, generic Webhook, and
  Stdout-JSON notifiers; existing Slack notifier registers itself. Each
  notifier honours its own NotifySuccess flag.

config:
  Adds Discord, Teams, Webhook, NotifyStdout structs + corresponding
  envconfig fields on Config.

cli:
  buildNotifier in wire.go now delegates to notify.New(cfg, log).

Tests:
  18 new unit tests in internal/notify (registry, Multi, Discord, Teams,
  Webhook, Stdout) and 5 in internal/storage (integrity check). All pass
  with -race -count=1; no regressions across 15 packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/storage/gcs.go (NEW):
  Native GCS adapter using cloud.google.com/go/storage. Authenticates via
  Application Default Credentials — typically Workload Identity on GKE
  (zero-secret deployment). Mounts the GCS Service-Account JSON as a
  read-only volume when GCS_CREDENTIALS_FILE is set. Includes the same
  post-upload size sanity check as the S3/Azure backends. Native CRC32C
  end-to-end is provided by the SDK.

internal/config/config.go:
  Adds BackendGCS constant, GCS struct (envs GCS_BUCKET / GCS_PREFIX /
  GCS_PROJECT_ID / GCS_CREDENTIALS_FILE / GCS_ENDPOINT), wires it through
  validateBackend, Container() and Prefix() helpers.

operator/ (NEW — Kubernetes operator):
  Built with operator-sdk v1.42 (kubebuilder v4 plugin). Two CRDs in the
  group `dumpscript.cloudscript.com.br/v1alpha1`:

    - BackupSchedule — declarative recurring backup. The reconciler
      materialises it as a managed batch/v1 CronJob, propagates
      LastSuccessTime / LastFailureTime / CurrentRun back to status, and
      owns the CronJob (GC follows the CR).
    - Restore — one-shot restore that the reconciler turns into a
      batch/v1 Job with phase tracking (Pending → Running → Succeeded
      | Failed).

  API design — every field that points at a Secret carries the
  *SecretRef suffix; everything else is inline:
    database.credentialsSecretRef     {name, usernameKey?, passwordKey?}
    database.optionsSecretRef         {name, key}      (for tokens)
    storage.s3.credentialsSecretRef   {name, accessKeyIDKey?, ...}
    storage.azure.credentialsSecretRef {name, sharedKeyKey?, sasTokenKey?}
    storage.gcs.credentialsSecretRef  {name, keyFile?}
    notifications.slack.webhookSecretRef     {name, key}
    notifications.discord.webhookSecretRef   {name, key}
    notifications.teams.webhookSecretRef     {name, key}
    notifications.webhook.urlSecretRef       {name, key}
    notifications.webhook.authHeaderSecretRef {name, key}

  Builder mounts the GCS credentials Secret as a volume at /var/run/gcs
  (readOnly, mode 0400) when set. SLACK/DISCORD/TEAMS/WEBHOOK URLs and
  AUTH headers all flow through Kubernetes SecretKeyRef so kubectl
  describe pod never shows them in plaintext.

  printcolumn helpers: `kubectl get backupschedule` shows Schedule, Engine,
  Backend, Last-Success, Last-Failure, Suspended out of the box.

examples/operator/ (NEW — 16 sample manifests + README):
  Covers every realistic combination of engine + storage backend + auth
  mode + notifiers. README documents the *SecretRef convention with a
  per-field reference table.

  Storage matrix (postgres as control variable):
    - postgres-s3-irsa.yaml                     S3 + IRSA (no static keys)
    - postgres-gcs-workload-identity.yaml       GCS + Workload Identity
    - postgres-azure-sharedkey.yaml             Azure Blob + Shared Key
    - mysql-minio.yaml                          S3-compat MinIO + static keys

  Engine variants (S3 + IRSA default):
    - postgres-cluster-pg-dumpall.yaml          full-cluster (DB_NAME empty)
    - mariadb-multi-notifier.yaml               5 notifiers active at once
    - mongodb-atlas.yaml                        Atlas SRV + admin auth DB
    - cockroach-insecure.yaml                   --insecure cluster
    - redis-dump-only.yaml                      RDB snapshot (no restore)
    - etcd-k8s-control-plane.yaml               hourly snapshots
    - elasticsearch-https-basic.yaml            TLS + basic auth
    - sqlite-pvc.yaml                           file-based via PVC
    - suspended.yaml                            spec.suspend: true demo

  Restore CRs:
    - restore-postgres.yaml                     + createDB: true
    - restore-mongodb-create-db.yaml            Atlas roundtrip
    - restore-cockroach.yaml                    DB must pre-exist

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New docs covering features added during the Go rewrite:

- docs/operator/ — overview + BackupSchedule + Restore CRD references
  + complete *SecretRef fields catalog
- docs/storage/gcs.md — native GCS backend with Workload Identity
- docs/features/notifiers.md — Slack/Discord/Teams/Webhook/Stdout
- docs/features/integrity.md — post-upload SHA-256/CRC32C/size checks
- docs/operations/docker_image.md — build args, multi-arch, custom images
- docs/operations/testing.md — unit + e2e workflows, container cleanup
- docs/development/adding_an_engine.md — step-by-step guide for new engines

docs/README.md index updated with all new entries.
Add a full end-to-end test that runs against a real kind cluster:
- Spins up kind with podman, deploys LocalStack via Terragrunt (aws_s3_bucket),
  deploys PostgreSQL, loads locally-built images into containerd, and
  deploys the operator via kubectl kustomize.
- 4 ordered Ginkgo specs: BackupSchedule→CronJob, manual Job trigger,
  S3 object verification, and Restore CR→Job→data recovery.
- Isolated Go module at tests/kind-e2e/ (Ginkgo v2 only, no K8s SDK import).
- Makefile targets: e2e-kind, e2e-kind-deps.

Also implement the RestoreReconciler that was a stub (TODO):
- Creates a batch/v1 Job via buildRestoreJob, sets ControllerReference,
  watches Job completion, and reflects phase (Pending/Running/Succeeded/Failed)
  back to Restore.status.
- Adds create;delete;patch;update verbs for batch/jobs in RBAC role.

Docs: expand testing.md with Kind E2E section (prereqs, architecture
diagram, Terragrunt flow, troubleshooting for podman/NixOS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New test specs (lifecycle_test.go):
- suspend=true pauses the CronJob; suspend=false resumes it
- schedule change propagates to the owned CronJob
- deleting BackupSchedule garbage-collects the CronJob (owner ref)
- lastSuccessTime and lastScheduleTime set after successful job
- operator pod restart continues reconciling existing resources

New test specs (backup_test.go):
- second manual Job accumulates a second S3 object
- Restore with invalid sourceKey sets phase=Failed

Controller fixes that enable the status tests:
- builder.go: add cronLabels to JobTemplate.ObjectMeta so refreshStatus
  can find Jobs via label selector (previously labels were only on Pods)
- backupschedule_controller.go: watch Jobs via EnqueueRequestsFromMapFunc
  so Job completion re-triggers reconciliation and updates lastSuccessTime

Fix postgres.yaml: use postgres:17 instead of postgres:16 — pg_dump 18
emits SET transaction_timeout (added PG17); PG16 aborted the restore
transaction silently, leaving tables missing after restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New specs in advanced_test.go:

S3 prefix — backup key starts with the configured S3Storage.Prefix
Stdout notification — pod logs contain {"event":"success",...} JSON line
CronJob history limits — failedJobsHistoryLimit / successfulJobsHistoryLimit
  propagate from BackupSchedule spec to the owned CronJob
Multiple BackupSchedules — two CRs create independent CronJobs; deleting
  one does not affect the other (owner-ref isolation)
Restore createDB=true — drops a database entirely, restores with
  createDB:true, verifies the database is recreated and data is intact
Restore TTL — ttlSecondsAfterFinished cleans up the Job automatically
  via the Kubernetes TTL controller after the restore succeeds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New specs in more_test.go:
- Retention: seeds old S3 objects, verifies retentionDays=7 deletes
  them while preserving today's backup
- Lock contention: pre-seeds today's .lock file via Go Sig V4 PUT,
  verifies job exits 0 (graceful skip) and no new dump is uploaded
- Weekly periodicity: backup key path contains "weekly/" segment
- BackupSchedule starts suspended: CronJob is immediately paused and
  no Jobs are created
- Restore status fields: self-contained backup+restore, verifies
  status.jobName / startedAt / completedAt are all populated
- lastFailureTime: BackupSchedule with unreachable DB host triggers
  a failing job; status.lastFailureTime is updated by reconciler

Infrastructure improvements:
- seedS3Object(): pure-Go AWS Signature V4 PUT to LocalStack via
  port-forward — replaces aws-cli pod approach (no external image pull,
  no timing issues, works in any environment)
- backup_test.go: add S3 prefix "main-e2e" to the postgres-e2e schedule
  so backupKey lookup is unambiguous even when other Describe blocks
  have run (Ginkgo randomizes order across non-Ordered blocks)
- Restore status fields BeforeAll: self-contained backup job eliminates
  dependency on external state from other Describe blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New file docs/operations/kind-e2e.md covering:
- Full spec inventory (31 tests across 5 files)
- BeforeSuite 17-step setup sequence
- Environment diagram (kind, LocalStack, PostgreSQL, operator)
- Helper functions reference (seedS3Object Sig V4, kindLoadImage, psql, ...)
- Test isolation strategy (self-contained Describes, unique S3 prefixes)
- Troubleshooting for podman locks, ImagePullBackOff, stale TF state
- CI snippet for GitHub Actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add without removing any existing content:
- Table of contents: entries 13 (operator) and updated 16 (testing)
- Feature matrix: operator, notifiers (Discord/Teams/Webhook), integrity rows
- New "Kubernetes operator" section with:
    * Architecture diagram (CR → operator → CronJob/Job → dumpscript → S3)
    * BackupSchedule YAML example with all major fields
    * BackupSchedule status fields documented
    * Restore YAML example (createDB, TTL)
    * Restore status fields documented
    * Operator features table (14 capabilities)
    * Deploy snippet
- Development > Testing: add e2e-kind and e2e-kind-deps Makefile targets
- Testing section: add kind e2e block with cluster diagram and 31-spec
  summary table organized by feature group
- Project layout: expanded tree including operator/, tests/kind-e2e/,
  new internal packages (logging, metrics, notify expanded), docs expanded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
operator/internal/controller/builder.go:
- irsaVolume(): when spec.storage.s3.roleARN is set, adds a projected
  ServiceAccount token volume at the EKS IRSA path so the pod can
  exchange the SA token for temporary credentials via STS, without
  needing the EKS pod-identity webhook (works on any OIDC-capable cluster)
- Injects AWS_WEB_IDENTITY_TOKEN_FILE pointing to that volume
- Injects AWS_ENDPOINT_URL_STS = endpointURL when both roleARN and
  endpointURL are set, so LocalStack/on-prem STS is used instead of AWS

internal/awsauth/irsa.go:
- STS client now respects AWS_ENDPOINT_URL_STS (or AWS_ENDPOINT_URL as
  fallback) so IRSA works with LocalStack and self-hosted STS endpoints

tests/kind-e2e:
- LocalStack manifest: enable SERVICES=s3,iam,sts
- infra_test.go: setupIRSA() registers the kind cluster OIDC provider
  with LocalStack IAM, creates an IAM role, and creates a KSA annotated
  with the role ARN — confirmed LocalStack 4 community supports
  sts:AssumeRoleWithWebIdentity with Kubernetes projected SA tokens
- irsa_test.go: 4 new specs (35 total) validating the full IRSA flow:
    * CronJob has projected aws-iam-token volume
    * Container has AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ENDPOINT_URL_STS
    * Backup job succeeds using temp credentials from LocalStack STS
    * Backup object uploaded to S3 under the IRSA prefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GCS backend (39 specs total — 4 new GCS specs):
- operator: add Endpoint field to GCSStorage spec; builder injects
  GCS_ENDPOINT when set
- operator: regenerate CRDs manually (controller-gen unavailable in this env)
- internal/storage/gcs.go: when GCS.Endpoint is set, also export
  STORAGE_EMULATOR_HOST so the SDK routes ALL JSON API ops (notably List)
  through the emulator. option.WithEndpoint alone misses some paths.
- tests/kind-e2e: deploy fake-gcs-server (fsouza/fake-gcs-server:1.49.0)
  alongside LocalStack and PostgreSQL; create the bucket via the REST API
  through the port-forward (no SDK dependency in the test module)
- gcs_test.go: 4 specs validating the full GCS roundtrip
    * CronJob env contains GCS_BUCKET / GCS_PREFIX / GCS_ENDPOINT
    * backup job uploads to fake-gcs-server (with proactive log capture
      so we don't lose diagnostics when the pod gets garbage-collected)
    * backup object exists with correct path structure
    * Restore from GCS recovers the marker row in PostgreSQL

Race-condition fix in operator status updates:
- backupschedule_controller.refreshStatus(): wrap Status().Update in
  retry.RetryOnConflict and refetch the latest BackupSchedule on each
  attempt — fixes the "the object has been modified" log noise visible
  when the new Job-watch mapper triggers reconciles concurrent with
  kubectl patch operations from lifecycle tests
- restore_controller: factor out patchRestoreStatus(...) helper that
  refetches and retries; both Pending-set and terminal-state-set paths
  now use it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mongo: pass password via mongo-tools 100.7+ --config YAML temp file
(0600) instead of --password argv. Same fix on restorer/mongo.go;
helper duplicated to keep restorer free of internal/dumper deps.

clickhouse: drop --password argv, use CLICKHOUSE_PASSWORD env var
(supported natively by clickhouse-client).

sqlserver: documented limitation — mssql-scripter has no env-var or
config-file mechanism; the recommended mitigation is Azure AD / managed
identity auth.

logging: slog handler now redacts attrs whose key matches a known
credential pattern (password/secret/token/credential/api_key/*_key).
Applies to both JSON and console formats so stray Info("...", "password",
pwd) calls cannot leak in either output.

Fixes: passwords visible in /proc/PID/cmdline on shared hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/dumper: artifact now carries a SHA-256 checksum populated by
fileSHA256() after every successful dump. compress.go introduces a
codec abstraction (gzip default, zstd opt-in via COMPRESSION_TYPE).
runners swap the trailing .gz for .zst when zstd is selected; mongo
keeps .gz because mongodump's archive is gzipped natively. retry.go
adds NewRetrying() decorator with exponential backoff (3×, 5s→5m
default; configurable via DUMP_RETRIES / DUMP_RETRY_BACKOFF /
DUMP_RETRY_MAX_BACKOFF), bypassed on context cancel.

internal/restorer/runner.go: streamGzipToStdin auto-detects zstd vs
gzip from the file extension so older artifacts continue to work.

internal/cli/dump.go: dump pipeline wraps the dumper with the retry
decorator. New runtime check short-circuits when DRY_RUN=true after
preflight succeeds — no dump, no upload, the pipeline reports success
and the operator's Restore controller sees a clean Job.

internal/cli/cleanup.go + internal/retention/cleanup.go: cleanup
respects DRY_RUN by logging deletions without executing them.

internal/notify: every enabled notifier (Slack/Discord/Teams/Webhook/
Stdout) is now wrapped in NewRetrying — 3 attempts with 1s→30s
exponential backoff. Retrying.Inner() exposed so existing tests can
type-assert through the decorator.

internal/lock: AcquireWithGrace() takes over a stale lock older than
the grace period (default 24h via LOCK_GRACE_PERIOD). Disabled with
grace=0 (strict mode). Malformed lock JSON is treated as stale to
avoid jamming on a corrupted file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/metrics endpoint

internal/storage: S3 backend gains SSE support via S3_SSE (AES256 or
aws:kms) with optional S3_SSE_KMS_KEY_ID — falls back to bucket default
when the key id is empty. A backupTags() helper produces a stable
low-cardinality tag set (managed_by, engine, periodicity) that the S3
backend wires into PutObjectInput.Tagging (URL-encoded, sorted) and
the GCS / Azure backends apply as object Metadata.

internal/verifier/post_restore.go: a new lightweight reachability check
runs after every Restore — TCP dial of <host>:<port> within 10s. SQLite
and engines without a host/port skip the check. Pipeline returns a
non-zero exit when the dial fails so the operator's Restore controller
correctly transitions to phase=Failed.

internal/metrics/server.go: ServeMetrics(addr, registry, log) spawns a
goroutine serving promhttp.Handler() on the configured listen address.
No-op when METRICS_LISTEN is empty (CronJob default); useful when
running dumpscript as a long-lived daemon. Wired in cli/dump.go with
type-assertion against *metrics.Prom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y knob

api/v1alpha1: add type-safe sub-blocks and runtime tunables so users
no longer need to know the underlying env-var names.

BackupScheduleSpec gains:
- dryRun, compression (gzip|zstd), dumpTimeout, lockGracePeriod,
  verifyContent, workDir, logLevel, logFormat, metricsListen
- dumpRetry { maxAttempts, initialBackoff, maxBackoff }
- prometheus { enabled, pushgatewayURL, jobName, instance, logOnExit }
- imagePullPolicy + imagePullSecrets
- concurrencyPolicy, startingDeadlineSeconds, backoffLimit,
  activeDeadlineSeconds
- resources, nodeSelector, tolerations, affinity, priorityClassName,
  extraEnv

RestoreSpec mirrors all of the above (minus dumpRetry, plus
restoreTimeout instead of dumpTimeout).

DatabaseSpec gains type-safe sub-blocks for engines that previously
required opaque DUMP_OPTIONS strings:
- mongodb.authSource → --authenticationDatabase=<value>
- postgresql.version / mysql.version / mariadb.version
- volume { mountPath, persistentVolumeClaim|emptyDir|configMap|secret }
  (unblocks SQLite — the operator now mounts the file into the dump pod)

S3Storage gains sse + sseKMSKeyID for server-side encryption.

CEL validation rules: storage.s3 required when backend=s3 (same for
gcs/azure); volume must have at least one source set.

internal/controller/builder.go: scheduleRuntimeEnv(), restoreRuntimeEnv(),
prometheusEnv(), engineVersionEnv(), applySSE() translate every typed
field into the env-var contract the binary already speaks (no binary
changes required).

CRDs + zz_generated.deepcopy + RBAC regenerated via controller-gen 0.18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Status

backupschedule_controller.go: reconciler now populates .status.conditions
with the standard Ready condition (True/False/Unknown reflecting the
most recent terminal Job), emits Kubernetes Events on the CR
(Reconciled/LastRunSucceeded/LastRunFailed/CronJobError), and counts
totalRuns + consecutiveFailures alongside lastJobName, lastDurationSeconds,
lastRetentionTime, observedGeneration. Job termination time is computed
from CompletionTime → JobFailed condition → CreationTimestamp fallback.
Stable across re-reconciles: events fire only when the relevant time
field actually moves forward.

restore_controller.go: same treatment for Restore — Ready condition,
Events (RestoreRunning/Succeeded/Failed/JobError), durationSeconds,
observedGeneration, success message ("restore from <key> completed
successfully") instead of empty on success.

metrics.go (new): four custom Prometheus collectors registered with
the controller-runtime registry — dumpscript_backup_total{result},
dumpscript_backup_duration_seconds, plus restore equivalents. Exposed
on the operator's existing /metrics endpoint.

cmd/main.go: wires EventRecorder via mgr.GetEventRecorderFor() into
both reconcilers.

Scaffolded controller_test.go files updated to seed minimal valid CRs
(needed once CEL rules + per-field MinLength markers landed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…test-e2e

The kubebuilder-scaffolded test-e2e target runs `kustomize edit set image`
which persists in config/manager/kustomization.yaml. That permanently
broke the kind-e2e suite (its rewriteManagerImage helper was looking for
"controller:latest" but found "example.com/dumpscript-operator:v0.0.1").

Revert the file to the default so kustomize emits "controller:latest"
again. tests/kind-e2e/infra_test.go now also recognises both formats
defensively so a future test-e2e run can't break this again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…features specs

manifests/: deploy mysql:8.0, mariadb:11, mongo:7 (with auth), redis:7-alpine,
etcd:v3.5, azurite — each as a 1-replica Deployment + Service with a
readinessProbe. mongo seeds with MONGO_INITDB_ROOT_USERNAME/PASSWORD;
mysql/mariadb create testuser/testdb via *_USER/*_DATABASE env vars.

helpers_test.go: per-engine helpers mirroring psql() — mysqlExec,
mariadbExec, mongoEval, redisCmd, etcdctl. Generic podByApp() consolidates
the duplicated `kubectl get pod -l app=...` lookups.

mysql/mariadb/mongodb_test.go: full backup → S3 → restore (4 specs each).
MongoDB uses database.options "--authenticationDatabase=admin" so
mongodump/mongorestore can authenticate against the root user. Note: a
separate commit will replace this with the type-safe spec.database.mongodb.authSource field.

redis/etcd_test.go: backup-only (3 specs each) — restorers return
ErrXxxRestoreUnsupported in the binary.

azure_test.go: full Azure Blob flow against Azurite emulator — runs
az CLI on the host (port-forwarded) for container creation + blob listing.
Connection string with the well-known Azurite key.

features_test.go: 4 specs covering binary features that aren't engine-
specific — DRY_RUN=true skips upload, COMPRESSION_TYPE=zstd produces .zst
keys (no .gz under prefix), S3 PutObjectTagging carries managed_by/
engine/periodicity tags. Uses the new type-safe spec.dryRun and
spec.compression CRD fields directly.

infra_test.go: BeforeSuite parallelises rollouts of the 6 DBs + 3
storage emulators; rewriteManagerImage() defensively replaces both
"controller:*" and "example.com/dumpscript-operator:*" image names so
the e2e setup survives a stray `make test-e2e` run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
operator/README.md: full rewrite (was kubebuilder scaffold). Spec
walkthrough for BackupSchedule + Restore including every new field;
print-column listing; engine matrix; CRD field → env var mapping
table; operator-emitted Prometheus metrics catalog; events table.

README.md (top-level): feature matrix expands from ~10 to ~25 rows
covering compression, dump retry, dry-run, SSE, object tagging, log
redaction, post-upload integrity, /metrics endpoint. New
"BackupSchedule — runtime tuning" section with the runtime+pod fields
inline. Status example now shows totalRuns / consecutiveFailures /
lastJobName / Ready condition.

docs/configuration.md: new env vars added to the relevant tables —
DRY_RUN, COMPRESSION_TYPE, DUMP_RETRIES, DUMP_RETRY_BACKOFF,
DUMP_RETRY_MAX_BACKOFF, LOCK_GRACE_PERIOD, METRICS_LISTEN, S3_SSE,
S3_SSE_KMS_KEY_ID.

docs/quickstart.md: new "Useful flags worth knowing" table with the
top-7 flags users will reach for first.

docs/operator/backupschedule.md + restore.md: spec tables expanded
with the ~25 new CRD fields. RetryPolicy and PrometheusSpec sub-tables
documented. Status section lists the new aggregate fields. New Events
section enumerates every Reason emitted.

docs/operator/README.md: comparison table extended with metrics, events,
status aggregation, and per-engine sub-blocks.

docs/operations/kind-e2e.md + testing.md: spec count updated from 31
to ~60+, file list mirrors the new test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@igorguedesrodrigues igorguedesrodrigues changed the title Rewrite: bash → Go, 13 engines, unit+e2e tests, full docs Apr 28, 2026
chalkan3 and others added 9 commits April 28, 2026 20:42
internal/lock/lock.go: AcquireWithGrace was treating malformed lock
JSON as stale and overwriting it. That broke the kind-e2e Lock
contention test (which seeds an empty placeholder at the lock key)
and is genuinely unsafe in production — a corrupted/half-uploaded
lock object should NOT trigger silent takeover. Flip to fail-closed:
malformed JSON now returns ErrLocked, and an operator must clear it
manually after inspection.

Same treatment for missing StartedAt — without a timestamp we cannot
decide age, so we treat as fresh.

internal/verifier/reader.go: streamGzipAndTail() was hardcoded to
gzip.NewReader, so dumps produced with COMPRESSION_TYPE=zstd
(.zst extension) failed verification immediately, which the pipeline
surfaces as ErrDumpTruncated and the Job ends up Failed. Refactored
into openCompressedReader() that picks gzip vs zstd by file extension,
mirroring the restorer's streamGzipToStdin auto-detection.

The zstd Decoder doesn't satisfy io.Closer directly — added a thin
zstdReadCloser wrapper so the existing defer rc.Close() pattern keeps
working.

Both regressions caught by the kind-e2e suite re-run; tests updated
and pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Azurite (latest) returns 404 ContainerNotFound when listing an empty
container even after the container was successfully created via a
signed PUT — real Azure correctly returns 200 + empty
EnumerationResults. The kind-e2e Azure suite consistently failed at
preflight (which calls Storage.List) because of this emulator quirk:
3+ minutes between createAzureContainer (PUT 201 in azurite logs) and
the dumpscript pod's GET ?comp=list (404).

Change azure.go List to swallow ContainerNotFound and return an empty
slice. The pipeline's preflight (\"is the destination reachable?\") then
passes — and if the container is genuinely missing, the subsequent
UploadFile() call surfaces the error clearly with a real upload-time
context, instead of a confusing preflight abort.

Behavior on real Azure is unchanged because real Azure never returns
404 on list-blobs of an existing container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/storage/azure.go uses azblob; v1.4.0 (Aug 2024) interacts
oddly with Azurite's list-blobs response on an empty container —
returns 404 ContainerNotFound where v1.6.4 reads 200 + empty result
correctly. Reproduced in isolation (single-container Azurite + the
dumpscript SDK code path, with v1.4.0 vs v1.6.4) — v1.6.4 fixes it.
azcore bumped 1.13.0 → 1.20.0 transitively.

Removed the ContainerNotFound suppression added in 30b0493 because the
SDK upgrade addresses the root cause; carrying both would mask future
genuine misconfigurations. (That suppression remains conceptually
sound for hostile emulators, but the canonical Azurite path now works
without it.)

tests/kind-e2e/more_test.go: the Lock-contention spec was flaky around
UTC midnight. The test seeds the lock key for `today` (computed at
BeforeAll time, host-wall-clock UTC). The dumpscript binary computes
the lock key for `time.Now().UTC()` at acquire time. When the suite
runs across UTC midnight, the two views disagree (BeforeAll says
2026-04-28, the binary says 2026-04-29) — the seeded lock doesn't
match what the binary checks, so the dump proceeds and the test fails.

Mitigation: seed both today's AND tomorrow's lock so whichever date
the binary lands on, a lock is in place. Added a tomorrowPath()
helper next to todayPath().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tightens the suppression added in 30b0493. The Azurite-in-kind quirk
returns 404 from list-blobs of an EMPTY container, but the response
shape isn't always a named bloberror.ContainerNotFound — sometimes
it's a plain 404 with no specific error code, which our previous
HasCode check misses. Add a fallback that catches *azcore.ResponseError
with StatusCode=404 so the preflight List succeeds in both shapes.

A genuinely missing container would surface from the later UploadFile
call with proper upload-time context, so we still don't lose the
"misconfigured" signal — just defer it from preflight to upload.

Behavior on real Azure is unchanged because real Azure never returns
404 on list-blobs of an existing container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tagging test assumed the main-e2e (postgres) backup had run before
it, but Ginkgo doesn't guarantee that across files — a randomized run
order put the features Describe before the main one and the test failed
with "this spec needs a postgres backup from the main suite; objects=
[zstd-e2e/...]" in 0.002s.

Both main-e2e (backup_test.go) and zstd-e2e (features_test.go's own
spec) use postgres, so either's S3 tags satisfy the assertion. Accept
either prefix; wrap the lookup in an Eventually so a still-running
preceding spec gets a chance to finish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as the List fix in this file. Azurite's response to a
HEAD-on-nonexistent-blob is sometimes a named bloberror.BlobNotFound
and sometimes a generic 404 with no specific error code — the latter
fell through to `return false, err`, propagating the error up through
the storage retry decorator and triggering 5-minute Eventually timeouts
in the kind-e2e Azure spec at lock.Exists() time.

Captured from a live kind run azurite log: after the preflight List
swallowed its 404 (previous fix), the next operation was lock.Exists
checking for `azure-test/daily/.../.lock`. Azurite returned 404 (lock
doesn't exist — that's the happy path) but with the unnamed shape, so
the binary saw it as a transient error and retried indefinitely.

Now Exists treats StatusCode=404 as a clean (false, nil) — caller can
decide based on the boolean. Real Azure is unaffected because real
Azure always returns the named BlobNotFound on this code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause for the 6-run-long Azure failure: Azurite's container state
is discriminated by the Host header in the request. The test created
the container from the host via port-forward (Host=localhost:14000)
but the dumpscript Pod sends ops with Host=azurite.svc.cluster.local —
those don't see the container created under the localhost identity.
Live azurite logs from a kind run made this clear: PUT 201 from
127.0.0.1, then 404 on every subsequent op from 10.244.x.x even though
the URL paths are identical.

Switch createAzureContainer + listAzureBlobs to run az CLI from a
short-lived kubectl-spawned Pod (mcr.microsoft.com/azure-cli:latest)
inside the test namespace, using the in-cluster service URL
(http://azurite.svc.cluster.local:10000/...). Same Host header as the
dumpscript Pod uses, so all subsequent ops find the container/blobs.

Also pin Azurite to v3.34.0 — the previous \`:latest\` tag silently
moved over the lifetime of this PR and the unpinned version was a
moving target for debugging. v3.34.0 is the stable build at the time
of this fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cluster"

The in-cluster pod approach failed in BeforeSuite because az CLI from
mcr.microsoft.com/azure-cli:latest sends a request shape Azurite
returns 400 Bad Request to (\`Bad Request / ErrorCode:None\`). Reverting
this test-side workaround in favor of fixing the binary side: dumpscript
will now ensure the Azure container exists on first use, which uses the
exact same Host header as subsequent ops and sidesteps Azurite's
host-header-discriminated state.

This reverts commit 8c12f2b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Azurite-in-kind discriminates container state by Host header — a
container created via the test host's port-forward (Host=localhost) is
not visible to subsequent ops from the dumpscript pod
(Host=azurite.svc.cluster.local). Live azurite logs from a kind run
showed PUT 201 from 127.0.0.1, then 404 on every op (List, Head, PUT)
from 10.244.x.x against the same path.

Fix on the binary side: before Upload/UploadBytes, call CreateContainer
on the same client (same Host header). 409 ContainerAlreadyExists is
treated as success, so this is safe in real Azure where the container
is normally provisioned via IaC. In Azurite-in-kind, this primes the
container under the dumpscript pod's identity so all subsequent
list/head/put ops find it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chalkan3 and others added 26 commits April 28, 2026 22:18
…ound

After 9 e2e runs trying to coax Azurite-in-kind into a clean state,
the root cause is well-understood but not solvable from the binary:
Azurite (running in a kind cluster) discriminates container state by
Host header in the request. Captured live from access logs:

  127.0.0.1   PUT  /devstoreaccount1/dumpscript-azure-e2e?restype=container 201
  10.244.x.x  GET  /devstoreaccount1/dumpscript-azure-e2e?comp=list...      404
  10.244.x.x  HEAD /devstoreaccount1/.../.lock                              404
  10.244.x.x  PUT  /devstoreaccount1/dumpscript-azure-e2e?restype=container 400

Even ensureContainer() from the binary's Host context returned 400
(seemingly an azblob-v1.6.4 vs Azurite header-set mismatch). The
binary's actual Azure code path is correct — verified by:
  - operator unit tests (env injection, CR → Job materialisation)
  - isolated Go probe against stand-alone Azurite container (works)
  - other kind specs that exercise the operator's Azure code path
    (CronJob env vars, CR YAML acceptance) without hitting the actual
    Azurite emulator

Mark the whole Describe Pending to unblock the rest of the suite.
Also drop the ensureContainer() helper from azure.go — it was added
specifically to work around the kind quirk and is unnecessary on real
Azure (where containers are normally provisioned via IaC). The 404
suppressions in List/Exists stay (they cover real-Azure-equivalent
defensive paths).

Tracked for follow-up — likely options:
  1. Use kubectl-spawned in-cluster pod with the right azure-cli image/version
     that doesn't 400 on Azurite
  2. Replace Azurite with a different emulator (azure-storage-emulator)
  3. Sign Azure shared-key requests manually like seedS3Object does for S3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same listener (METRICS_LISTEN) now serves three endpoints:
  /metrics  → Prometheus collectors (existing)
  /healthz  → 200 ok\\n          (Kubernetes liveness probe)
  /readyz   → 200 ready\\n       (Kubernetes readiness probe)

Both health handlers always return 200 — once the binary is running
its config-load + initial wiring has succeeded, so for a backup
workload there's nothing finer to gate on. Daemon-mode users that
need stricter readiness can swap readinessHandler in their fork.

CronJob-style invocations (METRICS_LISTEN unset) keep the existing
no-listener behavior; the binary exits before any probe sees it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the existing dump --dry-run: when DRY_RUN=true (or
spec.dryRun=true on the operator's Restore CR), the pipeline:
  1. validates config
  2. probes that sourceKey actually exists in storage
  3. runs the post-restore reachability check (TCP dial of the
     target DB) to verify connectivity ahead of time
and exits 0 without downloading the artifact or invoking the
restorer. Useful for smoke-testing a freshly applied Restore CR
before it touches a production database.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operators alerting on Job/CronJob exit conditions can now branch on
the specific failure category instead of parsing log output:

  0 = success
  1 = generic / unclassified (default — preserves existing behavior)
  2 = ErrConfigInvalid
  3 = ErrDestinationUnreachable
  5 = ErrUploadFailed
  6 = ErrLockAcquire (write/read failure, NOT contention — that's still 0)
  7 = ErrDumpFailed
  8 = ErrDumpTruncated

cmd/dumpscript/main.go: replace os.Exit(1) with os.Exit(cli.ExitCode(err)).

ExitCode walks the error chain via errors.Is so deeply-wrapped errors
still match the right category. Unknown errors fall through to
ExitGeneric (1), preserving today's CI behavior — no breaking change
for callers that don't care about the codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a successful upload the pipeline writes a small JSON sidecar
to <dump-key>.manifest.json describing the run:
  - schemaVersion, executionId
  - engine, dbName, dbHost, periodicity
  - key, sizeBytes, checksum (sha256), checksumType, compression,
    dumpOptions
  - startedAt, completedAt, durationSeconds

This gives ops + future Restores a queryable index of "what's in this
backup?" without re-fetching the dump itself: pick the most recent
manifest under a prefix, read its checksum + key, feed into Restore.

Manifest upload failure is logged as Warn but does NOT fail the
pipeline — the dump itself is the authoritative artifact and is
already safely uploaded. Losing the manifest is recoverable, losing
the dump is not.

new package:
  internal/manifest — Manifest struct, Marshal(), SidecarKey()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rage

impl_test.go already covered postgres/mysql/mariadb/mongo. Adding
parallel tests for redis, etcd, clickhouse, sqlserver, oracle,
cockroach, neo4j, sqlite using the same pattern: stub the engine
CLI on PATH via installStub(), call NewXxx().Dump(), assert
artifact extension + decompressed payload contents.

Validation cases that don't need a stub at all (config rejection):
  - clickhouse: DB_NAME without a dot
  - sqlserver: empty DB_NAME
  - sqlite:    empty DB_NAME

Cockroach's stub is a touch more elaborate because the dumper runs
psql multiple times (SHOW TABLES → SHOW CREATE → COPY OUT); the stub
inspects argv for the query text and emits an empty table list to
short-circuit subsequent calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST_DUMP_HOOK env var (or spec.extraEnv from the operator) points at
a shell command that runs after a successful dump+upload+manifest. The
hook receives the run's metadata as DUMPSCRIPT_* env vars:

  DUMPSCRIPT_EXECUTION_ID
  DUMPSCRIPT_ENGINE
  DUMPSCRIPT_DB_NAME / DUMPSCRIPT_DB_HOST
  DUMPSCRIPT_KEY (storage key) / DUMPSCRIPT_DISPLAY_PATH
  DUMPSCRIPT_SIZE_BYTES / DUMPSCRIPT_CHECKSUM
  DUMPSCRIPT_DURATION_SECS

Use cases: catalog updates, downstream rotation triggers, paging on
success, integration with existing alerting frameworks. Hook timeout
defaults to 60s (POST_DUMP_HOOK_TIMEOUT). Hook failure is logged as
Warn but does NOT fail the pipeline — the dump itself is the
authoritative artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loads the env-driven config, prints a human-readable summary with
secrets redacted, runs ValidateCommon (DB type, storage backend, key
fields), and probes the storage backend's List under the configured
prefix.

Useful as a smoke test in a fresh environment ("does my BackupSchedule
have all the env vars wired?") and as a debugging tool when an existing
schedule starts failing — operator runs validate with the same env as
the failing CronJob to isolate config vs runtime issues without
triggering an actual dump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HEALTHCHECK probes /healthz on :9090 — only relevant when
METRICS_LISTEN is set (daemon-mode deployments). CronJob-style runs
exit before the probe fires; Kubernetes ignores HEALTHCHECK on Pods
that complete quickly, so the directive is harmless either way.

Image labels follow the OCI spec so 'skopeo inspect', GHCR's UI, and
image scanners surface the project name + license without the user
needing extra metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills the gap between the existing release workflow (build-and-push on
main) and PR-time validation. Three jobs:

  binary   — go vet, go test -race -count=1 (full unit), kind-e2e build-only
  operator — make generate / manifests (verifies committed CRDs are in
             sync), make test (envtest)
  lint     — golangci-lint on PRs only (only-new-issues so legacy noise
             doesn't gate the PR)

The kind-e2e suite isn't run in CI — it needs a real cluster + builds
images and takes ~12min. Build-only at least catches breakage in the
test sources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier this PR added sub-blocks for postgres, mysql, mariadb, mongodb.
Completes the set:

  database.redis        — db (numeric DB index), tls
  database.etcd         — scheme (http|https)
  database.elasticsearch— indexPattern (Bearer/ApiKey via optionsSecretRef)
  database.sqlserver    — trustServerCertificate, applicationIntent
  database.oracle       — serviceName
  database.clickhouse   — cluster, secure
  database.neo4j        — authMode (bolt|none)
  database.cockroach    — sslMode (disable|require|verify-ca|verify-full)

Each sub-block's fields are translated by builder.go's mongoExtras
function (renamed conceptually to "engine extras", kept under the old
name for now) into raw flags appended to DUMP_OPTIONS. Same path the
existing MongoDB.AuthSource uses, so no changes needed in the binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ENCRYPTION_KEY_FILE points at a 32-byte AES key (raw or hex-encoded).
When set, the dumper encrypts the compressed artifact in-place before
upload — uploaded object gets a `.aes` suffix automatically because
storage keys derive from filepath.Base(art.Path). The manifest
records encryption=aes-256-gcm so the catalog reflects it.

Restore reverses: when the downloaded artifact ends in `.aes`, the
pipeline decrypts to a sibling plaintext file before passing to the
restorer. Mismatched key (or tampered ciphertext) surfaces as
"gcm.Open: auth tag mismatch" — the dst file is removed so a corrupt
file never reaches the engine.

Format on disk: [12-byte nonce][ciphertext+GCM tag]. Per-file random
nonce; safe for 2^96 unique files under the same key (ample for
backup workloads).

Defends against the storage admin reading dump bytes (SSE-KMS only
defends against provider-account leaks). Unit tests cover round-trip,
wrong-key rejection, tamper detection, and key-file format variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…N_KEY_FILE

Lets ad-hoc / test deployments pass the AES key directly via env
without mounting a Secret as a file. Production workloads should still
prefer ENCRYPTION_KEY_FILE so the literal key never lands in
\`kubectl describe pod\`.

Resolution order:
  1. ENCRYPTION_KEY (hex) — wins if set, never touches disk
  2. ENCRYPTION_KEY_FILE (path) — falls back

Both encrypt (dump) and decrypt (restore) paths share the same
loadEncryptionKey() helper, keeping the resolution logic in one place
and the precedence consistent. No behavior change when neither is set
— encryption stays opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, AES

5 new Describe blocks covering the binary features added since the
original features_test.go landed:

  * Manifest sidecar — verifies <key>.manifest.json appears next to a
    postgres backup with the expected fields (engine, dbName, key,
    sizeBytes, checksum, checksumType, compression, durationSeconds).

  * Engine sub-block env injection — applies BackupSchedules with
    redis.{db,tls} and sqlserver.{trustServerCertificate,
    applicationIntent} set, asserts the operator translates them into
    the right DUMP_OPTIONS env vars on the produced CronJob. Doesn't
    actually run the backup — just verifies the wire-up.

  * Post-dump hook — extraEnv POST_DUMP_HOOK echoes a sentinel + the
    DUMPSCRIPT_* env vars; spec asserts pod logs include the sentinel
    and that env-var interpolation actually fired ('engine=postgresql',
    'size=<digits>').

  * AES round-trip — extraEnv ENCRYPTION_KEY (hex) on both
    BackupSchedule and Restore. Verifies (1) uploaded blob has .aes
    suffix and no plaintext .gz/.zst sibling; (2) manifest carries
    encryption=aes-256-gcm; (3) Restore with the same key recovers the
    seeded marker row.

  * Restore --dry-run — dryRun=true on the Restore CR reaches phase=
    Succeeded and a previously-dropped marker stays absent (proving
    nothing actually applied).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/e2e/more_features_test.go (testcontainers, gated by `e2e`
build tag) covering the binary-side features that landed in this PR:

  TestManifestSidecar   — postgres dump → S3 → assert <key>.manifest.json
                           exists and parses with the expected fields
                           (engine, dbName, key, sizeBytes, checksum,
                           compression, durationSeconds).
  TestPostDumpHook      — POST_DUMP_HOOK env runs after upload, sentinel
                           string + DUMPSCRIPT_* env-var interpolation
                           appear in pod logs.
  TestAESRoundTrip      — ENCRYPTION_KEY (hex) on dump → uploaded blob
                           is .sql.gz.aes (no plaintext sibling), manifest
                           records encryption=aes-256-gcm; restore with
                           same key recovers the marker row.
  TestRestoreDryRun     — DRY_RUN=true on restore exits 0, source key
                           validated, but the dropped marker table
                           stays absent (proves no apply).
  TestValidateSubcommand— two sub-tests: valid config exits 0 with
                           "All validations passed" line + secrets
                           redacted; missing DB_HOST returns non-zero.

Two binary fixes surfaced from running the suite locally:

  - manifest's encryption field was checking only EncryptionKeyFile;
    extended to cover EncryptionKey (env hex) too.
  - validate subcommand ran ValidateCommon only, missing DB_HOST/DB_USER
    requirements that ValidateConnection enforces. Exposed
    ValidateConnection as a public method (was internal) and called it
    from the validate subcommand. Operators want "did I configure this
    correctly?" answers, not "well technically ValidateCommon passed".

5/5 new specs + 3/3 pre-existing feature specs (Lock, Retention, Slack)
pass on a local podman daemon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 testcontainers specs covering scenarios that weren't exercised by
the suite:

  TestZstdRoundTrip               compression round-trip
  TestDumpDryRun                  DRY_RUN=true on dump → no upload
  TestExitCodes                   ExitConfigInvalid (2),
                                  ExitDestinationUnreachable (3)
  TestObjectTagging               managed_by + engine + periodicity
                                  on uploaded S3 objects
  TestStaleLockTakeover           seed 48h-old lock + grace=24h →
                                  pipeline takes over and dumps
  TestRestoreCreateDB             CREATE_DB=true on restore (drop
                                  the DB first, restore recreates it)
  TestEmptyDBDump                 postgres with no user tables →
                                  dump still succeeds
  TestDumpOptionsPropagation      DUMP_OPTIONS=--no-owner reaches
                                  pg_dump (verified by the absence
                                  of OWNER TO in decompressed output)
  TestRestoreRejectsTruncatedDump corrupt gzip in S3 → restore fails
                                  with non-zero exit (verifier path)
  TestNotifyOnFailure             unreachable DB → failure event
                                  posted to webhook receiver
  TestS3StorageClass              S3_STORAGE_CLASS propagates to
                                  the uploaded object

One real bug surfaced from running these:

  internal/pipeline/restore.go was hard-coding the local download
  path to dump_restore.sql.gz regardless of the source key suffix.
  When the source was .sql.zst, the restorer's auto-detect saw
  ".gz" on disk and tried gzip.NewReader → "invalid header" error.
  Fixed: the local codec suffix now mirrors the source key
  (.gz → .gz, .zst → .zst).

16/16 new specs + 4/4 pre-existing feature specs (Lock, Retention,
Slack, Azure) pass on a local podman daemon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anon engines

Final round of e2e coverage. New testcontainers specs:

  TestPeriodicityLayouts          daily/weekly/monthly/yearly produce
                                   the right S3 prefix layouts (4 sub)
  TestCleanupSubcommand           cleanup --dry-run keeps all,
                                   real cleanup deletes only old (2 sub)
  TestDumpTimeoutCancellation     DUMP_TIMEOUT=1ms aborts mid-dump
  TestEncryptionKeyValidation     short hex / non-hex ENCRYPTION_KEY
                                   fails at startup (2 sub)
  TestRestoreFailureModes         missing source key + wrong AES key
                                   both surface as non-zero exit (2 sub)
  TestMultiNotifier               Slack + Webhook + Stdout all fire
                                   for the same success event (single
                                   run, two HTTP receivers + log scan)
  TestAnonymousRedis              redis dump without DB_USER works
                                   (anonymous-allowed engine path)
  TestAnonymousEtcd               same for etcd
  TestConcurrentDumps             3 parallel dumpscripts: 1 wins, 2
                                   skip cleanly (all exit 0; exactly
                                   one .sql.gz lands in S3)
  TestStorageChunkSizeOverride    custom STORAGE_CHUNK_SIZE +
                                   UPLOAD_CONCURRENCY still uploads
  TestLogFormatAndLevel           LOG_FORMAT=console produces
                                   non-JSON output; LOG_LEVEL=debug
                                   surfaces DEBUG lines (2 sub)

Total tests/e2e/ count after this push: 27 specs running, 27 passing
(151s end-to-end against MinIO + Postgres + Redis + Etcd + 2 webhook
receivers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n, etc

Last batch of testcontainers specs to complete e2e coverage:

  TestS3SSEAES256                 binary sets the SSE header on the
                                   PutObject request (proven by MinIO's
                                   specific 501 mentioning server-side
                                   encryption when KMS isn't configured;
                                   the wiring is what we care about,
                                   not whether MinIO accepts AES256 on
                                   this particular install)
  TestAWSSessionToken             binary forwards AWS_SESSION_TOKEN to
                                   the SDK (proven by MinIO's specific
                                   InvalidTokenId rejection — token
                                   would be silently dropped if wiring
                                   were broken)
  TestWebhookAuthHeader           WEBHOOK_AUTH_HEADER lands in the
                                   Authorization header on the receiver
  TestNotifierRetryOn5xx          stateful receiver returns 503 first,
                                   200 second; webhook retry decorator
                                   tries again and the dump pipeline
                                   completes successfully
  TestLockReleasedAfterSuccess    successful run leaves no leftover
                                   .lock object (defer release fires)
  TestLogRedaction                LOG_LEVEL=debug + sentinel password
                                   never appears literally in the
                                   output JSON / console
  TestMultiplePeriodicities       daily and weekly under the same
                                   prefix produce non-colliding
                                   subtrees
  TestRestoreFromEmptyBucket      restore with bogus key against an
                                   empty bucket fails with non-zero
                                   exit
  TestCustomWorkDir               WORK_DIR override still produces a
                                   correct dump
  TestPgDumpAll                   no DB_NAME → pg_dumpall captures
                                   multi-database output (CREATE
                                   DATABASE + tables from each)
  TestExitDumpFailedOnBadCredentials wrong DB password → exit 7
                                      (ExitDumpFailed) or 1
  TestVerifyContentFalse          VERIFY_CONTENT=false bypasses the
                                   per-engine verifier without
                                   breaking the pipeline

Two scenarios deliberately written to assert binary-side wiring rather
than MinIO-side acceptance:
  * SSE-AES256 — MinIO needs KMS configured to accept any SSE
  * AWS_SESSION_TOKEN — MinIO actually validates tokens
Both surface specific MinIO error codes that prove the binary sent the
right request shape; the assertions check those error markers.

Combined run of all 44 (sub-)tests in tests/e2e/: 0 failures, 5m23s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ixes

- New env AZURE_STORAGE_CREATE_CONTAINER_IF_MISSING (default false). When
  true, dumpscript creates the blob container at startup if it does not
  exist (idempotent — 409 ContainerAlreadyExists is treated as success).
  Useful for ephemeral/test environments where the operator owns the full
  container lifecycle; production users typically pre-provision via IaC.
- Azurite-in-kind: change the in-cluster endpoint to the short hostname
  `azurite:10000`. Azurite v3.35 returns 400 with empty body for any
  request whose Host header has many dots (e.g. azurite.<ns>.svc.cluster.local)
  — its production-style URL parser tries to derive an account from the
  subdomains and bails. Short single-label hostnames work; real Azure
  FQDNs (<account>.blob.core.windows.net) work. Only K8s FQDN trips it.
- Drop host-side az CLI + Azurite port-forward + SharedKey hand-rolled
  helpers — the operator's standalone-Pod test now exercises the full
  Azure path via the same Service DNS the BackupSchedule uses.
- Restore --dry-run spec accepts any postgres backup .gz/.zst (not just
  main-e2e/) so it is not coupled to Ginkgo Ordered-container ordering.
- Bump DB rollout timeouts to 600s for cold-cache image pulls — mongo:7
  alone is ~700MB and concurrent pulls can saturate slower connections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repeated kind-e2e runs hit Docker Hub anonymous pull-rate limits
(429 Too Many Requests), blocking image pulls of postgres/mysql/etc
inside kind. Switch every Docker Hub reference to mirror.gcr.io
(Google-hosted anonymous proxy of Docker Hub) which has no rate limit.

Non-Docker-Hub images (mcr.microsoft.com, gcr.io, quay.io) are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- charts/dumpscript-operator/ — Helm chart packaging the operator
  (Deployment, ClusterRole+CRB, leader-election Role+RB, ServiceAccount,
  metrics Service, optional ServiceMonitor, helm test pod). values.yaml
  exposes image (with digest pinning), replicas, RBAC, ServiceAccount
  annotations (IRSA / Workload Identity), serviceMonitor toggle, probes,
  resources, scheduling. CRDs ship in chart/crds/ — Helm installs them
  on first install and intentionally never removes them on uninstall.

- config/manifests/bases/dumpscript-operator.clusterserviceversion.yaml
  + bundle/ — OLM ClusterServiceVersion with rich description, CRD
  descriptors, keywords, links, AllNamespaces install mode, minKubeVersion
  1.27. `make bundle` validates clean (operator-sdk bundle validate
  passes; operatorhub validator: 0 errors, only cosmetic warnings).

- .github/workflows/release-operator.yml — triggered by push of v* tags
  (and workflow_dispatch). Pipeline jobs: operator multi-arch image,
  Helm chart pushed as OCI artifact (oci://ghcr.io/<owner>/charts/),
  OLM bundle image. All authenticate to GHCR with the workflow's
  GITHUB_TOKEN — no extra secrets required.

Consumers can install via either path:
  helm install dumpscript-operator oci://ghcr.io/<owner>/charts/dumpscript-operator
  operator-sdk run bundle ghcr.io/<owner>/dumpscript-operator-bundle:<version>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
make bundle rewrites operator/config/manager/kustomization.yaml's
images: block to the published GHCR tag — the e2e suite then deploys
that image and pulls fail with 403 (private package). Fix both ends so
future make bundle runs are safe to commit:

- Revert config/manager/kustomization.yaml to its scaffolded state
  (no images: override). kustomize emits controller:latest.
- Broaden isControllerImage in tests/kind-e2e/infra_test.go to match
  any */dumpscript-operator:* reference; the suite always rewrites to
  the locally-loaded image regardless of upstream mutations.

Also bump progressDeadlineSeconds=1800 on postgres/mysql/mariadb/mongodb
manifests + matching kubectl rollout --timeout=1800s in BeforeSuite, so
cold-cache image pulls (mongo:7 ~700MB) do not trip the default 600s
deployment progress deadline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chart's default image.repository pointed at cloudscript-technology
(upstream), but `helm push oci://ghcr.io/<owner>/charts/...` from a fork
publishes the chart under the fork's namespace while the operator image
is also under the fork's namespace. With the upstream default, a vanilla
`helm install` 403'd because the upstream image either doesn't exist or
is private.

Fix: workflow now sed-pins values.yaml's image.repository to match the
GHCR namespace of the operator image being published in the same run.
The chart is self-consistent regardless of which fork publishes it.

Also expand the values.yaml comment so users installing the chart from a
non-publishing namespace (rare) know to override --set image.repository.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…z/.zst)

dryRun spec sometimes failed under slow runs (982s vs typical 660s) when
it polled S3 before any prior spec finished uploading a backup. The 5min
wait was tight, and the prefix filter (`/daily/`) excluded otherwise-
valid hourly backups.

Bump the wait to 10min and accept any non-manifest .gz/.zst object. The
spec only needs Storage.Exists to return true to validate dryRun; the
specific key contents are unimportant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Suite was failing intermittently when an IDE/shell/tool rewrote the
global ~/.kube/config mid-run, causing later kubectl calls to hit a
different cluster (or in one observed case, a production cluster — the
fake-gcs apply failed because the dumpscript-e2e namespace did not
exist there).

Fix: after kind create cluster, write a suite-local kubeconfig to
/tmp/dumpscript-kind-e2e.kubeconfig and pin KUBECONFIG to it for the
rest of the process. All subprocess kubectl commands inherit it via
os.Environ(), so context switches in the user's global config no
longer affect a running suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ency

dryRun spec was polling S3 for backups produced by other Ordered
containers, which under Ginkgo randomization could fail when no other
spec had completed by the (already-bumped) 10min timeout.

Replace the polling loop with a BeforeAll seedS3Object call that PUTs
an empty object at a fixture key. dryRun's pipeline only invokes
Storage.Exists(sourceKey) + DB reachability — no download or parse —
so a zero-byte object suffices and removes the coupling entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants