Skip to content

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

Merged
Timofei Larkin (lllamnyp) merged 17 commits into
mainfrom
worktree-kafka-backup-example
Sep 24, 2026
Merged

Timofei Larkin (lllamnyp) merged 17 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 replication factor, and replays every partition file. S3 access is curl --aws-sigv4, verified against the endpoint's CA projected from the <app>-backup-s3 Secret, 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 of a topic 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.

The script fails closed rather than reporting a partial backup or a lossy restore as success: it refuses an offset listing short of the topic's partition count, a drain short of end - begin, a topics value with a duplicate or a name outside Kafka's charset, a manifest that disagrees with the data files, a target topic that already holds records (checked before the replay, so a retried Pod cannot append a second copy), a pre-existing topic with a different partition count or replication factor, a replication factor below the target's effective min.insync.replicas, and a post-replay count that does not match the manifest. Each topic's replication factor is recorded in the manifest and restored as captured; the replicationFactor BackupClass parameter overrides it for restoring into a smaller cluster.

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 → two-broker source Kafka with a seeded 3-partition, 2-replica topic plus a decoy topic that the demo topic matches as a regex but not as a literal → BackupJob → in-place restore with count, content and replication-factor assertions → a second RestoreJob into the populated topic that must be refused with the topic untouched → two-broker target Kafka + to-copy restore + verify), mirroring mariadb-2-backup-roundtrip. Two brokers, not one, because that is the shape on which the chart sets min.insync.replicas: 2 and a restore with the wrong replication factor cannot land a record. It resolves the Kafka image from the running operator so the backup Pod reuses the image already cached on the nodes.

The strategy script itself is pinned by hack/kafka-topic-backup-strategy.bats, on the shape of hack/kafka-backup-script.bats: it renders the strategy through a stub kubectl exactly as step 01 applies it, lifts the container body out, and executes it against stubbed kafka-*.sh, a stubbed curl and a directory standing in for S3, with fault injection for the broker behaviours the guards exist for. Fourteen cases, one per guard; removing the partition-listing guard reddens one of them.

Verified: chainsaw/kafka-2-backup-roundtrip passed in this PR's CI on the two-broker shape (record count, byte-identical content dump and replication factor 2 asserted after both restores, the refused RestoreJob settled Failed with the topic untouched); hack/kafka-topic-backup-strategy.bats passes under both hack/cozytest.sh and bats.

Files:

  • examples/backups/kafka/ — 14 files (00-helpers.sh, 01..07, run-all.sh, cleanup.sh, README.md, 90/91/92 scenario walk-throughs).
  • hack/kafka-topic-backup-strategy.bats — hermetic test of the strategy script.
  • hack/e2e-chainsaw/kafka/chainsaw-test.yaml — new kafka-2-backup-roundtrip Test appended.
  • hack/backup-access-preflight.bats — the kafka example and Test join the S3-preflight roster.
  • hack/select-e2e.sh, hack/e2e-chainsaw/.chainsaw.yaml — comment-only: their enumerations of preflight-gated suites are replaced by a pointer.
  • .gitignore — the example's .source-dump.txt snapshot.

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, one unit test under hack/, 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)

@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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: a Kafka topic-data backup/restore example and an end-to-end roundtrip test.
✨ 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 Outdated
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.

Comment thread examples/backups/kafka/cleanup.sh Outdated
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 <[email protected]>
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 <[email protected]>
@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 <[email protected]>
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 <[email protected]>
@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 <[email protected]>
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 <[email protected]>
@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 (androndo) pushed a commit that referenced this pull request Aug 20, 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 <[email protected]>
Andrey Kolkov (androndo) pushed a commit that referenced this pull request Aug 20, 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 <[email protected]>
@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.

@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.

@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.

@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

NOT LGTM

Reviewed at a43d90dc5692b01bf9a05a2e7cb357dd07d5abb9, against merge-base cd91627c. The round-trip Test really did run and pass on this head (--- PASS: chainsaw/kafka-2-backup-roundtrip (558.81s), job 106676143135), and I read the run's own output rather than the badge: the freeze cut captured 8/11/11, both restores came back byte-identical to the source dump, and the decoy topic survived with its 5 records. That settles the happy path. What it does not settle is the shape of a cluster nobody tested on, and that is where both blockers sit.

Two things block. The example cannot restore at all onto a Kafka with more than one broker, which is the only shape anyone runs in production. And the guard added last round to stop a restore appending into a live topic reads a listing that Kafka documents as lossy, without the check the backup side of the same file already performs on the same listing.

Findings

[MAJOR] examples/backups/kafka/02-create-backupclass.sh:38: a restore lands zero records on any Kafka with two or more brokers

replicationFactor is fixed at "1" here, and the strategy's own fallback at 01-create-strategy.sh:56 is also "1". The Cozystack Kafka chart sets the broker-level min.insync.replicas to 2 as soon as kafka.replicas reaches 2:

$ grep -n 'min.insync.replicas' packages/apps/kafka/templates/kafka.yaml
49:      min.insync.replicas: 1
55:      min.insync.replicas: 2
61:      min.insync.replicas: 2

Line 49 is the replicas == 1 branch, 55 the replicas == 2 branch, 61 the else branch for three or more.

That broker value becomes each topic's default (KafkaConfig.scala:1133, logProps.put(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, minInSyncReplicas)). kafka-console-producer defaults --request-required-acks to -1, i.e. acks=all (core/src/main/scala/kafka/tools/ConsoleProducer.scala:222-226 in Kafka 3.9.1), and the replay at 01-create-strategy.sh:366 does not override it. Upstream is explicit about what happens next:

When a producer sets acks to "all" (or "-1"), this configuration specifies the minimum number of replicas that must acknowledge a write for the write to be considered successful. If this minimum cannot be met, then the producer will raise an exception (either NotEnoughReplicas or NotEnoughReplicasAfterAppend).
Source: clients/src/main/java/org/apache/kafka/common/config/TopicConfig.java:174-182

So the restore recreates the topic with RF=1 against a broker default of min.insync.replicas=2, and every record it replays is rejected. The rejection is not fatal to the process: the async path hands the send an ErrorLoggingCallback (ConsoleProducer.scala:118), which logs and returns, and main still exits 0. The run therefore reaches the post-replay check and dies there instead:

restore verification failed for orders.v1: topic holds 0 record(s), backup recorded 30;
refusing to report a lossy or duplicated restore as complete

which names the wrong cause. The operator is told the replay was lossy; the actual NOT_ENOUGH_REPLICAS lines are only in the strategy Pod's log.

The in-place variant is worse, because 06-restore-in-place.sh:24-37 deletes the topic first, as the documented procedure. On a 3-broker cluster that turns an RF=3 topic into an unwritable RF=1 one and then fails, with the live topic already gone. The tarball still holds the data, so this is recoverable, but only by someone who works out that replicationFactor was the problem.

README.md:18 points the other way. It lists min.insync.replicas among the topic configs that are not captured, then says restoring into a multi-broker cluster without raising replicationFactor is "a silent durability downgrade". On a stock Cozystack Kafka it is not a downgrade, it is a restore that cannot complete. Nothing catches this because the demo pins kafka.replicas: 1 (04-create-kafka.sh:24, 07-restore-to-copy.sh:24), so the single tested shape is the single shape where RF=1 works.

