Skip to content

feat(kafka): add topic-data backup/restore example and e2e roundtrip - #3580

Open
Andrey Kolkov (androndo) wants to merge 8 commits into
mainfrom
worktree-kafka-backup-example
Open

feat(kafka): add topic-data backup/restore example and e2e roundtrip#3580
Andrey Kolkov (androndo) wants to merge 8 commits into
mainfrom
worktree-kafka-backup-example

Conversation

@androndo

@androndo Andrey Kolkov (androndo) commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds a worked backup/restore example for a Cozystack-managed Kafka application's topic data, built on the generic Job backup strategy — the same pattern as examples/backups/nats, with no purpose-built backup image. A stock Strimzi Kafka image (the kafka-*.sh CLI plus bash, curl and tar) runs one shell script that branches on {{ .Mode }}: on backup it freezes each partition's end offset and drains [begin, end) to a tarball it PUTs to S3; on restore it GETs the tarball, recreates the topics with their original partition count and replays every partition file. S3 access is curl --aws-sigv4, so no extra client image is needed.

The consistency model is a frozen-end-offset cut: kafka-get-offsets --time -1 is read once per partition at backup start and the consumer drains only up to that offset, so anything produced during the run is excluded and every partition is captured as of the same instant — a cut by log position, independent of record timestamps.

This is deliberately a DATA-only backup. Topic configs, ACLs, SCRAM users and consumer-group offsets are not captured, and re-produced records receive fresh offsets — the README spells out the scope and points at a volume-snapshot strategy for a faithful, offset-preserving backup. Keyed records reproduce into their original partition because the topic is recreated with the same partition count and the default (murmur2) partitioner is deterministic.

Also adds a kafka-2-backup-roundtrip Chainsaw test to hack/e2e-chainsaw/kafka, which drives the example run-all.sh as its harness (Bucket → source Kafka + a seeded 3-partition topic → BackupJob → in-place restore → to-copy restore + verify) so the test and the documented flow cannot drift, mirroring mariadb-2-backup-roundtrip. It resolves the Kafka image from the running operator so the backup Pod reuses the image already cached on the nodes.

Verified end to end on a live single-broker cluster: the freeze cut captured orders:0 [0,8), orders:1 [0,11), orders:2 [0,11) = 30 records; both the in-place restore and a to-copy restore into a freshly-provisioned empty cluster returned 30 records with the original 8/11/11 partition distribution preserved.

Files:

  • examples/backups/kafka/ — 11 files (00-helpers.sh, 01..07, run-all.sh, cleanup.sh, README.md).
  • hack/e2e-chainsaw/kafka/chainsaw-test.yaml — new kafka-2-backup-roundtrip Test appended.

No package sources (values.yaml, values.schema.json, Chart.yaml, package README.md) are touched, so make generate produces no diff.

Screenshots

N/A — no UI changes.

Downstream repositories