The cheap fix is to stop guessing the replication factor. kafka-topics --describe already prints ReplicationFactor: on the very line the backup side parses PartitionCount: out of (01-create-strategy.sh:210-211), so capturing the source RF into the manifest and using it on restore is a small change, with the replicationFactor parameter kept as an override. Failing that, refuse up front when the requested RF is below the target's min.insync.replicas rather than discovering it through a misattributed count mismatch.

[MAJOR] examples/backups/kafka/01-create-strategy.sh:328: the "refuse a non-empty target" guard reads a listing that can come back short, and nothing checks that it did

ea2124076 moved the emptiness check ahead of the replay so a retried Pod leaves a live topic untouched. The check counts records from kafka-get-offsets, and that tool skips partitions it could not read while still exiting 0:

257                try {
258                    partitionInfo = listOffsetsResult.partitionResult(partition).get();
259                } catch (ExecutionException e) {
260                    if (e.getCause() instanceof KafkaException) {
261                        System.err.println("Skip getting offsets for topic-partition " + partition.toString() + " due to error: " + e.getMessage());
262                    } else {
263                        throw e;
264                    }
265
266                    continue;
267                }

Source: tools/src/main/java/org/apache/kafka/tools/GetOffsetShell.java, Kafka 3.9.1

The backup side of this same file knows that and guards it. The restore side reads the same listing and does not:

$ sed -n '215,220p' examples/backups/kafka/01-create-strategy.sh
                  seen=0
                  for e in \${ends}; do seen=\$((seen + 1)); done
                  if [ "\${seen}" -ne "\${pc}" ]; then
                    echo "offset listing returned \${seen} of \${pc} partition(s) for \${t}; refusing to upload an incomplete backup" >&2
                    exit 1
                  fi

$ sed -n '328,343p' examples/backups/kafka/01-create-strategy.sh
                  cends=\$("\${BIN}"/kafka-get-offsets.sh --bootstrap-server "\${BOOT}" --topic "\Q\${t}\E" --time -1)
                  cbegins=\$("\${BIN}"/kafka-get-offsets.sh --bootstrap-server "\${BOOT}" --topic "\Q\${t}\E" --time -2)
                  held=0
                  for e in \${cends}; do
                    ekey=\${e%:*}
                    eo=\${e##*:}
                    bo=0
                    for b in \${cbegins}; do
                      if [ "\${b%:*}" = "\${ekey}" ]; then bo=\${b##*:}; break; fi
                    done
                    held=\$((held + eo - bo))
                  done
                  if [ "\${held}" -ne 0 ]; then
                    echo "topic \${t} already holds \${held} record(s); this restore appends and is supported only against an absent or empty topic. Empty or delete it and re-run." >&2
                    exit 1
                  fi

A leader election in flight during the check, which is the same fault the comment at :194-201 was written for, drops the partitions holding the records from cends. held then sums the surviving empty partitions to 0, the guard passes, and the replay appends a full copy into a live topic. The post-replay check at :397 catches the result and fails the RestoreJob, but the duplication has already landed, which is precisely the outcome moving the check earlier was supposed to prevent. (If every partition fails the tool errors out and set -e stops the Pod, so only the partial case is exposed.)

The fix costs nothing: have_parts is already in scope twelve lines above (:312, validated numeric at :315-317 and equal to parts at :318). Count the lines in cends and refuse when they are fewer, exactly as the backup side does.

[MINOR] examples/backups/kafka/01-create-strategy.sh:172: a topic named twice in topics yields a Backup that reports Succeeded and can never be restored

TLIST is the parameter split on commas and spaces, with no de-duplication, and for t in ${TLIST} at :187 runs the whole per-topic block once per occurrence. The manifest gains a second set of lines; the data files are written to the same names and overwrite. On restore, want at :380-384 sums every manifest line while the replay writes each data file once, so the counts can never agree:

$ bash -c '
W=$(mktemp -d); M="$W/manifest.txt"; : > "$M"
TLIST=$(printf "%s" "orders.v1, orders.v1" | tr ", " "  ")
for t in $TLIST; do for p in 0 1 2; do
  printf "%s %s %s %s\n" "$t" "$p" 0 10 >> "$M"; : > "$W/data-$t-$p.tsv"
done; done
SEEN=""; while read -r mt mp mb me; do case " $SEEN " in *" $mt "*) ;; *) SEEN="$SEEN $mt";; esac; done < "$M"
for t in $SEEN; do want=0
  while read -r mt mp mb me; do [ "$mt" = "$t" ] || continue; want=$((want + me - mb)); done < "$M"
  echo "manifest lines=$(wc -l < "$M" | tr -d " ")  data files=$(ls "$W"/data-*.tsv | wc -l | tr -d " ")  want=$want  replayed=30"
done
rm -rf "$W"'
manifest lines=6  data files=3  want=60  replayed=30

have != want at :397 then fails every restore of that backup, permanently. The records do reach the target, so nothing is lost, but the operator is told the restore failed and the backup that reported Succeeded is not usable. A sort -u on TLIST, or a refusal on a repeated name, belongs with the other fail-closed guards in this script. While you are there: for t in ${TLIST} is unquoted and path-expanded, so a topics value containing * iterates the Pod's working directory instead. It fails closed at the PartitionCount guard, but it is the one place operator input reaches an unquoted expansion.

[MINOR] examples/backups/kafka/01-create-strategy.sh:160: curl -k, where the neighbouring example mounts the CA instead

The S3 helper disables certificate verification outright. The rabbitmq example in the same tree solves it against the same in-cluster seaweedfs: run-all.sh copies the seaweedfs CA into a Secret and the strategy Pod mounts it and sets CURL_CA_BUNDLE (examples/backups/rabbitmq/00-helpers.sh:44-51, 03-rabbitmq-strategy.yaml:86-94), with its own comment noting the curl script then "stays byte-for-byte the shipped one, with no -k". Both rabbitmq-2-backup-roundtrip and redis-2-backup-roundtrip pass in the same CI run under that arrangement, so it is not a theoretical alternative.

README.md:27 documents the weakness honestly and tells the reader to mount the tenant CA before pointing this anywhere external. That is worth something, but an example is copied more often than it is read, and the pattern it should be copying already exists two directories over.

[MINOR] hack/backup-access-preflight.bats:260: the renamed test claims "every" over a hand-written list of five

The rename from "all four Chainsaw backup roundtrips" to "every Chainsaw backup roundtrip" broadened a name that was accurate about a closed list into one that asserts an enumeration the test does not perform. Eight round-trip Tests exist; five are listed:

$ for f in hack/e2e-chainsaw/*/chainsaw-test.yaml; do
    grep -q 'name: .*-2-backup-roundtrip' "$f" &&
      printf '%s\t%s\n' "$(grep -c 'COZY_E2E_BACKUP_PREFLIGHT=1' "$f")" "$f"; done
1	hack/e2e-chainsaw/clickhouse/chainsaw-test.yaml
0	hack/e2e-chainsaw/etcd/chainsaw-test.yaml
1	hack/e2e-chainsaw/kafka/chainsaw-test.yaml
1	hack/e2e-chainsaw/mariadb/chainsaw-test.yaml
1	hack/e2e-chainsaw/mongodb/chainsaw-test.yaml
1	hack/e2e-chainsaw/postgres/chainsaw-test.yaml
0	hack/e2e-chainsaw/rabbitmq/chainsaw-test.yaml
0	hack/e2e-chainsaw/redis/chainsaw-test.yaml

rabbitmq and redis both provision a Bucket and drive the same in-cluster seaweedfs, and both passed in this run, so they are exactly the population the preflight exists for. Under the new name a reader concludes they are gated on it. The other two comments this PR touches (hack/select-e2e.sh:401-406, hack/e2e-chainsaw/.chainsaw.yaml:10-13) go the opposite way and delete their stale enumerations, which is the right instinct: either derive the list from hack/e2e-chainsaw/*/chainsaw-test.yaml or put the count back in the name.

[MINOR] examples/backups/kafka/00-helpers.sh:16: the documented default namespace is the one nothing runs in

NAMESPACE defaults to tenant-test, and README.md:39 repeats it. Every other S3-backed backup example defaults to tenant-root (clickhouse, postgres, mariadb, mongodb, redis, rabbitmq, etcd, foundationdb, vmi, kafka-metadata); only this one and nats do not. The round-trip Test overrides it (chainsaw-test.yaml:186, namespace: tenant-root) for the reason its own header gives: the e2e tenant-test is created isolated, so its Cilium egress allowlist blocks it from reaching tenant-root's seaweedfs. A fresh install has no tenant-test at all, so ./run-all.sh as documented fails at step 03 on a namespace that does not exist, and the documented default is never exercised by anything.

[MINOR] examples/backups/kafka/01-create-strategy.sh:348: a restore Pod that dies mid-replay can never be retried into success

The create/shape/emptiness loop at :299-344 runs over every topic first, then the replay loop at :348-368 writes them. If the Pod dies after topic A's file is replayed and before B's, the Job's backoffLimit: 2 (internal/backupcontroller/jobstrategy_controller.go:313) starts a new Pod, which reaches the emptiness check on A, finds records, and exits. Both retries burn that way and the RestoreJob settles Failed. That is the safe outcome, but the operator is handed a message naming one topic, with nothing recording which topics completed, so the remedy is "empty every topic this backup covers and start again" rather than what the message says. README.md:22 describes the retry as merely unable to stack a second copy, which is true and leaves out the part that matters. A progress line per topic before its replay would make the log a resume map; a sentence in that bullet would set the expectation.

[MINOR] examples/backups/kafka/00-helpers.sh:59: the image lookup fails on a digest-pinned operator, which is the air-gapped case the fallback is for

resolve_kafka_image filters with grep -E '=.*/kafka:', which requires a tag reference. The vendored Strimzi chart emits @<digest> instead of :<tag> whenever a digest value is set (packages/system/kafka-operator/charts/strimzi-kafka-operator/templates/_helpers.tpl:49), so a digest-pinned deployment produces 3.9.1=quay.io/strimzi/kafka@sha256:... and the filter drops every line:

$ printf '3.9.1=quay.io/strimzi/kafka@sha256:deadbeef\n' | grep -E '=.*/kafka:' | sed 's/.*=//' | wc -l
       0
$ printf '3.9.1=quay.io/strimzi/kafka@sha256:deadbeef\n' | grep '=' | sed 's/^[^=]*=//'
quay.io/strimzi/kafka@sha256:deadbeef

The fallback then assumes quay.io/strimzi/kafka:0.45.1-rc1-kafka-3.9.1, a public image, on a cluster that pins digests precisely because it cannot reach public registries. The warning does fire, so this is not silent, but the demo lands in ImagePullBackOff and wait_for_field spends its 600s before saying so. The jsonpath already selects only STRIMZI_KAFKA_IMAGES, so grep '=' is enough and the /kafka: shape assumption can go. The copy in chainsaw-test.yaml:262-264 carries the same filter.

[MINOR] hack/e2e-chainsaw/kafka/chainsaw-test.yaml:228: still open from earlier rounds: the guards keep arriving without anything that can redden them

Carried forward and re-reached independently by a second pass this round. The suite drives run-all.sh once, on one plain topic, with no fault injection, no second backup, no pre-seeded restore target and no tampered tarball. Of the guards in the strategy script, one is reachable: removing a \Q...\E pin from the offset listing lets the decoy's partitions into the count and the partition-set check fires, which the CI run confirms end to end (Decoy topic 'orders-v1' untouched: 5 record(s)). The rest are not: the partial-drain check (:252), the manifest-missing and manifest-field checks (:267, :284), the empty-manifest refusal (:289), both PartitionCount reads (:213, :315), the shape mismatch (:317), the emptiness refusal (:340), the data-file-versus-manifest check (:361) and the post-replay failure branch (:398).

The sharpest instance is the parameter default. topics defaults to empty, which selects every non-internal topic, and README.md:21 calls that "the riskiest, not the safest". 02-create-backupclass.sh:37 always pins a single topic, so the --list fail-closed path and the internal-topic filter at :174-182 never execute in CI, and neither does any multi-topic restore. Two of those would be cheap to cover without a fault injector: a second BackupClass with topics: "" exercising the all-topics path and the decoy's exclusion, and a second RestoreJob against the already-restored topic to drive the emptiness refusal.

[NIT] examples/backups/kafka/05-create-backupjob.sh:36: resolves the Backup's name, then throws it away

backup_ref is read from status.backupRef.name, checked non-empty, logged, and never used; 06 and 07 reference ${BACKUPJOB_NAME} instead. That is correct today, because createJobBackupArtifact names the Backup after the BackupJob (internal/backupcontroller/jobstrategy_controller.go:348), but the resolved value is the one that cannot go stale. Cache it to a file beside .backup-name.env, which .gitignore:125 already covers for the other examples, and use it.

[NIT] examples/backups/kafka/00-helpers.sh:289: the decoy count sums end offsets without subtracting begin