Walked the trigger map in docs/agents/contributing.md file-by-file against the diff. The change adds example scripts under examples/backups/kafka and appends one Chainsaw Test to an existing file under hack/e2e-chainsaw/kafka — it adds no package under packages/apps or packages/extra, changes no CRD, no ApplicationDefinition, no values.schema.json, no platform/installer values, no hack/*.mk anchor or make-target behaviour, and no node prerequisites in hack/e2e-prepare-cluster.bats. None of the downstream triggers match.

Release note

feat(kafka): add a topic-data backup/restore example and e2e round-trip on the generic Job backup strategy (data-only: topic records with a frozen-end-offset consistency cut and partition-preserving replay; consumer offsets, topic configs, ACLs and users are out of scope)

Summary by CodeRabbit

  • New Features

    • Added a complete Kafka topic backup and restore workflow using S3 storage.
    • Supports in-place restoration and restoration to a separate Kafka cluster, including topic recreation and record replay.
    • Added automated provisioning, validation, cleanup, and an all-in-one execution workflow.
    • Added readiness checks, backup verification, and restored message-count validation.
  • Documentation

    • Added setup instructions, workflow steps, configuration guidance, limitations, and image requirements.
  • Tests

    • Added end-to-end coverage for Kafka backup and restore round-tripping.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/feature Categorizes issue or PR as related to a new feature labels Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a complete Kafka topic-data backup and restore example. The workflow provisions Kafka and S3 resources, stores frozen-offset archives, restores data in place or to another cluster, documents each step, and validates the round trip with Chainsaw.

Changes

Kafka backup and restore workflow

Layer / File(s) Summary
Shared runtime and demo provisioning
examples/backups/kafka/00-helpers.sh, examples/backups/kafka/03-create-bucket.sh, examples/backups/kafka/04-create-kafka.sh
Adds shared Bash helpers, Kubernetes polling, Kafka CLI execution, topic seeding, record counting, S3 credential caching, and Kafka and bucket provisioning.
Backup strategy and class
examples/backups/kafka/01-create-strategy.sh, examples/backups/kafka/02-create-backupclass.sh
Adds the Job strategy for frozen-offset Kafka exports, S3 archive transfer, manifest-based topic restoration, resource limits, security settings, and the related BackupClass.
Backup and restore execution
examples/backups/kafka/05-create-backupjob.sh, examples/backups/kafka/06-restore-in-place.sh, examples/backups/kafka/07-restore-to-copy.sh, examples/backups/kafka/cleanup.sh, examples/backups/kafka/run-all.sh
Adds backup submission, in-place restore, to-copy restore, record-count validation, cleanup, and sequential execution workflows.
Documentation and end-to-end validation
examples/backups/kafka/README.md, hack/e2e-chainsaw/kafka/chainsaw-test.yaml
Documents the consistency model, limitations, commands, and image configuration. Adds a Chainsaw round-trip test with diagnostics and unconditional cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant BackupJob
  participant Kafka
  participant S3
  participant RestoreJob
  Operator->>BackupJob: create backup
  BackupJob->>Kafka: capture bounded topic records
  BackupJob->>S3: upload archive
  S3-->>RestoreJob: provide archive
  RestoreJob->>Kafka: recreate topics and replay records
  Kafka-->>Operator: report restored record count
Loading

Suggested labels: area/testing, area/storage

Suggested reviewers: lllamnyp

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a Kafka topic-data backup and restore example with an end-to-end roundtrip test.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-kafka-backup-example

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
examples/backups/kafka/03-create-bucket.sh (1)

62-68: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Quote the cached values.

The cache writes each value unquoted. create_s3_secret sources this file, so a credential that contains a space, quote, $, or ; breaks the assignment or executes shell syntax. Use printf %q to emit safe literals.

♻️ Proposed shell-safe cache write
-cat > "$SCRIPT_DIR/.bucket-info.env" <<ENV
-export S3_ACCESS_KEY=${S3_ACCESS_KEY}
-export S3_SECRET_KEY=${S3_SECRET_KEY}
-export S3_ENDPOINT=${S3_ENDPOINT}
-export S3_REGION=${S3_REGION}
-export S3_BUCKET=${S3_BUCKET}
-ENV
+: > "$SCRIPT_DIR/.bucket-info.env"
+for v in S3_ACCESS_KEY S3_SECRET_KEY S3_ENDPOINT S3_REGION S3_BUCKET; do
+    printf 'export %s=%q\n' "$v" "${!v}" >> "$SCRIPT_DIR/.bucket-info.env"
+done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/03-create-bucket.sh` around lines 62 - 68, Update the
.bucket-info.env cache generation in the heredoc to emit shell-safe quoted
literals for every S3 value, using printf %q before writing S3_ACCESS_KEY,
S3_SECRET_KEY, S3_ENDPOINT, S3_REGION, and S3_BUCKET. Keep create_s3_secret’s
sourcing behavior unchanged while ensuring values containing spaces, quotes,
dollar signs, or semicolons remain data.
examples/backups/kafka/00-helpers.sh (1)

55-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a terminal-failure phase to wait_for_field.

wait_for_field only compares against desired. If a BackupJob or RestoreJob reaches Failed, the loop keeps polling until the timeout expires. Steps 05, 06 and 07 therefore stall for 600s on every failure. The etcd example passes an extra failure phase (examples/backups/etcd/04-create-backupjob.sh), and the comment in hack/e2e-chainsaw/kafka/chainsaw-test.yaml (Lines 192-193) already claims fail-fast behavior that this helper does not implement.

♻️ Proposed fail-fast argument
 wait_for_field() {
     local resource_type="$1"
     local resource_name="$2"
     local jsonpath="$3"
     local desired="$4"
     local namespace="${5:-}"
     local timeout="${6:-300}"
+    local failed="${7:-}"
@@
         if [[ "$current" == "$desired" ]]; then
             log_success "$resource_type/$resource_name reached '$desired'"
             return 0
         fi
+        if [[ -n "$failed" && "$current" == "$failed" ]]; then
+            log_error "$resource_type/$resource_name reached terminal failure phase '$current'"
+            return 1
+        fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/00-helpers.sh` around lines 55 - 82, Update
wait_for_field to accept an optional terminal-failure value in addition to
desired, and return failure immediately when the current field matches it.
Preserve existing success and timeout behavior, and keep the argument optional
so current callers remain compatible while Kafka BackupJob/RestoreJob steps can
pass Failed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/backups/kafka/01-create-strategy.sh`:
- Around line 193-202: Update the Fidelity bullet in the Kafka backup README to
explicitly state that null-key records are replayed with the literal
four-character key “null” rather than a null key, in addition to the existing
partition-change caveat.
- Around line 120-124: Update the s3() wrapper to stop passing AWS credentials
via curl’s --user command-line argument; provide them through a curl
configuration file on stdin while preserving the existing SigV4, TLS, and
forwarded-argument behavior.

In `@examples/backups/kafka/05-create-backupjob.sh`:
- Around line 36-38: Persist the resolved backupRef name after retrieving it in
the backup job script, using the established variable or state mechanism
consumed by later steps. Update downstream steps 06 and 07 to reference the
cached backupRef instead of BACKUPJOB_NAME, while preserving the existing
empty-value validation and success logging.

In `@examples/backups/kafka/06-restore-in-place.sh`:
- Around line 37-38: Update the backupRef.name value in the restore
configuration to use the resolved Backup name from
BackupJob.status.backupRef.name rather than BACKUPJOB_NAME, preserving the
reference to the created Backup resource.

In `@examples/backups/kafka/07-restore-to-copy.sh`:
- Around line 50-51: Update the backupRef.name value in the restore manifest to
reference the resolved Backup resource name rather than BACKUPJOB_NAME, matching
the correction made in step 06.

In `@examples/backups/kafka/cleanup.sh`:
- Line 14: Update the cleanup command to delete the Backup using its resolved
Backup resource name rather than assuming it matches BACKUPJOB_NAME; reuse the
variable or lookup established by the script’s resolution flow, while preserving
the namespace and ignore-not-found options.

In `@hack/e2e-chainsaw/kafka/chainsaw-test.yaml`:
- Around line 200-205: Update the KAFKA_IMAGE extraction in the backup setup to
parse the multi-line STRIMZI_KAFKA_IMAGES version-to-image map by selecting one
explicit mapping and extracting its image value. Validate that the result is
exactly one non-empty image value, and retain the existing failure path when
parsing does not produce a valid image.

---

Nitpick comments:
In `@examples/backups/kafka/00-helpers.sh`:
- Around line 55-82: Update wait_for_field to accept an optional
terminal-failure value in addition to desired, and return failure immediately
when the current field matches it. Preserve existing success and timeout
behavior, and keep the argument optional so current callers remain compatible
while Kafka BackupJob/RestoreJob steps can pass Failed.

In `@examples/backups/kafka/03-create-bucket.sh`:
- Around line 62-68: Update the .bucket-info.env cache generation in the heredoc
to emit shell-safe quoted literals for every S3 value, using printf %q before
writing S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, S3_REGION, and S3_BUCKET.
Keep create_s3_secret’s sourcing behavior unchanged while ensuring values
containing spaces, quotes, dollar signs, or semicolons remain data.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 982fd5d4-679d-4eaa-bdd5-b89165a71c7e

📥 Commits

Reviewing files that changed from the base of the PR and between df157da and 891fdad.

📒 Files selected for processing (12)
  • examples/backups/kafka/00-helpers.sh
  • examples/backups/kafka/01-create-strategy.sh
  • examples/backups/kafka/02-create-backupclass.sh
  • examples/backups/kafka/03-create-bucket.sh
  • examples/backups/kafka/04-create-kafka.sh
  • examples/backups/kafka/05-create-backupjob.sh
  • examples/backups/kafka/06-restore-in-place.sh
  • examples/backups/kafka/07-restore-to-copy.sh
  • examples/backups/kafka/README.md
  • examples/backups/kafka/cleanup.sh
  • examples/backups/kafka/run-all.sh
  • hack/e2e-chainsaw/kafka/chainsaw-test.yaml

Comment on lines +120 to +124
# curl --aws-sigv4 signs the request (SigV4) so no separate S3
# client image is needed. -k accepts seaweedfs's internal
# self-signed cert; a production strategy would mount the tenant
# CA and drop -k.
s3() { curl -fsS -k --aws-sigv4 "aws:amz:\${S3_REGION}:s3" --user "\${AWS_ACCESS_KEY_ID}:\${AWS_SECRET_ACCESS_KEY}" "\$@"; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Keep the S3 credentials off the curl command line.

--user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" places the secret key in the process arguments. Any process in the Pod can read it from /proc/<pid>/cmdline, and any shell trace prints it. Pass the credentials through a curl config file on stdin instead.

🔒 Proposed credential handling
-              s3() { curl -fsS -k --aws-sigv4 "aws:amz:\${S3_REGION}:s3" --user "\${AWS_ACCESS_KEY_ID}:\${AWS_SECRET_ACCESS_KEY}" "\$@"; }
+              s3() {
+                printf 'user = "%s:%s"\n' "\${AWS_ACCESS_KEY_ID}" "\${AWS_SECRET_ACCESS_KEY}" \
+                  | curl -fsS -k --aws-sigv4 "aws:amz:\${S3_REGION}:s3" -K - "\$@"
+              }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# curl --aws-sigv4 signs the request (SigV4) so no separate S3
# client image is needed. -k accepts seaweedfs's internal
# self-signed cert; a production strategy would mount the tenant
# CA and drop -k.
s3() { curl -fsS -k --aws-sigv4 "aws:amz:\${S3_REGION}:s3" --user "\${AWS_ACCESS_KEY_ID}:\${AWS_SECRET_ACCESS_KEY}" "\$@"; }
# curl --aws-sigv4 signs the request (SigV4) so no separate S3
# client image is needed. -k accepts seaweedfs's internal
# self-signed cert; a production strategy would mount the tenant
# CA and drop -k.
s3() {
printf 'user = "%s:%s"\n' "\${AWS_ACCESS_KEY_ID}" "\${AWS_SECRET_ACCESS_KEY}" \
| curl -fsS -k --aws-sigv4 "aws:amz:\${S3_REGION}:s3" -K - "\$@"
}
🧰 Tools
🪛 Betterleaks (1.7.3)

[high] 120-124: Discovered a potential basic authorization token provided in a curl command, which could compromise the curl accessed resource.

(curl-auth-user)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/01-create-strategy.sh` around lines 120 - 124, Update
the s3() wrapper to stop passing AWS credentials via curl’s --user command-line
argument; provide them through a curl configuration file on stdin while
preserving the existing SigV4, TLS, and forwarded-argument behavior.

Source: Linters/SAST tools

Comment thread examples/backups/kafka/01-create-strategy.sh
Comment thread examples/backups/kafka/05-create-backupjob.sh
Comment on lines +37 to +38
backupRef:
name: ${BACKUPJOB_NAME}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reference the resolved Backup name, not the BackupJob name.

spec.backupRef.name must carry the Backup name reported by BackupJob.status.backupRef.name. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/06-restore-in-place.sh` around lines 37 - 38, Update
the backupRef.name value in the restore configuration to use the resolved Backup
name from BackupJob.status.backupRef.name rather than BACKUPJOB_NAME, preserving
the reference to the created Backup resource.

Comment on lines +50 to +51
backupRef:
name: ${BACKUPJOB_NAME}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reference the resolved Backup name, not the BackupJob name.

Same defect as step 06. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/07-restore-to-copy.sh` around lines 50 - 51, Update
the backupRef.name value in the restore manifest to reference the resolved
Backup resource name rather than BACKUPJOB_NAME, matching the correction made in
step 06.

kubectl -n "$NAMESPACE" delete restorejob "$RESTOREJOB_TOCOPY_NAME" --ignore-not-found
kubectl -n "$NAMESPACE" delete restorejob "$RESTOREJOB_INPLACE_NAME" --ignore-not-found
kubectl -n "$NAMESPACE" delete backupjob "$BACKUPJOB_NAME" --ignore-not-found
kubectl -n "$NAMESPACE" delete backup "$BACKUPJOB_NAME" --ignore-not-found

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Delete the Backup by its resolved name.

This line assumes the Backup carries the BackupJob name. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/cleanup.sh` at line 14, Update the cleanup command to
delete the Backup using its resolved Backup resource name rather than assuming
it matches BACKUPJOB_NAME; reuse the variable or lookup established by the
script’s resolution flow, while preserving the namespace and ignore-not-found
options.

Comment thread hack/e2e-chainsaw/kafka/chainsaw-test.yaml Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
examples/backups/kafka/01-create-strategy.sh (2)

1-14: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the consistency guarantee.

The offset queries run sequentially. Records produced during the backup can be included in partitions whose end offsets are queried later. State that each partition has an independent frozen cut, not that all partitions are captured at one instant.

Proposed fix
-# to that offset, so everything produced during the backup is excluded and all
-# partitions are captured as of the same instant. This is a logical DATA
+# to that partition's offset. Each partition has an independent frozen cut.
+# This is a logical DATA
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/01-create-strategy.sh` around lines 1 - 14, Update the
consistency-model comments in the script header to describe an independent
frozen end-offset cut per partition: offsets are queried sequentially, and
records produced before a partition’s query may be included while later-produced
records are excluded. Remove the implication that all partitions are captured at
one instant.

182-185: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Replace the line-oriented record format with a lossless encoding.

kafka-console-consumer.sh writes key<TAB>value<LF> without escaping. kafka-console-producer.sh reads one line per record and splits at the first tab. Newlines create extra records, tabs in keys change the key/value boundary, null fields become the literal null, and non-UTF-8 bytes are not preserved.

  • At examples/backups/kafka/01-create-strategy.sh#L182-L185, export key and value bytes with explicit null-state and length-delimited or Base64 encoding.
  • At examples/backups/kafka/01-create-strategy.sh#L219-L224, decode that format before producing the original key and value bytes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/backups/kafka/01-create-strategy.sh` around lines 182 - 185, The
Kafka backup pipeline currently uses an ambiguous line-oriented TSV format that
cannot preserve binary data, embedded tabs/newlines, or null fields. At
examples/backups/kafka/01-create-strategy.sh:182-185, replace the
kafka-console-consumer.sh output with an explicit null-state and
length-delimited or Base64 encoding; at
examples/backups/kafka/01-create-strategy.sh:219-224, update the
kafka-console-producer.sh input path to decode that format and reproduce the
original key and value bytes exactly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@examples/backups/kafka/01-create-strategy.sh`:
- Around line 1-14: Update the consistency-model comments in the script header
to describe an independent frozen end-offset cut per partition: offsets are
queried sequentially, and records produced before a partition’s query may be
included while later-produced records are excluded. Remove the implication that
all partitions are captured at one instant.
- Around line 182-185: The Kafka backup pipeline currently uses an ambiguous
line-oriented TSV format that cannot preserve binary data, embedded
tabs/newlines, or null fields. At
examples/backups/kafka/01-create-strategy.sh:182-185, replace the
kafka-console-consumer.sh output with an explicit null-state and
length-delimited or Base64 encoding; at
examples/backups/kafka/01-create-strategy.sh:219-224, update the
kafka-console-producer.sh input path to decode that format and reproduce the
original key and value bytes exactly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bb30d64-2e2e-42e5-b69c-67a27ebfa59e

📥 Commits

Reviewing files that changed from the base of the PR and between b92c438 and c901a5e.

📒 Files selected for processing (1)
  • examples/backups/kafka/01-create-strategy.sh

@IvanHunters

Copy link
Copy Markdown
Collaborator

Verdict

LGTM with non-blocking notes

Documentation/example scripts plus one appended Chainsaw round-trip Test; no packages/, chart, CRD, Go, or migration surface is touched, so there is no customer-cluster upgrade/install blast radius. One MINOR fail-open in the example backup path, two CI-quality notes surfaced by a second independent reviewer, and a couple of doc-precision nits, none blocking.

Findings

[MINOR] examples/backups/kafka/01-create-strategy.sh:159 — topic-list resolution fails open on a broker error.

In the "back up all topics" branch (empty TOPICS):

TLIST=$("${BIN}"/kafka-topics.sh --bootstrap-server "${BOOT}" --list | grep -v '^_' || true)

The script body runs under set -eu (line 105) with no pipefail, so the pipeline's exit status is grep's alone. A real failure of kafka-topics.sh (broker unreachable / auth error) that emits no stdout makes grep exit 1, || true swallows it, and TLIST is empty — indistinguishable from the legitimate "no non-internal topics" case. Backup then drains nothing, tars an empty work dir, PUTs it and reports uploaded ... success: a silent empty backup that a later restore would treat as authoritative. Not exercised by the e2e (the BackupClass sets topics: "orders" at 02-create-backupclass.sh:33, so the explicit-list branch at 156-157 is taken and is fail-closed under set -e), and the README frames this as a demo driver — hence MINOR, not MAJOR — but the empty-topics mode is a documented, supported path of the example. Fail-closed shape:

raw=$("${BIN}"/kafka-topics.sh --bootstrap-server "${BOOT}" --list)   # fails closed on broker error
TLIST=$(printf '%s\n' "$raw" | grep -v '^_' || true)                  # empty = genuinely no user topics

Novelty pass — second independent reviewer (findings verified against the code)

A second reviewer given no checklist raised the items below; each was re-verified against the code. Neither can produce a false GREEN e2e (set -euo pipefail propagation plus the record-count assertions keep a broken run RED); both are CI-quality notes.

[MINOR] The Chainsaw test's comment claims a fail-fast property the kafka helpers do not implement. hack/e2e-chainsaw/kafka/chainsaw-test.yaml:192-194 states "run-all.sh fails fast on terminal BackupJob/RestoreJob phases and stalled HelmReleases rather than polling out the budget." But examples/backups/kafka/00-helpers.sh:55-82 wait_for_field takes six positional args and has only two exits — match desired, or elapsed >= timeout ("Timeout", :75-78); there is no fail_value/terminal-state branch. The mariadb helper does have it (examples/backups/mariadb/00-helpers.sh:77 fail_value, terminal branch :91-94, plus wait_hr_ready with a Stalled fast-path :126-130) — the comment was carried over from mariadb but the mechanism was not. Call sites 05-create-backupjob.sh:34, 06-restore-in-place.sh:42, 07-restore-to-copy.sh:59 pass no failure value, and HR waits use plain kubectl wait hr --for=condition=ready (03:30, 04:35, 07:34) with no Stalled fast-path. Consequence: a BackupJob that settles phase=Failed (strategy Job exhausts backoffLimit=2) is polled to the full 600s and mislabeled "Timeout", burning ~10 of the 40 chainsaw minutes per already-RED run. It does not cause a false pass. Fix is mechanical: adopt the mariadb wait_for_field/wait_hr_ready variants, or correct the comment to match reality.

[MINOR] The catch section does not capture the strategy Job Pods' logs — where the actual S3-upload error prints. chainsaw-test.yaml:163-167 comment says the drain/replay runs in controller-created Job Pods and their errors should be captured, yet the only podLogs selector (:168-170) is app.kubernetes.io/instance=kafka-kafka-test (the Strimzi broker Pods). The strategy Job Pods carry job.strategy.backups.cozystack.io/mode=backup|restore plus the owned-by labels (internal/backupcontroller/jobstrategy_controller.go:33,:201-204,:276), never app.kubernetes.io/instance, and are not TTL-reaped (buildJobStrategyBatchJob sets no TTLSecondsAfterFinished, :317-327), so they are still present at catch time. A second podLogs keyed on the mode/owned-by label would capture the curl/SigV4 error; as written, a failed upload leaves no container log in the catch output (events/describe show phase but not stdout). Diagnostics-only; does not affect pass/fail.

Claim mismatches

[UNVERIFIABLE] "Verified end to end on a live single-broker cluster … both restores returned 30 records" — cannot be re-executed in a hermetic static review (no live cluster). Plausible and consistent with the merged mariadb-2-backup-roundtrip shape it mirrors; noted, not blocking.

[PARTIAL] "the original 8/11/11 partition distribution preserved" — the e2e only asserts total count >= MESSAGE_COUNT (06-restore-in-place.sh:49, 07-restore-to-copy.sh:66); per-partition distribution is not asserted anywhere, so this claim rests on the manual run only. Not a defect in the example; the test is simply looser than the prose.

Caveats

  • Backup path verified fail-closed at every gate EXCEPT the one Finding above: kafka-console-consumer/tar/s3 PUT failures abort under set -e; topic_message_count's 2>/dev/null || exit 0 (00-helpers.sh:145-146,157) is safe because every caller guards with a numeric check (04:48, 06:48, 07:65); the --delete … || true at 06:20 is gated by an explicit disappearance poll (06:21-27); wait_for_field's kubectl get … || true (00:70) fails closed via timeout. Reported so the clean gates are a verified negative, not a silent skip.
  • RestoreJob.spec.backupRef.name is hardcoded to ${BACKUPJOB_NAME} in 06/07, assuming the Backup object shares the BackupJob's name. This matches the merged mariadb reference and createJobBackupArtifact (Name: j.Name), so it is consistent with the working pattern — but it is a latent coupling; step 05 already resolves the real status.backupRef.name, so threading it into 06/07 would remove the assumption.
  • The e2e round-trip is non-vacuous: in-place restore deletes the topic and confirms absence in the same Pod (06:21-30) before asserting count; to-copy asserts on a freshly provisioned cluster; the pre-clean removes stale Succeeded BackupJobs that would otherwise satisfy the wait instantly. Its actual pass/fail is a CI concern, not statically verifiable (hermetic boundary).

Recommended follow-ups (doc precision, non-blocking)

  • Null-key records: backup drains with print.key=true and restore replays with parse.key=true, so a null key round-trips as the literal 4-byte string null, altering key/compaction semantics — the README (README.md) notes only that partition placement is not guaranteed. One sentence would close the gap, and it matters more in the empty-topics (all-topics) mode where unkeyed topics are common.
  • README "Scope and limitations" reads as if the original replication factor is preserved, but RF actually comes from the BackupClass replicationFactor parameter (default 1); and restore is at-least-once (backoffLimit=2 + append replay can duplicate), which the count >= expected assertions deliberately tolerate but the limitations list does not mention.
  • If the generic-Job Kafka strategy shell body is ever promoted from example to a shipped/production driver, the 01:159 fail-open becomes MAJOR (silent empty backup) and should be fixed first, alongside the binary-safe payload handling the README already flags as out of scope.
  • Optional cluster-side validation of the round-trip via the cozystack-pr-test skill against a disposable dev cluster, since the e2e path is not statically executable.

Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 11, 2026
In the empty-TOPICS (back up all non-internal topics) branch, the topic
list was resolved with a single pipeline under set -eu without pipefail:

  TLIST=$(kafka-topics.sh --list | grep -v '^_' || true)

The pipeline's exit status is grep's alone, so a real broker error
(unreachable / auth) that emits no stdout makes grep exit 1, || true swallows
it, and TLIST is empty - indistinguishable from 'no non-internal topics'. The
backup then drains nothing, tars an empty dir, PUTs it and reports success: a
silent empty backup a later restore would treat as authoritative.

Split the --list into its own command substitution so a broker error aborts
the Pod under set -e; filter internal topics only afterwards, so an empty
TLIST means genuinely no user topics. Addresses the reviewer's fail-open note
on PR #3580.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 11, 2026
… Job logs

Two CI-quality notes from the PR #3580 review, both diagnostics/wall-clock
only (neither can turn a run falsely green):

- The chainsaw comment claimed run-all.sh fails fast on terminal
  BackupJob/RestoreJob phases and Stalled HelmReleases, but the kafka helpers
  never implemented it (carried over from mariadb). A BackupJob that settled
  phase=Failed was polled the full 600s and then mislabelled 'Timeout',
  burning ~10 of the 40 chainsaw minutes per already-red run. Port the mariadb
  variants: wait_for_field gains a terminal fail_value arg (call sites pass
  Failed), and a new wait_hr_ready fails fast on Stalled=True with an existence
  backstop. HR waits in steps 03/04/07 now use it; the Strimzi Kafka CR waits
  keep their genuine Ready condition. run-all.sh runs under set -e, so a
  non-zero step aborts the flow immediately, matching the comment.

- The catch block only captured the Strimzi broker Pods
  (app.kubernetes.io/instance=kafka-kafka-test); the generic Job strategy runs
  the drain/replay in controller-created Pods labelled
  job.strategy.backups.cozystack.io/mode=backup|restore (never that instance
  label) and not TTL-reaped, so the actual S3/SigV4 error left no log in the
  report. Add a second podLogs selector keyed on that label.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Aug 11, 2026
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 12, 2026
In the empty-TOPICS (back up all non-internal topics) branch, the topic
list was resolved with a single pipeline under set -eu without pipefail:

  TLIST=$(kafka-topics.sh --list | grep -v '^_' || true)

The pipeline's exit status is grep's alone, so a real broker error
(unreachable / auth) that emits no stdout makes grep exit 1, || true swallows
it, and TLIST is empty - indistinguishable from 'no non-internal topics'. The
backup then drains nothing, tars an empty dir, PUTs it and reports success: a
silent empty backup a later restore would treat as authoritative.

Split the --list into its own command substitution so a broker error aborts
the Pod under set -e; filter internal topics only afterwards, so an empty
TLIST means genuinely no user topics. Addresses the reviewer's fail-open note
on PR #3580.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 12, 2026
… Job logs

Two CI-quality notes from the PR #3580 review, both diagnostics/wall-clock
only (neither can turn a run falsely green):

- The chainsaw comment claimed run-all.sh fails fast on terminal
  BackupJob/RestoreJob phases and Stalled HelmReleases, but the kafka helpers
  never implemented it (carried over from mariadb). A BackupJob that settled
  phase=Failed was polled the full 600s and then mislabelled 'Timeout',
  burning ~10 of the 40 chainsaw minutes per already-red run. Port the mariadb
  variants: wait_for_field gains a terminal fail_value arg (call sites pass
  Failed), and a new wait_hr_ready fails fast on Stalled=True with an existence
  backstop. HR waits in steps 03/04/07 now use it; the Strimzi Kafka CR waits
  keep their genuine Ready condition. run-all.sh runs under set -e, so a
  non-zero step aborts the flow immediately, matching the comment.

- The catch block only captured the Strimzi broker Pods
  (app.kubernetes.io/instance=kafka-kafka-test); the generic Job strategy runs
  the drain/replay in controller-created Pods labelled
  job.strategy.backups.cozystack.io/mode=backup|restore (never that instance
  label) and not TTL-reaped, so the actual S3/SigV4 error left no log in the
  report. Add a second podLogs selector keyed on that label.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@androndo
Andrey Kolkov (androndo) force-pushed the worktree-kafka-backup-example branch from ed07141 to f7fb5cf Compare August 12, 2026 08:01
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 17, 2026
In the empty-TOPICS (back up all non-internal topics) branch, the topic
list was resolved with a single pipeline under set -eu without pipefail:

  TLIST=$(kafka-topics.sh --list | grep -v '^_' || true)

The pipeline's exit status is grep's alone, so a real broker error
(unreachable / auth) that emits no stdout makes grep exit 1, || true swallows
it, and TLIST is empty - indistinguishable from 'no non-internal topics'. The
backup then drains nothing, tars an empty dir, PUTs it and reports success: a
silent empty backup a later restore would treat as authoritative.

Split the --list into its own command substitution so a broker error aborts
the Pod under set -e; filter internal topics only afterwards, so an empty
TLIST means genuinely no user topics. Addresses the reviewer's fail-open note
on PR #3580.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 17, 2026
… Job logs

Two CI-quality notes from the PR #3580 review, both diagnostics/wall-clock
only (neither can turn a run falsely green):

- The chainsaw comment claimed run-all.sh fails fast on terminal
  BackupJob/RestoreJob phases and Stalled HelmReleases, but the kafka helpers
  never implemented it (carried over from mariadb). A BackupJob that settled
  phase=Failed was polled the full 600s and then mislabelled 'Timeout',
  burning ~10 of the 40 chainsaw minutes per already-red run. Port the mariadb
  variants: wait_for_field gains a terminal fail_value arg (call sites pass
  Failed), and a new wait_hr_ready fails fast on Stalled=True with an existence
  backstop. HR waits in steps 03/04/07 now use it; the Strimzi Kafka CR waits
  keep their genuine Ready condition. run-all.sh runs under set -e, so a
  non-zero step aborts the flow immediately, matching the comment.

- The catch block only captured the Strimzi broker Pods
  (app.kubernetes.io/instance=kafka-kafka-test); the generic Job strategy runs
  the drain/replay in controller-created Pods labelled
  job.strategy.backups.cozystack.io/mode=backup|restore (never that instance
  label) and not TTL-reaped, so the actual S3/SigV4 error left no log in the
  report. Add a second podLogs selector keyed on that label.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@androndo
Andrey Kolkov (androndo) force-pushed the worktree-kafka-backup-example branch from f7fb5cf to 46ab0e3 Compare August 17, 2026 17:59
IvanHunters
IvanHunters previously approved these changes Aug 18, 2026

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

LGTM with non-blocking notes.

This is example scripts plus one appended Chainsaw Test. Nothing here touches a chart, package, controller, CRD, or values contract, so the upgrade / SSA / CRD-transition / vendored-rename / config-matrix regression classes don't apply, and I checked that against the envelope: 0 charts, 0 Go packages, 0 packages/ files, and the "make generate produces no diff" claim holds up. The scripts fail closed on the paths that matter, cleanup.sh deletes the right Backup object (Backup.Name == BackupJob.Name, internal/backupcontroller/jobstrategy_controller.go:348), and the e2e actually asserts on record count rather than passing vacuously.

The notes below are accuracy and coverage refinements. None of them block merge. Two are worth calling out first, though: the ^_ over-match and the unvalidated explicit-topic list both let a backup example silently produce an incomplete backup that still reports success, which is the one failure a backup example must not teach. Both are one-line fixes.

Findings

[MINOR] 01-create-strategy.sh:167, grep -v '^_' silently drops legal user topics in the default "all topics" mode

Kafka's internal topics are __consumer_offsets and __transaction_state, both double-underscore. This filter also drops every single-underscore name, so a user topic like _schemas (Confluent Schema Registry) or _events holds user data yet is silently excluded from the documented "every non-internal topic" default. The comment at line 155 ("bookkeeping topics start with _") carries the same imprecision. Use grep -v '^__'.

[MINOR] 01-create-strategy.sh:156-157, an explicit topics: value naming a missing topic yields a successful empty backup

The explicit-TOPICS branch takes the list verbatim with no existence check, unlike the empty branch right below it, which is deliberately fail-closed. kafka-get-offsets.sh on a nonexistent topic prints nothing and exits 0, so the loop writes no manifest entry, uploads a near-empty tarball, and the BackupJob reports Succeeded. A typo in the BackupClass topics parameter produces a "backup" that restores nothing, and you find out at restore time. (I reasoned the exit-0-on-missing-topic behaviour from the CLI, I did not run it here.) A pre-flight check that each requested topic exists, failing loudly otherwise, closes this.

[MINOR] 01-create-strategy.sh:139-140, one S3 key per app: a later backup overwrites the earlier one, but old Backup CRs stay Ready

The object key is ${SRC}/kafka-topics.tar, scoped only by app name. Take backup A, produce more data, take backup B, then run a RestoreJob referencing A: the restore succeeds and hands back B's data, because the key is the same. Nothing marks the older Backup CR as superseded. This matches the nats sibling's single-key pattern and may be forced by the engine (the template gets no per-backup identity in backup mode, .Backup is nil there), so I read it as a documentation gap rather than a code defect. The README should say that only the latest backup of an app is actually restorable through this strategy.

[MINOR] 06-restore-in-place.sh:22, the topic-deletion wait can false-positive on a transient broker error and hollow out what the e2e proves

Inside the Pod snippet (set -eu, no pipefail) the check is ! kafka-topics.sh ... --list | grep -qx "$TOPIC". If --list itself fails, its output is empty, grep exits 1, and the negation declares the topic deleted while it's still there with all its records. Restore then replays into the live topic, the count reaches 60, and because the verification is >= (next finding) it still passes. So the round-trip test can go green without ever exercising topic recreation. Add set -o pipefail to that snippet, or check --list's own exit status before trusting its output.

[MINOR] 06-restore-in-place.sh:49, 07-restore-to-copy.sh:65, the round-trip check is >= and total-only

Verification passes when count >= MESSAGE_COUNT over a partition-summed offset delta. That can't catch over-restore (duplicate records: a restore Pod retried mid-replay re-runs the whole producer, and --create --if-not-exists skips the topic, so records double) and it doesn't assert per-partition distribution. The PR's headline claim is partition-preserving replay ("8/11/11 distribution preserved"), but nothing in the scripts or the test asserts per-partition counts, so the murmur2 repartitioning this PR is about is never exercised by CI. Consider exact equality where the flow uses a fresh/empty topic, plus a per-partition kafka-get-offsets assertion.

[MINOR] 01-create-strategy.sh:189-194, the drain assumes end - begin equals the consumable-record count

--max-messages $((end - begin)) holds only for a non-compacted, non-transactional topic. On a compacted topic (sparse offsets) or one written by transactional producers (control markers occupy offsets but are never delivered), fewer records than end-begin are consumable, so kafka-console-consumer idles until --timeout-ms 120000 and exits non-zero, killing the Pod under set -e and failing the BackupJob. That's fail-closed, no silent partial tarball, which is the right direction. But the topic_message_count comment at 00-helpers.sh:190 ("exact ... even for a compacted or truncated topic") is about the offset-math helper, not the drain, and could mislead. A one-line README limitation that compacted/transactional topics are out of scope for this logical path settles it.

[MINOR] README.md:21 / 01-create-strategy.sh:227-232, null-key fidelity is understated

The README says null-key records "are not guaranteed to land in their original partition." It's worse than that. print.key=true renders a null key as the literal token null, and replay with parse.key=true turns it into the 4-byte string key "null". So a null-key record doesn't just move partition, it gains a non-null key, and every originally-null-key record collapses onto one partition; null values (tombstones) similarly become ordinary payloads. The demo uses keyed records only, so this isn't exercised, but the limitation text should say null-key/null-value records are not round-tripped faithfully. The pinned 3.8 image supports the null.marker property (KIP-810) if faithful round-tripping is wanted later.

[NIT] 01-create-strategy.sh:128-129, hand-rolled endpoint parse mishandles IPv6 / path-carrying endpoints

HOST="${HOSTPORT%%:*}" mis-splits an IPv6 literal like [::1]:8333, and any endpoint carrying a path prefix loses it, since OBJ_URL is rebuilt from the bare host. The BucketInfo host:port shape works; other legal endpoint forms don't. Demo-scope, worth a comment.

Claim mismatches

[PARTIAL] "every partition is captured as of the same instant" / "a coherent point-in-time view" (README.md:12, PR body). The --time -1/--time -2 reads sit inside the for t in $TLIST loop (01-create-strategy.sh:172-178), so each topic is frozen when its iteration starts, after the previous topics have drained. The cut is atomic within a topic, not across topics. Kafka gives no cross-topic guarantee anyway, but the prose generalizes to "every non-internal topic." Narrow it to per-topic consistency.

[PARTIAL] "recreates each topic with only its original partition count and replication factor" (README.md:18). The partition count is original (from the manifest). The replication factor is not: restore uses the static REPLICATION_FACTOR parameter, default 1 (01-create-strategy.sh:53-54, applied at :222), and the source RF is never captured. Restoring into a multi-broker cluster without setting the parameter recreates topics at RF=1, a silent durability downgrade. Say "the configured replication factor (default 1)", not "its original replication factor."

[PARTIAL] "partition distribution preserved" (PR body). Correct in the code path (topic recreated with original partition count, deterministic murmur2), but not asserted by the shipped e2e, which checks total record count only.

Caveats

  • Mechanical fail-open sweep ran on all 10 shell files. Every || true / 2>/dev/null hit was verified non-gating: 01-create-strategy.sh:167 handles grep's no-match exit only (the deciding --list is a separate command under set -e); 00-helpers.sh:77,110,116,126 are poll-loop reads; 00-helpers.sh:195-196 || exit 0 returns empty and every caller rejects a non-numeric count (06:48, 07:64); 06:20 --delete ... || true is backstopped by the disappearance wait-loop. Clean, with the one exception that the wait-loop's own --list pipe is unguarded (finding above).
  • shellcheck SC2016 (00-helpers.sh:175,194; 06:19) and SC1091 (dynamic source) are false positives: the single-quoted bodies are Pod snippets whose $VAR must stay unexpanded until they reach the in-Pod bash, and the sourced path resolves at runtime. No action.
  • job-service-label-overlap: no overlap. The throwaway CLI pods use kubectl run (run=kafka-cli-<rand>), which doesn't match the Strimzi bootstrap Service selector; the strategy Job pod labels are stamped by the controller, out of this PR.
  • Sound on independent check: .bucket-info.env is gitignored (examples/backups/*/.bucket-info.env); empty partitions are handled (no data file, manifest still records the partition count); a tab inside a value round-trips because parse.key splits at the first separator only; the to-copy source-keyed S3 read via .Backup.ApplicationRef.Name is correct; the curl 7.76.1 --aws-sigv4 port-stripping workaround is internally consistent.

Recommended follow-ups

  • grep -v '^__' and a topic-existence pre-check are the two cheapest, highest-value fixes: they stop the example from silently teaching a lossy backup.
  • Add a per-partition offset assertion (and exact equality where the flow uses a fresh topic) to the e2e, so the partition-preserving-replay claim is guarded and duplicate-on-retry is caught.
  • Note the latest-only-restorable and compacted/transactional-out-of-scope limitations in the README.

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the value here is bigger than the title suggests. The generic Job strategy has had unit tests and exactly one worked example (examples/backups/nats/) but no end-to-end coverage at all, and no Chainsaw suite drives it. kafka-2-backup-roundtrip is therefore the first e2e that exercises the generic driver for real: template rendering including .Mode/.Parameters/.Backup.ApplicationRef.Name, the parameter round-trip through the Backup's driverMetadata, and the to-copy retarget. The run bears it out — 452s, and the log shows 30 records seeded, backed up, the topic deleted, restored in place, then restored into a second cluster.

The design call also looks right to me. A logical frozen-end-offset drain is not the highest-fidelity Kafka backup available, but the README says so plainly and up front rather than burying it: no topic configs, no ACLs, no SCRAM users, no consumer-group offsets, offsets reassigned on restore, no headers or timestamps, and an explicit "for a faithful, offset-preserving Kafka backup, prefer a volume-snapshot strategy over this logical one". An example gets copied, so being told where it bites is most of what makes it safe to ship. The comment density throughout is well above average and the reasoning held up everywhere I cross-checked it against the controller.