decoy_message_count adds ${e##*:} straight up, where topic_message_count eleven lines below subtracts the begin offset per partition. Equivalent for a freshly seeded topic and wrong for anything with retention behind it. Since this one is a regression fixture, that difference will read as a broken pin the first time someone reuses the helper.

[NIT] examples/backups/kafka/: no 90/91/92 scenario docs

clickhouse and nats, the two siblings this example copies its 00..07 layout from, both ship 90-scenario-admin-prepare.md, 91-scenario-user-backup.md and 92-scenario-user-restore.md alongside it. The README here covers the same ground in prose, so this is only a consistency note.

Checked and correct

Every finding from the previous round came back closed except the fixture one, and the two that were still open are both closed at this head: the delete-wait now matches with grep -qxF (06-restore-in-place.sh:31), and the host-side count defaults a missing begin offset to 0 instead of dropping the partition (00-helpers.sh:319). The preflight wiring is genuinely pinned now: deleting the call from 03-create-bucket.sh and stripping COZY_E2E_BACKUP_PREFLIGHT=1 from the Test were both run as mutations and both reddened hack/backup-access-preflight.bats, with the tree restored clean afterwards.

The \Q...\E design holds up against upstream rather than against its own comments: --topic is a regex in both GetOffsetShell and TopicCommand, and both route it through IncludeList, which calls String.matches, so the match is full-string anchored and Kafka's own [a-zA-Z0-9._-] name charset means the quoting cannot be escaped. The calls left unpinned are the ones that take a literal name. The template context is real (.Release.Name, .Release.Namespace, .Mode, .Parameters, .Backup.ApplicationRef.Name all come out of renderJobTemplate), the SRC_NAMESPACE reasoning holds because targetApplicationRef is a TypedLocalObjectReference and the restore Job always runs in restoreJob.Namespace, the job.strategy.backups.cozystack.io/mode selector in catch matches a label that really lands on the Pod template, and the fallback image literal really is what the vendored chart ships. hack/select-e2e.sh and hack/e2e-chainsaw/.chainsaw.yaml changed only comments, verified by diffing both files with comment lines stripped, and the Chainsaw file is append-only, so nothing existing moved.

What would change my mind

On the first blocker: a run of this example against a Kafka with kafka.replicas: 3 that restores successfully. If min.insync.replicas turns out not to reach the recreated topic on a Strimzi-managed cluster the way KafkaConfig.extractLogConfigMap says it does, the finding is mine to withdraw.

On the second: an argument that kafka-get-offsets cannot return a partial listing here, or a decision that the pre-replay check is advisory and the post-replay one is the real guard, in which case say so in the comment rather than leaving the two sides of the file asymmetric.

@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

NOT LGTM. The replication-factor fix is real and is now pinned by a fixture; the partition-listing fix is real and is pinned by nothing. The branch also no longer merges.

What this round closed

Both blockers from the last round are fixed, and the fix for the first one brought the demo up to a shape that can actually catch it.

  • 02-create-backupclass.sh no longer pins replicationFactor: "1". The backup reads ReplicationFactor off the same kafka-topics --describe line the partition count comes from, writes it as the manifest's fifth field, and restore recreates the topic with it, with the parameter surviving only as an override.

  • A backup taken by the previous revision carries a four-field manifest line. That case fails closed rather than falling back to 1, and the guard that does it is load-bearing:

    $ printf 'legacyTopic 0 0 30\n' > manifest.txt   # 4 fields, pre-fix format
    $ bash mcheck.sh                                  # the :356-366 validation loop, verbatim
    manifest line 1 (topic legacyTopic): non-numeric field ''; refusing to restore from a malformed backup
    exit=1
    
  • The demo runs two brokers with two-replica topics, which is the shape the chart puts min.insync.replicas: 2 on, and steps 06 and 07 assert the restored replication factor. The e2e is the evidence that this is not decorative: chainsaw/kafka-2-backup-roundtrip passed on the previous head with replicationFactor: "1" and one broker (558.81s), and on this head it prints the assertions firing:

    OK Topic 'orders.v1' holds 30 record(s) at replication factor 2.
    OK Verified after in-place restore: 30 record(s) ... at replication factor 2, content matches source.
    OK Restore into the populated topic was refused (3 attempt(s)) and left it untouched.
    OK To-copy restore verified: ... at replication factor 2, content matches source.
    

Fifteen of the seventeen items I had open are closed. Credit also for the resolved Backup name cached in .backup-name.env, the CA verification replacing curl -k, the tenant-root default, the digest-tolerant image lookup, the topics charset and duplicate guards, and the three scenario walk-throughs.

Findings

[MAJOR] examples/backups/kafka/01-create-strategy.sh:192-195, the partition-listing guard reddens nothing when removed

topic_records' seen -ne want_parts check is the whole of the second fix: without it a listing shortened in flight sums the surviving partitions to zero and the replay appends a second copy into a live topic. Delete those four lines and every fixture in the tree stays green. The guard only fires when kafka-get-offsets skips a partition, nothing in examples/backups/kafka or hack/e2e-chainsaw/kafka induces that, and the second RestoreJob the round adds exercises the emptiness refusal on a healthy listing, which is a different branch. A mutation run against the extracted script with stubbed kafka-*.sh reproduces it: head refuses, the mutant appends 30 records into a populated topic, and the happy path run-all.sh drives is rc=0 either way.

The test is feasible without a cluster: lift args[0] out of the rendered strategy, stub /opt/kafka/bin, drive it from bats. The same harness would cover the eight earlier rounds' guards, none of which has one either. What would change my mind: a line in the README ruling that an example's embedded script is out of scope for in-tree tests. Then this closes as a decision rather than a gap.

[MINOR] examples/backups/kafka/01-create-strategy.sh:427-435, the shape check reads PartitionCount and not ReplicationFactor

Both come off the same --describe. Restoring into a pre-existing topic whose replication factor differs from the backup's is accepted, and the log line at :448 then reports the manifest's value rather than the topic's. On a min.insync.replicas: 2 broker a pre-existing rf=1 topic takes the replay, lands nothing, and fails on a record count that names neither cause. Parse rf beside have_parts and compare it.

[MINOR] examples/backups/kafka/01-create-strategy.sh:358, the topic name is the one manifest field restore does not validate

mp/mb/me/mr are checked numeric, mt only non-empty, and mt is what reaches for t in ${SEEN} at :407 unquoted with globbing on, because set -f is in the backup branch only (:244):

$ ls
data-a-0.tsv  g.sh  manifest.txt
$ mt='*'; SEEN=" ${mt}"; for t in ${SEEN}; do printf 'topic=[%s]\n' "$t"; done
topic=[data-a-0.tsv]
topic=[g.sh]
topic=[manifest.txt]

It ends fail-closed at Kafka's --create, so this is legibility rather than damage, but the backup side already applies [A-Za-z0-9._-] to exactly this at :247-249.

[MINOR] hack/e2e-chainsaw/kafka/chainsaw-test.yaml:179-183, the stated reason for the .svc endpoint is not true

The comment says the serving cert's SANs are *.tenant-root.svc and *.tenant-root.svc.cluster.local, and that seaweedfs-s3.tenant-root matches neither. With s3.tlsSecret unset, which it is, the S3 listener serves seaweedfs-client-cert, and the two-label wildcard is its first SAN:

$ helm template swfs packages/system/seaweedfs --namespace tenant-root \
    -s charts/seaweedfs/templates/s3/s3-deployment.yaml | grep cert.file
  -cert.file=/usr/local/share/ca-certificates/client/tls.crt

$ helm template swfs packages/system/seaweedfs --namespace tenant-root \
    -s charts/seaweedfs/templates/cert/client-cert.yaml | sed -n '/dnsNames/,+3p'
  dnsNames:
    - '*.tenant-root'
    - '*.tenant-root.svc'
    - '*.tenant-root.svc.cluster.local'

Four sibling suites (postgres, clickhouse, mongodb, mariadb) pass in CI on the two-label form every run. The .svc name is fine and I would keep it; the sentence explaining why will send the next person debugging a TLS failure down the wrong path. The same parenthetical is already wrong in examples/backups/rabbitmq/run-all.sh:23.

[MINOR] examples/backups/kafka/03-create-bucket.sh:77,81, a denial reads as an absence, and the persona column disagrees with the sibling

Both lookups discard stderr, so Forbidden on the cross-namespace read is indistinguishable from "not there" and surfaces as No seaweedfs CA Certificate found in tenant-root; set S3_CA_SECRET explicitly. Setting it explicitly cannot fix a 403. S3_CA_NAMESPACE is pinned to tenant-root independently of NAMESPACE, and the README invites pointing NAMESPACE at another tenant. Meanwhile README.md:39 still labels step 03 tenant, where the clickhouse example doing the same cross-namespace CA read labels it tenant, plus admin for the CA copy. One of the two is wrong.

[NIT] examples/backups/kafka/06-restore-in-place.sh:127 against :130

The comment says every attempt must have stopped at the emptiness check; the check accepts one. verify_restored afterwards covers the real risk, so this is the sentence rather than the logic.

[NIT] examples/backups/kafka/01-create-strategy.sh:186, called as $(topic_records ...) || exit 1, errexit is off inside the function

Confirmed on bash 3.2 and 5.1: a failing command anywhere in a function invoked as the operand of || does not abort it, execution continues, and the || never fires. The helper is safe today only because the seen -ne want_parts recount turns any such failure into return 1. Since this is now the calling convention for the helper, a future command added to it will be swallowed silently.

[NIT] examples/backups/kafka/01-create-strategy.sh:409-413, the manifest's replication factor has no agreement check

rf=${mr} takes the last matching line and never compares the lines with each other, where the partition count gets an explicit shape check. Only reachable through a tampered tarball, which is the trust boundary the surrounding code otherwise treats with real suspicion.

Still open from my earlier rounds

One item, at two anchors, open for a second round: hack/e2e-chainsaw/kafka/chainsaw-test.yaml (rounds_open=1). Guards keep arriving and nothing can fail when one is removed. This round improved it, the emptiness refusal and the replication factor are now genuinely driven, but the partial-drain check, the manifest validations, the data-file cross-check, both PartitionCount parses and the post-replay check still have no fixture, live or offline, and the all-topics path is never exercised because the BackupClass pins one topic. The MAJOR above is the same finding reaching this round's own fix, found independently by two passes.

Claim mismatches in the PR body

  • "Verified end to end on a live single-broker cluster" is the evidence offered for a defect that only appears at two or more brokers. That run is what the CI at the previous head also did, green, with the hardcoded replicationFactor: "1" in place.
  • "a seeded 3-partition topic" is now 3 partitions at 2 replicas, plus a decoy.
  • "examples/backups/kafka/ — 11 files" is 14, and the file list omits hack/select-e2e.sh, hack/e2e-chainsaw/.chainsaw.yaml, hack/backup-access-preflight.bats and .gitignore.

Caveats

  • The branch conflicts with main. hack/e2e-chainsaw/kafka/chainsaw-test.yaml is the only file changed on both sides since the merge base, and the API reports mergeable: CONFLICTING.
  • timeout: 40m on the run step is unchanged while the flow grew a second broker per cluster and a third RestoreJob. The head's run took 806s, so there is headroom, but I did not measure the worst case.
  • Everything I ran was offline: shellcheck (info only), bats hack/backup-access-preflight.bats (7/7), hack/select-e2e.sh on the changed file list (selects the full suite), helm template of the seaweedfs certificate and S3 deployment, and shell-level reproductions of the manifest validation and the glob. Broker-side behaviour is read from the e2e log on this head, not executed here.
  • No chart, Go package or values.yaml is touched, and nothing under examples/ is packaged into an artifact, so there is no upgrade or fresh-install surface to judge.

Follow-ups, outside this diff

  • examples/backups/nats/01-create-strategy.sh:112-115 still carries curl -fsS -k with the exact sentence this PR retracted from the kafka example.
  • examples/backups/kafka-metadata/README.md:40 claims the source replication factor is applied as-is while its demo runs single-broker clusters, which is the blind spot this PR just removed from its own demo.
  • The preflight roster at hack/backup-access-preflight.bats:260 is hand-maintained and correct today. redis and rabbitmq drive the same Bucket and endpoint without it. Deriving the list would keep it correct without a rename each time.

Andrey Kolkov added 17 commits September 23, 2026 17:44
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.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
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 <[email protected]>
Assisted-by: LLM
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 <[email protected]>
Assisted-by: LLM
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.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
… Job logs

Two CI-quality fixes, 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 <[email protected]>
Assisted-by: LLM
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.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
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.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
Two accuracy fixes in the all-topics path and the in-place delete-wait.
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).

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
- Short-drain guard: kafka-console-consumer exits 0 after a timed-out
  short read, so a truncated drain was tarred and reported Succeeded.
  Compare the drained line count against end-begin per partition and
  fail the backup on any shortfall, making the README's "fails closed"
  promise real for compacted/transactional topics.