Two things I'd like changed before merge, both small. The rest below are comments rather than blockers.

Blocking

1. Restore is not idempotent, and the assertion cannot see the failure mode. 01-create-strategy.sh:221-233 recreates topics with --create --if-not-exists (existing topics are left alone) and then replays every partition file unconditionally, so restoring into a topic that still holds data appends rather than replaces. That matters more than it looks, because buildJobStrategyBatchJob in internal/backupcontroller/jobstrategy_controller.go hard-codes backoffLimit: 2 — a restore Pod that dies partway through replay is retried, and the retry replays from the beginning. Both verifications then assert if (( count < MESSAGE_COUNT )), strictly less-than, so a restore that produced 45 or 60 records instead of 30 prints "In-place restore verified" and passes (06-restore-in-place.sh:49, 07-restore-to-copy.sh:65). Could we make both comparisons -eq, and add a line to the README's limitations saying restore appends into a non-empty topic and is safe only against an absent or empty one?

2. The roundtrip proves record counts, never record content. topic_message_count (00-helpers.sh:192) sums end - begin offsets per partition, and nothing ever consumes the records back. Meanwhile the README makes three fidelity claims the test does not check: keys survive, keyed records land in their original partition via the murmur2 partitioner, and per-partition order is preserved. A restore that replayed 30 records of garbage, or scrambled every key, passes today. The seeded sentinels are already perfect for this (k-<n> / order-<n>) — consuming the topic once and diffing a sorted list would close the gap in a few lines, and given that data fidelity is the entire subject of the example it seems worth having.