- Settling teardown: cleanup.sh returned before the Kafka apps and
  Bucket had uninstalled and could not report failure, letting the
  neighbouring chainsaw/kafka Test contend on PVC reclaim. Add a
  wait_deleted helper and a teardown_rc gate that waits for the heavy
  objects and exits non-zero when the teardown does not settle.
- Fully qualify the ambiguous delete kinds (backup/bucket/backupclass/
  restorejob/backupjob) so a bare shortcut cannot target a foreign group
  and silently leave the Cozystack artifact behind.
- Default KAFKA_IMAGE from the running operator (newest STRIMZI_KAFKA_
  IMAGES entry), falling back to the literal only when the operator
  cannot be read; drop the duplicate resolution note.
- Make the strategy and throwaway CLI Pods restricted-clean
  (runAsNonRoot + JSON-patch securityContext on the CLI Pods).
- gitignore .source-dump.txt; replace the deprecated "small" preset with
  c1.small; capture the to-copy cluster in the e2e catch; document the
  all-topics risk; de-enumerate the decaying suite-list comments.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
- Restore verification: kafka-console-producer, like the consumer on
  backup, exits 0 without every record being accepted, so a replay that
  dropped records still reported Succeeded. After the replay, compare
  each restored topic's record count (sum of end-begin over partitions)
  against the manifest and fail on any shortfall or overshoot - the
  latter being a restore into a non-empty topic, which this appending
  DATA restore does not support.
- Namespace-scope the S3 object key (<namespace>/<app>/kafka-topics.tar)
  so two same-named apps in different namespaces sharing one bucket do
  not collide. The namespace is .Release.Namespace in both modes and
  always names the source, since a to-copy target shares the source's
  namespace (a TypedLocalObjectReference).
- Warn instead of failing open when the Kafka image cannot be resolved
  from the operator: the pinned public literal is unreachable on an
  air-gapped cluster, so tell the caller to set KAFKA_IMAGE.
- Word the .chainsaw.yaml namespace note as "most" (etcd-2-backup-
  roundtrip sets no namespace) and document the empty all-topics backup.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
The drain guard only covers a short read within a partition it was told
about. kafka-get-offsets lists just the partitions whose lookup
succeeded - GetOffsetShell logs a per-partition KafkaException to stderr,
skips it and still exits 0 - so a partition lost to a leader election got
no manifest line, no data file and no drain check, and the tarball was
uploaded with the Backup reporting Succeeded. The restore-side count
check could not catch it either, since it sums the same short manifest,
and a missing highest-numbered partition made restore recreate the topic
with fewer partitions, re-placing every keyed record.

Read the topic's own PartitionCount before the drain and fail closed when
the offset listing is short. A short "begins" needs no separate check:
its per-partition default of 0 can only overstate n, which the existing
drain check then catches.

Also run the shared BucketAccess preflight in the e2e - accessGranted
does not prove the S3 server reloaded the new identity, and a fresh grant
answering the SigV4 PUT with 403 reds the run as a backup defect - and
state the .chainsaw.yaml namespace override as the rule it is rather than
as a claim about which Tests match a name pattern.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
kafka-topics and kafka-get-offsets compile --topic into a Java regex and
full-match it, so any name carrying a metacharacter also selects its
siblings - "audit.events" matches "audit-events". Two consequences here:

- The in-place step's `--delete --topic "$TOPIC"` deleted every topic the
  name happened to match, which for a demo meant destroying a bystander
  topic to "simulate data loss".
- The partition-set guard read another topic's partitions into `ends` and
  another topic's PartitionCount into `pc`, so it either refused a legal
  backup or, when the counts coincided, wrote duplicate manifest lines and
  overwrote a data file.

Pin every filtering call with \Q...\E, the same fix the kafka-metadata
example carries. --create and the console producer/consumer take a literal
name and stay unquoted.

Also: verify the restore target's partition count matches the backup, since
--if-not-exists leaves an existing topic's shape alone and the post-replay
total cannot see re-placed records; stop wrapping the whole kafka_run in
2>/dev/null, which hid pod and API failures rather than in-pod noise; and
point the warned image fallback at the newest entry of the operator's map,
the one the resolver itself picks, instead of the oldest.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
…he suite

The manifest arrives inside the downloaded tarball but was consumed as
trusted input. A non-numeric partition field slipped through the numeric
test that computes a topic's partition count: an if condition is exempt
from errexit, so test exits 2, the term reads false, the count comes out
short, the topic is created smaller, and the shape check then compares
that short value against itself and passes. The per-topic total still
added up, so the restore reported Succeeded with every keyed record
re-placed. Validate each manifest field on read, refuse an empty manifest
whose verification loops would otherwise run zero times and report a
successful restore of nothing, and guard the restore-side PartitionCount
read the way the backup side already guards its own.

The delete-wait still matched the topic name as a regex; use grep -qxF.
The suite now pins that class of bug instead of relying on review: the
demo topic is orders.v1 and a decoy ordersXv1 sits beside it, matching the
first as a regex but not as a literal. The decoy is never named in the
BackupClass, so dropping any \Q...\E pin fails the run on its own - the
partition-set guard counts the decoy's partitions, or the in-place delete
takes the decoy with it.

Also: the host-side record count skipped a partition missing from the
begin-offset listing instead of defaulting it to 0, undercounting the
topic; the two preflight invariants now cover the kafka flow they already
describe; and the README names the disabled TLS verification.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
The replay appends and the driver retries a Pod that died mid-replay, so
checking the record count only afterwards meant every attempt had already
written another full copy into a live topic and the check merely reported
the damage. Read the target's count before replaying and refuse a topic
that already holds records, leaving it as the failed attempt left it; the
post-replay check still verifies what landed.

Name the decoy so it sorts before the demo topic. Both PartitionCount
reads pipe --describe through head -1, so an unpinned describe reads
whichever matching topic comes first: with the decoy sorting after the
topic those two pins were never exercised and could be removed with the
suite staying green. "-" precedes ".", so orders-v1 now sorts first and
an unpinned describe reads its partition count instead.

Also state in the README that an empty backup is refused on restore
rather than merely uploaded, so the empty-tarball note does not read as
if such an artifact were usable.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
The begin-offset lookups matched on the partition index alone, so a line
belonging to another topic could supply this partition's begin. The cut
then shifts silently and the drain check cannot see it, because n is
derived from that same wrong begin. Match on the whole topic:partition
key in all four lookups, which holds whatever order the listing arrives
in and no longer depends on the topic filter being pinned.

Hold the tarball's file names to the manifest as well. The manifest is
validated as untrusted input, but the replay derived its topic from the
data file names, so a file naming a topic the manifest never listed was
replayed into a topic that skipped the create, shape, emptiness and
post-replay checks while the restore still reported success.

Correct what the decoy is documented to prove. It reliably covers the
offset listing, the in-place delete and the two record counts; it does
not cover the two PartitionCount reads, because kafka-topics --describe
yields topics in the admin client's result-map order rather than sorted,
so which one their head -1 sees is not something a fixture can pin down.
The previous text claimed every pin reddens the run, and explained the
decoy's name by a sort order that does not govern that output.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-by: LLM
…te listing

Record each topic's ReplicationFactor in the backup manifest, off the
same --describe line the partition count already comes from, and
recreate the topic with it on restore. The value used to be a
BackupClass parameter defaulting to 1, and the Cozystack Kafka chart
sets the broker-level min.insync.replicas to 2 as soon as kafka.replicas
reaches 2: kafka-console-producer writes with acks=all, the broker
rejects every write into a topic with fewer replicas than that, and the
producer logs the rejection and exits 0. A restore onto any multi-broker
cluster therefore landed zero records and failed the post-replay count
with a message naming the wrong cause; in place, the documented delete
had already taken the live topic with it. replicationFactor stays as an
override for restoring into a smaller cluster, and a value the target
cannot host now fails at --create in Kafka's own words.

Hold the pre-replay emptiness check to the topic's partition count, as
the backup side already does. kafka-get-offsets skips a partition it
could not read and still exits 0, so a leader election in flight dropped
the populated partitions from the listing, the surviving empty ones
summed to zero, and the replay appended a full copy into a live topic -
the exact outcome the check was moved ahead of the replay to prevent.
Both restore-side counts now go through one helper that refuses a short
listing. The data-file-versus-manifest check runs both ways before
anything is created, and the replay walks the manifest topic by topic,
announcing each one, so a retried Pod's log says which topics landed.

The demo now runs two brokers with two-replica topics, which is the
shape the chart puts min.insync.replicas=2 on; the restore steps assert
the restored topic's replication factor, and step 06 submits a second
RestoreJob against the populated topic and requires it to be refused
with the topic untouched.

Smaller findings from the same round: the topics parameter is held to
Kafka's name charset (a literal "*" no longer path-expands) and refused
on a repeated name, which produced a Succeeded backup no restore could
balance; the strategy verifies S3 TLS against the seaweedfs CA projected
into the <app>-backup-s3 Secret instead of curl -k, following the
rabbitmq example, so the e2e endpoint moves to the .svc name the serving
cert covers; the image lookup keeps digest-pinned references; the
default namespace is tenant-root, the only one the demo ever ran in;
step 05 caches the resolved Backup name for the restore steps; the decoy
count subtracts begin offsets like the topic count; the bats test name
carries its count again; and the scenario walk-throughs the sibling
examples ship are added.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-By: LLM
The in-Pod script had grown eight fail-closed guards across as many
rounds, and the only thing exercising any of them was a 40-minute
cluster test that drives the happy path plus one refusal. The partition
listing guard in particular - the whole of the previous round's second
fix - could be deleted with nothing in the tree going red. Add
hack/kafka-topic-backup-strategy.bats on the shape of
hack/kafka-backup-script.bats: render the strategy through a stub
kubectl exactly as step 01 applies it, lift the container body out of
it, and execute it against stubbed kafka-*.sh, a stubbed curl and a
directory standing in for S3, with fault injection for the broker
behaviours the guards exist for. Deleting that listing guard now fails
one case; the script gains BIN/SCRATCH/CA_FILE overrides so it can run
outside the Pod, each defaulting to the Pod's value.

The same rounds' smaller findings: the manifest's topic name is held to
Kafka's charset like the topics parameter, since it reaches unquoted
expansions with globbing on; the shape check after --create compares
the replication factor as well as the partition count, and refuses a
replication factor below the topic's effective min.insync.replicas
before replaying, because kafka-console-producer only logs a rejected
acks=all write and the run would otherwise end at the count check with
the cause unnamed; the manifest's replication factor must agree across
a topic's lines; topic_records carries its own || return 1 on each
command, as a function called as the operand of || runs with errexit
off; the CA lookup in step 03 distinguishes a denied read from an
absent Secret, and the README's persona column says the copy needs the
admin; the e2e comment no longer claims the 2-label endpoint fails the
serving cert's SANs, which include '*.tenant-root'.