Non-blocking, roughly by weight

Helper Pods will time out on a first run outside CI. kafka_run (00-helpers.sh:157) uses kubectl run --restart=Never --rm -i, and kubectl run with attach waits for Running under --pod-running-timeout, whose default is 1m, with no override and no retry. CI never hits it because the Chainsaw test resolves KAFKA_IMAGE from the running strimzi-cluster-operator, so the image is already cached on the nodes. A user following the README gets the default quay.io/strimzi/kafka:0.45.0-kafka-3.8.0 pulled cold, which routinely takes longer than 60s, and step 04 dies with timed out waiting for the container to run before seeding anything. --pod-running-timeout=5m on the four call sites would cover it.

Helper Pods carry no securityContext at all. The passing CI log contains the evidence three times: Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false ..., unrestricted capabilities ..., runAsNonRoot != true ..., seccompProfile ... for each kafka-cli-* Pod. Warn-only in the e2e cluster, so the run stays green, but in a tenant that enforces restricted the demo cannot run at all. The strategy Pod is in better shape and its comment already acknowledges the runAsNonRoot gap; the helper Pods get no mention. Either --overrides with a securityContext, or a README line naming the requirement.

Null-key records come back with the literal key null. The dump uses --property print.key=true and Kafka's default formatter renders a null key as the string null; the replay reads that back through parse.key=true as a four-byte key (01-create-strategy.sh:190-192, 231-232). So such a record does not merely land in an unpredictable partition, which is what the README currently says — its key changes from null to "null", which also changes compaction semantics downstream. Worth restating that bullet in those terms.