Rebased onto main, which moved the chart to KRaft: the demo's Kafka CRs
drop the zookeeper block the chart no longer has.

Signed-off-by: Andrey Kolkov <[email protected]>
Assisted-By: LLM

@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

Round 2's blocker is genuinely closed: the partition-listing guard is now pinned, and I confirmed it by deleting the four lines and watching the suite go red under the runner CI actually uses, not only under bats. Eighteen of the twenty guards I mutated redden exactly one case each, the branch conflict from round 2 is gone, and the E2E run on this head is green. Nothing below blocks a merge.

Two of the findings are worth picking up, either before this lands or straight after. One new guard reads the lowest-precedence synonym and so can never fire on a real broker, which means it does not yet do the job its own README describes. And one comment this round rewrote is now false: that second one is my fault, not yours, and I say so below.

Findings

[MINOR] examples/backups/kafka/01-create-strategy.sh:476-484: the min.insync.replicas guard parses the lowest-precedence synonym, so it reads 1 on every Cozystack Kafka

kafka-configs --describe --all prints one line per config and that line carries the synonym list after sensitive=, ordered by descending precedence. The sed has a greedy leading .*, so it captures the last occurrence on the line, normally DEFAULT_CONFIG:min.insync.replicas=1, instead of the effective value. packages/apps/kafka/templates/kafka.yaml:53-59 sets broker-level min.insync.replicas: 2 whenever kafka.replicas is 2, which is exactly the shape this guard exists for, so misr comes out 1 and [ "${have_rf}" -lt 1 ] is false for every legal replication factor. Line 481 is dead.

The repo already has the right idiom for this same line, in the driver this example is modelled on:

$ sed -n '118p;133p' packages/system/backupstrategy-controller/files/kafka-backup.sh
    # ("  key=value sensitive=... synonyms=..."), so a value that contains a
        kv=${cl%% sensitive=*}

$ echo '  min.insync.replicas=2 sensitive=false synonyms={STATIC_BROKER_CONFIG:min.insync.replicas=2, DEFAULT_CONFIG:min.insync.replicas=1}' \
    | sed -n 's/.*min\.insync\.replicas=\([0-9][0-9]*\).*/\1/p' | head -1
1

Consequence: replicationFactor: 1 restored into a two-broker target passes the guard, the topic is created with rf=1, every acks=all write is rejected, and the run dies at the post-replay count with "topic holds 0 record(s), backup recorded 30": the misleading shortfall the guard was added to replace, and the one README.md:18 promises it prevents.

The test cannot see this. hack/kafka-topic-backup-strategy.bats:153 writes ${MISR} into both the value and the synonym, so the one line shape a real broker always produces is the one shape the stub never emits. Case 11 detects the guard being deleted (my mutation reddens it) and not the guard being wrong. Cutting at sensitive= like kafka-backup.sh does, plus a stub case where the two differ, closes both halves.

[MINOR] hack/e2e-chainsaw/kafka/chainsaw-test.yaml:146-151: the reworded SAN comment is false, and the previous wording was right

This round replaced the comment with "whose SANs cover *.tenant-root, *.tenant-root.svc and *.tenant-root.svc.cluster.local, so the 2-label name would verify too". A wildcard whose remainder is a single label does not match in any mainstream TLS stack. On a synthetic certificate carrying exactly those three SANs:

$ openssl verify -CAfile c.pem -verify_hostname seaweedfs-s3.tenant-root c.pem
error 62 at 0 depth lookup: hostname mismatch
$ openssl verify -CAfile c.pem -verify_hostname seaweedfs-s3.tenant-root.svc c.pem
c.pem: OK

$ curl -sS --cacert c.pem --resolve seaweedfs-s3.tenant-root:18443:127.0.0.1 https://seaweedfs-s3.tenant-root:18443/ -o /dev/null
curl: (60) SSL: no alternative certificate subject name matches target host name 'seaweedfs-s3.tenant-root'
$ curl -sS --cacert c.pem --resolve seaweedfs-s3.tenant-root.svc:18443:127.0.0.1 https://seaweedfs-s3.tenant-root.svc:18443/ -o /dev/null ; echo rc=$?
rc=0

This one is on me. My round-2 note said the comment's stated reason was untrue because *.tenant-root is the cert's first SAN, and you rewrote it to match. The SAN is there; it just does not match a 2-label name. Three neighbours in the tree still carry the correct statement and are worth copying rather than re-deriving: hack/e2e-chainsaw/rabbitmq/chainsaw-test.yaml:84-88, hack/e2e-chainsaw/redis/chainsaw-test.yaml:663-665, examples/backups/rabbitmq/run-all.sh:20-23. The same sentence also went into the commit message of 7ce85a2f7. Nothing catches it because the harness uses the 3-label name, so the comment is unexercised prose.

[MINOR] examples/backups/kafka/01-create-strategy.sh:366-385: a duplicated (topic, partition) manifest line is the one malformed shape the untrusted-tarball validation misses

The validation loop checks each line's topic charset and that four fields are numeric; it never checks that a pair appears once. The replay at :508-513 runs the producer once per matching line and the expected total at :529-533 accumulates me - mb once per matching line, so both double and have == want balances. Run against a tarball this script itself produced, with one line duplicated:

orders.v1 0 0 10 2
orders.v1 0 0 10 2
orders.v1 1 0 10 2
orders.v1 2 0 10 2
  orders.v1: restored 40 record(s)
restore complete
RECORDS=40

The backup side refuses a duplicate topic for exactly this reason (:249-253), and :536 says the check exists "refusing to report a lossy or duplicated restore as complete": a duplicated restore is what it passes. The precondition is a corrupt or tampered tarball, which is narrow, but it is inside the threat model the code states for itself at :356-357.

[MINOR] examples/backups/kafka/01-create-strategy.sh:232-233: a topics value of separators only backs up nothing and reports Succeeded

The branch is chosen by [ -n "${TOPICS}" ] on the raw string. tr ', ' ' ' turns ",", ", ," or " " into whitespace, the loop iterates zero times, and tar plus PUT still run: there is no [ -n "${TLIST}" ] gate before :269, though the restore side has exactly that gate at :385:

backing up topics [] from kafka-x-kafka-bootstrap.tenant-root.svc:9092
uploaded s3://bkt/tenant-root/kafka-test/kafka-topics.tar
MANIFEST_BYTES=0

Because the S3 key carries no run identifier (README.md:25), this overwrites the previous good tarball, and the loss surfaces only at restore time as "manifest is empty".

[MINOR] hack/backup-access-preflight.bats:242-258: still open from round 2: the roster is five hand-written paths, and the title claims "all"

The word "every" is gone, but :244-248 is still a literal list, not a glob over examples/backups/*/. A new example that waits on accessGranted without calling the preflight is not noticed: adding one and re-running left the case green. The title is also wrong on the tree as it stands: ten example directories gate on accessGranted and five run the preflight, and Kafka is not a database flow. The twin case twelve lines below was renamed this round for the same reason ("all four" to "the five preflight-wired"); this one was left.

[MINOR] ZooKeeper survives in text this PR added, in a KRaft-only tree

hack/e2e-chainsaw/kafka/chainsaw-test.yaml:220 says each cluster "boots ZooKeeper then the brokers", 217 lines below the same file's own header ("KRaft mode ... no ZooKeeper") and 111 lines below an assertion that the ZooKeeper client Service must never exist. examples/backups/kafka/cleanup.sh:35 waits on "two StatefulSets, ZooKeeper and their PVCs". Both lines are additions in this PR, and the same push removed the zookeeper: block from 04-create-kafka.sh and 07-restore-to-copy.sh because the chart no longer has the field.

[NIT] examples/backups/kafka/01-create-strategy.sh:243: || true reinstates the swallow the comment above it just removed

:235-241 explains at length that --list runs as its own command so a broker error aborts under set -e. The next line's grep -v '^__' || true cannot tell "no non-internal topics" (rc 1) from an I/O error (rc 2), and rc 2 yields an empty TLIST and the empty-backup path above. packages/system/backupstrategy-controller/files/kafka-backup.sh:127-130 checks rc -eq 1 explicitly for the same reason.

[NIT] examples/backups/kafka/01-create-strategy.sh:437,465: replicationFactor reaches Kafka unvalidated and comes back as a string comparison

Every other operator-supplied value gets a case guard (topics at :258, each manifest field at :377-383); rf=${REPLICATION_FACTOR} does not. :465 then compares it as a string against what the broker reports, where its sibling at :461 compares a number the script computed. Any value Kafka normalises rather than rejects makes the run abort with "refusing to restore into a pre-existing topic with a different replication factor" against a topic it created seconds earlier. I have not run this against a real broker, so treat the normalisation as reasoned, not measured.

[NIT] the retracted "every attempt" wording survives in two places

06-restore-in-place.sh:126-129 was weakened this round from "Every attempt must have stopped at the emptiness check" to "At least one attempt must have logged the refusal". hack/e2e-chainsaw/kafka/chainsaw-test.yaml:222 and examples/backups/kafka/92-scenario-user-restore.md:35 still carry the stronger claim.

Status of findings from my earlier rounds

Closed, each with the command behind it:

  • The partition-listing guard reddens nothing (MAJOR, round 2). Closed. Deleting 01-create-strategy.sh:201-204 reddens case 9 and nothing else, under bats and under the runner CI uses: hack/cozytest.sh hack/kafka-topic-backup-strategy.bats exits 1 with "restore refuses a pre-replay listing short of the partition count". The suite runs in CI on this head: job 107215023221 shows all 14 cases, auto-discovered by BATS_UNIT_FILES in the Makefile, so nothing needed wiring.
  • Shape check read PartitionCount and not ReplicationFactor (MINOR, round 2). Closed: :454, :458-460, :465-468. Deleting :465-468 reddens case 10.
  • The manifest topic name was unvalidated (MINOR, round 2). Closed: :372-376. Deleting them reddens case 13.
  • A denial read as an absence in 03-create-bucket.sh (MINOR, round 2). Closed at :81-89: only NotFound earns the discovery pass, anything else names the namespace and exits 1.
  • The persona column disagreed with the ClickHouse example (MINOR, round 2). Closed: README.md:39 now matches examples/backups/clickhouse/README.md:30.
  • Nothing in the chainsaw Test can redden its own guards (MINOR, open two rounds). Closed. Both script ops carry check: ($error == null): true, run-all.sh runs each step bare under set -euo pipefail, and each of the five assertions fails on a wrong value: exact equality on the count (06-restore-in-place.sh:71), diff against a source dump pinned non-empty at 04-create-kafka.sh:62, an RF compared against a variable the strategy Pod never sees, wait_for_field given Succeeded as a terminal fail-value, and the decoy count. The || true and 2>/dev/null shapes present sit on log fetches, not on verdicts.
  • errexit is off inside topic_records (NIT, round 2). Closed in effect. Not every command carries || return 1, but the two that can fail do, and fault injection shows the rest either cannot fail silently or die as a fatal expansion error that kills the substitution subshell, so || exit 1 still fires.
  • The manifest's replication factor had no agreement check (NIT, round 2). Closed: :431-434, reddens case 13 when deleted.
  • 06-restore-in-place.sh comment against its own check (NIT, round 2). Closed at :126-132; the residue is the NIT above, in two other files.

Still open: the preflight roster, filed above.

The branch conflict from round 2 is gone: GitHub reports mergeable: MERGEABLE, and the E2E run on this exact head is green.

Mutation coverage, measured

I deleted twenty guards one at a time and ran the full suite against each. Eighteen redden exactly one case and no other. The two that redden nothing are :355 (manifest file missing) and :368 (manifest line with no topic); I checked both, and with the guard gone the script still fails closed: set -e on the redirect, and the numeric-field check at :377-383 respectively. They improve the message, not the outcome, so the suite not seeing them is fair. "Fourteen cases, one per guard" in the PR body is the one claim I would reword: the mapping is many guards to one case in several places, which is fine, but it means a reader cannot tell from the count which guards are held.

@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.

Both blockers are addressed, and the fixes go further than the review asked for.

The exact-count comparison plus the byte-equality diff against the source snapshot now prove that keys, values, partition placement and per-partition ordering survived the round trip, rather than just the record count. And instead of only making a double-replay fail an assertion, the driver refuses a non-empty target before replaying, with the partition-count shape check and the post-replay verification alongside it — so a retried Pod leaves the topic untouched rather than stacking a second copy onto a partial one. Driving that refusal path in the in-place step, requiring it to settle Failed, and then re-running the full verification to prove the topic came back unchanged is a better test than the one suggested.

The non-blocking items are all either fixed or documented. The two left as documentation — the unbounded ephemeral-storage usage and the per-run S3 key — are the right call for an example, and the second genuinely needs a driver change, since the Backup artifact does not exist at render time.

Worth calling out separately, because none of it came from the review: the Java-regex topic matching, the ^_ to ^__ internal-topic filter, the short-drain detection for compacted and transactional topics, and the min.insync.replicas interaction with the captured replication factor. The decoy topic sitting beside orders.v1 pins the first of those as a permanent regression guard, and the hermetic bats suite pins the rest — including the all-topics path the e2e cannot reach.

LGTM.

@lllamnyp
Timofei Larkin (lllamnyp) merged commit 95ece88 into main Sep 24, 2026
21 checks passed
@lllamnyp
Timofei Larkin (lllamnyp) deleted the worktree-kafka-backup-example branch September 24, 2026 09:00
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Sep 24, 2026
…le (#4436)

## What this PR does

[#3580](#3580) landed with
the old `wait_hr_ready` in `examples/backups/kafka/00-helpers.sh`. It
was branched before
[#3769](#3769) moved the
backup examples to one body, and `hack/mariadb-first-boot-waits.bats`
runs every copy it finds, so unit tests are red on main now and on every
PR tested against it ([example
run](https://github.com/cozystack/cozystack/actions/runs/35978978386/job/107566130505)).

Function is replaced with the mariadb copy, rest of the file untouched.
`make unit-tests` passes locally.

### Screenshots

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(kafka): the topic-data backup example now reports why a HelmRelease wait failed, same as the other backup examples.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved diagnostics when a Kafka backup release fails to become
ready. Messages now distinguish lookup failures, releases that never
appear, and releases deleted while waiting.
* Failure reports include release conditions and available deployment
history, making it easier to understand stalled or timed-out releases.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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