No bound on the drain's local disk usage. WORK=/tmp/kafka-dump plus /tmp/kafka-topics.tar materialises the whole topic twice on the Pod's writable layer, and resources (01-create-strategy.sh:236-242) declares cpu and memory but no ephemeral-storage request or limit. A tenant copying this for a large topic fills the node's ephemeral storage and evicts unrelated Pods before the backup fails. A sizing caveat in the limitations section would earn its place.

Every backup of an app overwrites the previous one. The S3 key is ${SRC}/kafka-topics.tar with no run identifier (01-create-strategy.sh:139), so N Backup objects all resolve to the same tarball and restoring an older Backup silently restores current data. This is largely forced by the driver — at backup-render time the context carries only Application/Release/Mode/Parameters, and the Backup artifact is created after the Job completes, so the Pod has no unique handle — and the NATS example has the same shape. Still belongs in the limitations rather than being discovered later.

set -e fragility in the partition-count loop. [ "${mp}" -ge "${parts}" ] && parts=$((mp + 1)) as the last statement of a while read body (01-create-strategy.sh:217-220): if the final iteration's test is false, the loop returns non-zero and set -eu kills the restore Pod with no message. It cannot fire today because kafka-get-offsets emits partitions in ascending order, but if ... then ... fi is the same length and closes it.

The e2e never exercises the all-topics path. The BackupClass pins topics: "${TOPIC}" (02-create-backupclass.sh:33), so the --list-and-filter branch — exactly what "fix(kafka): fail closed when listing topics for an all-topics backup" addresses — has no coverage from the test that ships with it.

Smaller things: the hack/e2e-chainsaw/.chainsaw.yaml header enumerates the Tests that override the namespace to tenant-root as "postgres-2-, mariadb-2-, clickhouse-2-" and could pick up kafka-2-; every other script-based backup example ships the 90/91/92 scenario docs (clickhouse, nats, etcd, foundationdb) and this one has only the README, which may well be deliberate; and the default KAFKA_IMAGE pins 0.45.0 while the vendored operator chart is strimzi-kafka-operator 0.45.1-rc1, where the Chainsaw test already shows the drift-free way to resolve it.

On CI cost, for the record, since a backup roundtrip is the kind of thing that quietly grows the suite: hack/select-e2e.sh maps both examples/backups/kafka/* and hack/e2e-chainsaw/kafka/* to the single kafka suite, Chainsaw runs parallel: 1, and the measured 452s sits well inside the declared 40m budget, which is justified in-line as convention 6 asks. That reads as appropriately gated to me. Test hygiene follows the mariadb precedent closely and correctly — pre-clean before the run so a leftover Succeeded BackupJob in the shared namespace cannot instantly satisfy the wait, cleanup in an always-reached finally, wait_hr_ready failing fast on Stalled=True, wait_for_field failing fast on the terminal Failed phase, and a catch block selecting the strategy Pods by the real job.strategy.backups.cozystack.io/mode constant. The note in 05-create-backupjob.sh explaining why there is no intermediate "wait Running" gate is exactly the right instinct. I did not find anything I would expect to go red intermittently in CI.

Andrey Kolkov and others added 6 commits August 20, 2026 20:24
Add examples/backups/kafka, a worked backup/restore of a Cozystack-managed
Kafka application's topic data via the generic Job backup strategy - the
same pattern as examples/backups/nats, with no purpose-built backup image:
a stock Strimzi Kafka image (kafka-*.sh CLI + bash/curl/tar) runs one
shell script that branches on .Mode.

Consistency is a frozen-end-offset cut: kafka-get-offsets --time -1 is read
once per partition at backup start and the consumer drains only up to that
offset, so anything produced during the run is excluded and every partition
is captured as of the same instant. Restore recreates each topic with its
original partition count and replays every partition file; keyed records
reproduce into their original partition via the default partitioner.

This is deliberately a DATA-only backup: topic configs, ACLs, SCRAM users
and consumer-group offsets are not captured, and re-produced records get
fresh offsets. The README spells out the scope and points at a
volume-snapshot strategy for faithful, offset-preserving backups.

Add kafka-2-backup-roundtrip to hack/e2e-chainsaw/kafka, driving the
example run-all.sh as the harness (Bucket -> source Kafka + seeded topic
-> BackupJob -> in-place restore -> to-copy restore + verify) so the test
and the documented flow cannot drift, mirroring mariadb-2-backup-roundtrip.
The image is resolved from the running operator so the backup Pod reuses
the cached Kafka image.

Verified end to end on a live cluster: freeze cut orders:0 [0,8),
orders:1 [0,11), orders:2 [0,11) = 30 records; in-place and to-copy
restores both return 30 with the original 8/11/11 partition distribution.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
STRIMZI_KAFKA_IMAGES is a newline-separated version=image map with one
entry per supported Kafka version. The old 'sed | tr -d [:space:]' collapsed
every line into one string, producing an invalid multi-image reference
(quay.io/...3.8.0quay.io/...3.8.1...) that failed 'kubectl run' with
'invalid reference format' and broke kafka-2-backup-roundtrip.

Split on whitespace, keep image lines, strip the version prefix and take the
last (newest) image so a single valid reference is exported.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
The Strimzi image ships curl 7.76.1, whose --aws-sigv4 omits a
non-default port from the SigV4 canonical Host while still sending it in the
Host header (a bug fixed in curl 7.87.0). seaweedfs recomputes the signature
from the received host:port, so every PUT/GET to the :8333 in-cluster S3
Service the e2e harness targets was rejected with HTTP 403, failing
kafka-2-backup-roundtrip at the backup upload. It passed on dev7 only because
that run used a :443 ingress, where the port is absent from the Host anyway.

Strip the port from the signed and sent URL and redirect the connection to the
real port with --connect-to, so the signed, sent and server-side Host all
agree on the bare host. Verified against the image's curl 7.76.1: the emitted
SigV4 signature matches a portless canonical Host, and the request still
reaches the :8333 listener. Correct on curl >= 7.87 too, which drops the
default port from the canonical Host as well.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
In the empty-TOPICS (back up all non-internal topics) branch, the topic
list was resolved with a single pipeline under set -eu without pipefail:

  TLIST=$(kafka-topics.sh --list | grep -v '^_' || true)

The pipeline's exit status is grep's alone, so a real broker error
(unreachable / auth) that emits no stdout makes grep exit 1, || true swallows
it, and TLIST is empty - indistinguishable from 'no non-internal topics'. The
backup then drains nothing, tars an empty dir, PUTs it and reports success: a
silent empty backup a later restore would treat as authoritative.

Split the --list into its own command substitution so a broker error aborts
the Pod under set -e; filter internal topics only afterwards, so an empty
TLIST means genuinely no user topics. Addresses the reviewer's fail-open note
on PR #3580.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
… Job logs

Two CI-quality notes from the PR #3580 review, both diagnostics/wall-clock
only (neither can turn a run falsely green):

- The chainsaw comment claimed run-all.sh fails fast on terminal
  BackupJob/RestoreJob phases and Stalled HelmReleases, but the kafka helpers
  never implemented it (carried over from mariadb). A BackupJob that settled
  phase=Failed was polled the full 600s and then mislabelled 'Timeout',
  burning ~10 of the 40 chainsaw minutes per already-red run. Port the mariadb
  variants: wait_for_field gains a terminal fail_value arg (call sites pass
  Failed), and a new wait_hr_ready fails fast on Stalled=True with an existence
  backstop. HR waits in steps 03/04/07 now use it; the Strimzi Kafka CR waits
  keep their genuine Ready condition. run-all.sh runs under set -e, so a
  non-zero step aborts the flow immediately, matching the comment.

- The catch block only captured the Strimzi broker Pods
  (app.kubernetes.io/instance=kafka-kafka-test); the generic Job strategy runs
  the drain/replay in controller-created Pods labelled
  job.strategy.backups.cozystack.io/mode=backup|restore (never that instance
  label) and not TTL-reaped, so the actual S3/SigV4 error left no log in the
  report. Add a second podLogs selector keyed on that label.

Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
The backup roundtrip only summed offset counts, so a restore that
replayed garbage, scrambled keys, or ran twice (the strategy Job's
backoffLimit retry re-appends from the start) still passed. Both
verifications also compared with `< MESSAGE_COUNT`, so an inflated
count slipped through.

Snapshot the source topic in step 04 as a per-partition, offset-ordered
partition/key/value dump, and diff the restored topic against it in the
in-place and to-copy steps: byte-equality proves keys, values, partition
placement and per-partition ordering survived, not merely the count.
Make both count checks exact.

Document that restore appends into a non-empty topic and is safe only
against an absent or empty one. Raise the helper Pods'
--pod-running-timeout to 5m so a cold image pull on a first run outside
CI does not abort before seeding.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@androndo
Andrey Kolkov (androndo) force-pushed the worktree-kafka-backup-example branch from 46ab0e3 to ed3b808 Compare August 20, 2026 16:25
@androndo

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Both blockers addressed and the branch is rebased on fresh main.

1. Restore idempotency / blind assertion. Both verifications now compare exactly ((( count != MESSAGE_COUNT ))), so a double-replay landing 45 or 60 records fails instead of passing. Added a limitations bullet spelling out that restore creates the topic only if absent and then replays every record, so it appends into a non-empty topic and is safe only against an absent or empty one — and that the strategy Job's backoffLimit: 2 means a Pod dying mid-replay re-appends from the start.

2. Content, not just counts. Step 04 now snapshots the source topic as a per-partition, offset-ordered partition/key/value dump (topic_dump in 00-helpers.sh), and steps 06 and 07 diff the restored topic against that snapshot. Byte-equality of the two ordered dumps proves keys survive, keyed records land in their original partition, and per-partition ordering is preserved — the three fidelity claims that were previously unchecked. Re-verified end to end on a live single-broker cluster: both restores now report content matches source at exactly 30/30 records.

Also picked up the cold-pull non-blocker since the new topic_dump helper hits the same path: kafka_run now passes --pod-running-timeout=5m, so a first run outside CI on the default image no longer aborts before seeding.

The remaining non-blockers (helper-Pod securityContext, null-key rendered as literal null, ephemeral-storage and same-key-overwrites-previous-backup caveats, the set -e loop tidy, all-topics e2e coverage, the .chainsaw.yaml header, scenario docs, and the KAFKA_IMAGE default pin) I've left for now to keep this round scoped to the blockers — happy to fold the doc/caveat ones in here or in a follow-up, whichever you prefer.

Fold in the cheap items from review: document the caveats that make the
example safe to copy - a null key round-trips as the literal string
"null" (changing key and compaction identity), the drain stages the
topic twice under /tmp with no ephemeral-storage bound, every backup of
an app overwrites the same S3 key, and the Pods target warn-only Pod
Security. Harden the all-topics partition-count loop: the final
`[ ... ] && parts=...` returned non-zero under `set -e` when the last
test was false, so use `if ... then ... fi`. Align the default
KAFKA_IMAGE with the vendored operator (0.45.1-rc1) and list kafka-2- in
the .chainsaw.yaml tenant-root header.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@androndo

Copy link
Copy Markdown
Contributor Author

Folded in the cheap non-blockers in ad5e0b2f3:

  • Null-key bullet restated — a null key round-trips as the literal string null, changing both key and downstream compaction identity, not just partition.
  • Added limitations bullets for the unbounded /tmp + tarball ephemeral-storage usage, and for every backup of an app overwriting the same <app>/kafka-topics.tar S3 key (no per-run handle at backup-render time).
  • Added a bullet naming the warn-only Pod Security stance of both the strategy and helper Pods.
  • Hardened the all-topics partition-count loop: [ ... ] && parts=...if ... then ... fi, so a false final test no longer trips set -e.
  • Aligned the default KAFKA_IMAGE with the vendored operator (0.45.1-rc1-kafka-3.8.0) and listed kafka-2- in the .chainsaw.yaml tenant-root header.

Left for a follow-up as discussed: enforced-PSA securityContext on the helper Pods (the example intentionally targets warn-only PSA, same as the strategy Pod), an all-topics e2e path, and the 90/91/92 scenario docs.

Fold in the cheap accuracy notes from review. The all-topics default
filtered `^_`, which also drops single-underscore user topics such as
`_schemas`, silently excluding user data from a backup that still
reports success; filter `^__` (Kafka's internal topics are
double-underscore). The in-place delete-wait ran `--list | grep` in a
pod snippet under `set -eu` without pipefail, so a transient `--list`
failure read as empty output and false-positived "deleted"; capture
`--list` on its own and check its exit before trusting it.

Sharpen the docs: the frozen-end-offset cut is per-topic, not
cross-topic; restore uses the configured replication factor (default 1),
not the source's; compacted and transactional topics are out of scope;
and the endpoint parse is demo-scope (no IPv6 / path-carrying forms).

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
@androndo

Copy link
Copy Markdown
Contributor Author

Thanks IvanHunters — folded in the cheap accuracy/coverage notes across ad5e0b2f3 and 87d7a627c:

  • ^_^__ in the all-topics default, so single-underscore user topics (_schemas, _events) are no longer silently excluded; comment corrected to double-underscore.
  • Delete-wait --list | grep now captures --list on its own and checks its exit, so a transient broker error can't false-positive "deleted" (the pod snippet runs under set -eu without pipefail).
  • Docs sharpened: the frozen-end-offset cut is now described as per-topic (not cross-topic); the recreated replication factor is called out as the configured replicationFactor (default 1), not the source's; compacted/transactional topics are documented as out of scope; and the endpoint parse is noted as demo-scope (no IPv6 / path forms).
  • Null-key fidelity restated (literal "null" key + compaction identity), latest-only-restorable documented, and both round-trip checks are now exact — over-restore fails instead of passing. The partition-preserving claim is now actually guarded: step 04 snapshots the source as a per-partition, offset-ordered partition/key/value dump and steps 06/07 diff the restore against it, so keys, placement and per-partition order are asserted, not just the count.

Deferred (happy to take in a follow-up): the explicit-topics: existence pre-check (your other correctness note — a few lines in the strategy, so not a one-liner), an enforced-PSA securityContext on the helper Pods, and the 90/91/92 scenario docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants