Skip to content

DA262: S3 backup (create-backup + list-backups)#59

Merged
delgod merged 11 commits into
9/edgefrom
s3-backup
Jun 26, 2026
Merged

DA262: S3 backup (create-backup + list-backups)#59
delgod merged 11 commits into
9/edgefrom
s3-backup

Conversation

@delgod

@delgod delgod commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Adds two Juju actions — create-backup and list-backups — that stream a fresh Valkey RDB snapshot from the targeted unit's valkey-cli --rdb - stdout directly to S3 via boto3 multipart upload. Any unit (leader or follower) can run the actions; locking is per-unit.

Credentials are supplied by relating the charm to the upstream s3-integrator via a new s3-credentials relation (interface s3, limit 1, optional).

Depends on

#58 (workload-exec-stream). Must be merged first — this PR uses workload.exec_stream and CliClient.build_command_prefix.

What's in the PR

src/
literals.py                # S3_RELATION_NAME, BACKUP_ID_FORMAT, BACKUP_CA_FILENAME
statuses.py                # BackupStatuses enum (3 entries)
common/exceptions.py       # ValkeyBackupError
core/base_workload.py      # tls_paths.backup_ca property
core/models.py             # s3_credentials on PeerAppModel,
                           # backup_id on PeerUnitModel,
                           # ValkeyServer.is_backup_in_progress,
                           # ValkeyCluster.s3_credentials (parsed JSON envelope)
core/cluster_state.py      # s3_relation property
managers/backup.py         # NEW – BackupManager + status protocol
events/backup.py           # NEW – BackupEvents + _exists_preventing_reason
events/base_events.py      # storage_detaching guard during backup
charm.py                   # wire BackupManager + BackupEvents;
                           # register with StatusHandler;
                           # restart_workload guard during backup
metadata.yaml                # requires.s3-credentials
actions.yaml                 # create-backup, list-backups
pyproject.toml               # boto3, mypy-boto3-s3
lib/charms/data_platform_libs/v0/s3.py   # via charmcraft fetch-lib
tests/unit/test_backup.py    # ~40 ops-scenario unit tests

Design highlights

  • Streaming, no archive storage. bucket.upload_fileobj(proc.stdout, key, Config=TransferConfig(multipart_chunksize=8 MiB)) reads chunks from the pipe; no on-disk staging.
  • Per-unit lock. The backup_id field in the running unit's peer-unit databag is both the lock value and the operation identifier. Two backups can run concurrently on different units (each gets a distinct S3 key); two on the same unit are rejected.
  • Cleanup on failure. Partial S3 objects are deleted (bucket.Object(key).delete()) when valkey-cli exits non-zero or upload_fileobj raises.
  • boto3 quirks honoured. Config(request_checksum_calculation="when_required", response_checksum_validation="when_required") (boto3#4400);
    CreateBucketConfiguration omitted for us-east-1 (aws-sdk-js#3647);
    idempotent error tokens (BucketAlreadyOwnedByYou, BucketAlreadyExists, BucketNameUnavailable).
  • TLS CA chain stored on every unit (not only the leader) so any unit can use TLS to talk to a self-signed S3 endpoint (e.g. RadosGW, MicroCeph).
  • Leader-switchover recovery. Re-observes leader_elected to re-trigger _on_s3_credentials_changed — covers the case where credentials_gone fired only on the old leader.

Action UX

# Any unit — leader or follower
juju run valkey/N create-backup
# → { "backup-id": "2026-05-13T10:00:00Z" }

juju run valkey/N list-backups
# → { "backups": "backup-id … | backup-status\n-…-\n2026-05-13T10:00:00Z | finished" }

Status surface:

  • BACKUP_IN_PROGRESS (maintenance, unit scope) while streaming
  • BACKUP_S3_PARAMETERS_MISSING (blocked, app scope) when the relation is present but the integrator hasn't supplied bucket/credentials yet
  • BACKUP_FAILED (blocked, unit scope) after a failure

@delgod delgod changed the title DA262 S3 backup (create-backup + list-backups) DA262: S3 backup (create-backup + list-backups) May 13, 2026

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

Thank you Mykola! Good work! I left some comments

Comment thread src/core/models.py Outdated
Comment thread src/core/models.py Outdated
Comment thread src/events/backup.py Outdated
Comment thread src/events/backup.py Outdated
Comment thread src/events/backup.py
Comment thread src/managers/backup.py Outdated
Comment thread src/events/backup.py
Comment thread actions.yaml
Comment thread src/events/backup.py
Comment thread src/events/backup.py Outdated
delgod added a commit that referenced this pull request Jun 23, 2026
- _blocking_reason: move below the handlers, add a check_running_operations
  flag (create-backup rejects a backup already running; list-backups, being
  read-only, passes False). Flag is ready for a future restore action.
- create-backup: surface BACKUP_IN_PROGRESS as a running status during the
  blocking action; drop the redundant delete on upload ClientError (boto3's
  managed upload aborts the multipart upload itself) — explicit cleanup now
  only on rc!=0 / bad-RDB-magic, where the object did fully upload.
- list-backups: add an `output` (table/json) action parameter.
- storage-detaching: raise ValkeyBackupInProgressError instead of returning,
  so the hook errors and Juju retries teardown until the backup finishes.
- move the S3 endpoint CA path onto WorkloadBase.backup_ca_path, kept
  charm-process-local via JUJU_CHARM_DIR (boto3 runs charm-side, not in the
  workload container, so it must not be a tls_paths/container path).
- s3_credentials property: return {} instead of None and type it
  dict[str, Any] (tls-ca-chain is a list); drop the now-dead None guards in
  list_backups/create_backup.
- create_backup: flatten the nested try/except, derive backup_id from a
  single timestamp, tidy comments.
- nits: walrus operators, clearer variable names, docstrings, a log line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Edu6gfuBJviyqpaYV3Pqde
Mehdi-Bendriss
Mehdi-Bendriss previously approved these changes Jun 23, 2026

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

Thank you Mykola!

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

Thank you @delgod. I left some comments/questions.

Comment thread src/common/exceptions.py Outdated
Comment thread src/core/base_workload.py Outdated
Comment thread concierge-k8s.yaml
Comment thread src/managers/backup.py Outdated
Comment thread src/events/backup.py
Comment thread src/events/backup.py
Comment thread src/managers/backup.py
Comment thread src/managers/backup.py Outdated
Comment thread src/managers/backup.py
Comment thread src/managers/backup.py
delgod added a commit that referenced this pull request Jun 24, 2026
Give all backup-related exceptions a common base so callers can catch the
whole family with `except ValkeyBackupError`. ValkeyBackupInProgressError
is only raised from storage-detaching (to retry the hook), which is not on
any `except ValkeyBackupError` path, so this cannot be swallowed by the
backup action/upload handlers.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
delgod added a commit that referenced this pull request Jun 24, 2026
The S3 endpoint CA bundle is a charm-process-local file (boto3 runs in the
charm process, not the workload container), so it does not belong on the
WorkloadBase. Move it to BackupManager._backup_ca_path, derived from the ops
charm_dir (self.state.charm.charm_dir) instead of reading os.environ
["JUJU_CHARM_DIR"] directly, matching how managers/topology.py already
obtains the charm dir. Drop the now-unused os/pathlib/BACKUP_CA_FILENAME
imports from base_workload.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
delgod added a commit that referenced this pull request Jun 24, 2026
A misconfigured S3 integrator can send a tls-ca-chain whose items are not
PEM certificates (base64 without an armour header, or stray strings); such
a bundle is written to disk but boto3 cannot load it. Reject the chain
unless every item carries a PEM "-----BEGIN ...-----" header, mirroring the
key check in managers/tls.py.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
delgod added a commit that referenced this pull request Jun 24, 2026
Answer two review questions inline: why _on_s3_credentials_changed trims
whitespace and strips slashes from the integrator envelope (s3-integrator
passes values through verbatim, and empty/"/" path or bucket would corrupt
S3 keys or enumerate the whole bucket), and why us-east-1 is special when
creating the bucket (AWS rejects LocationConstraint=us-east-1).

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM

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

LGTM. The only remaining concern is if the backup procedure (streaming) takes a long time the create backup action will time out.

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

Thank you Mykola, and sorry for the late review. I agree with the overall concept and have only some minor comments and questions.

Comment thread lib/charms/data_platform_libs/v0/s3.py Outdated
Comment thread src/managers/backup.py Outdated
Comment thread src/managers/backup.py Outdated
Comment thread src/managers/backup.py
@reneradoi

Copy link
Copy Markdown
Contributor

Hint for the failing integration tests: #76

delgod added a commit that referenced this pull request Jun 24, 2026
Address PR #59 review nits from @reneradoi:
- add the `-> Bucket` return annotation on `_get_bucket_resource`
- use `Path.as_posix()` instead of `str()` for the CA bundle path,
  matching the idiom used across the rest of the charm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w
delgod added a commit that referenced this pull request Jun 25, 2026
Replace the vendored data_platform_libs/v0/s3 charm lib with the
maintained object-storage-charmlib PyPI package (schema v1: shares
credentials via Juju secrets, common s3/azure/gcs contract), per
@reneradoi's review on PR #59.

- add object-storage-charmlib ^1.0.0 to dependencies
- import S3Requirer + StorageConnectionInfo{Changed,Gone}Event from
  object_storage and observe storage_connection_info_{changed,gone}
- read credentials via get_storage_connection_info(); copy the returned
  S3Info TypedDict into a plain dict before envelope normalisation
- delete lib/charms/data_platform_libs/v0/s3.py
- update unit-test mocks to the new getter

The s3 relation interface and endpoint name are unchanged; the new lib
stays wire-compatible with both v0 and v1 s3-integrator providers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w
delgod added a commit that referenced this pull request Jun 25, 2026
Address @skourta's review nit on PR #59: replace the dict-of-str S3
envelope (and the cast on s3_parameters['bucket']) with a typed
pydantic S3Parameters model.

- add S3Parameters in core/models.py: typed fields with hyphenated
  aliases plus field validators that fold in the whitespace/separator
  normalisation and required-field validation previously inlined in
  _on_s3_credentials_changed
- ValkeyCluster.s3_credentials now returns S3Parameters | None
- _on_s3_credentials_changed validates via model_validate (ValidationError
  -> skip), stores model_dump_json(by_alias=True), and dedups by value
- BackupManager methods take S3Parameters and use typed attributes
- remaining casts (S3ServiceResource, IO[bytes], region Literal) are
  boto3-stub artifacts, not envelope casts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w

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

Hey Mykola! I've tested the PR, it looks really good UX wise and the implementation is also robust against failure scenarios. I am happy to approve, there is only one issue that we need to either solve in this PR or in the next one, and it is about safeguarding the backup creation on units in parallel: I was able to produce a situation where two units create a backup at the same time, which results in the exact same name for the backup file to be uploaded to s3 storage. Both units stream their backup (according to the logs), and there is no error. But we end up with only one backup in the s3 storage (see screenshot below, sorry it is a bit chaotic 😓 ). It is not clear which unit won the race, whether the file is corrupted or not, and there is no error from the s3 client either. This might be an edge case, but it could happen for example with scheduled backups. I think we need to safeguard it. The most simple solution would be to add the unit name to the backup file, but that's just an idea.

Image

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

The issue mentioned above will be treated in a separate PR and will be tracked with #77.

delgod and others added 3 commits June 26, 2026 16:55
The S3 backup feature uploads RDB snapshots through boto3 and consumes
the s3 relation via the data_platform_libs S3Requirer. Add the boto3 and
mypy-boto3-s3 dependencies and vendor charms/data_platform_libs/v0/s3.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stream the on-disk RDB out of the workload with `valkey-cli --rdb -` over
the exec_stream primitive (admin password passed via VALKEYCLI_AUTH, never
on argv) and upload it to the configured S3 bucket; list-backups renders
the stored snapshots.

Adds the BackupStatuses, the s3_credentials/backup_id peer state and the
s3 relation, the BackupManager (bucket construction, CA handling, upload
via a counting reader), the backup event handlers and create-backup/
list-backups actions, and guards that refuse scale-down and storage
detach while a backup is in progress. Type-clean under the whole-repo
pyright gate and covered by tests/unit/test_backup.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose a MicroCeph S3 endpoint in the concierge VM and K8s configs so the
backup integration tests have an S3 target to run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delgod and others added 8 commits June 26, 2026 16:55
- _blocking_reason: move below the handlers, add a check_running_operations
  flag (create-backup rejects a backup already running; list-backups, being
  read-only, passes False). Flag is ready for a future restore action.
- create-backup: surface BACKUP_IN_PROGRESS as a running status during the
  blocking action; drop the redundant delete on upload ClientError (boto3's
  managed upload aborts the multipart upload itself) — explicit cleanup now
  only on rc!=0 / bad-RDB-magic, where the object did fully upload.
- list-backups: add an `output` (table/json) action parameter.
- storage-detaching: raise ValkeyBackupInProgressError instead of returning,
  so the hook errors and Juju retries teardown until the backup finishes.
- move the S3 endpoint CA path onto WorkloadBase.backup_ca_path, kept
  charm-process-local via JUJU_CHARM_DIR (boto3 runs charm-side, not in the
  workload container, so it must not be a tls_paths/container path).
- s3_credentials property: return {} instead of None and type it
  dict[str, Any] (tls-ca-chain is a list); drop the now-dead None guards in
  list_backups/create_backup.
- create_backup: flatten the nested try/except, derive backup_id from a
  single timestamp, tidy comments.
- nits: walrus operators, clearer variable names, docstrings, a log line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Edu6gfuBJviyqpaYV3Pqde
Give all backup-related exceptions a common base so callers can catch the
whole family with `except ValkeyBackupError`. ValkeyBackupInProgressError
is only raised from storage-detaching (to retry the hook), which is not on
any `except ValkeyBackupError` path, so this cannot be swallowed by the
backup action/upload handlers.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
The S3 endpoint CA bundle is a charm-process-local file (boto3 runs in the
charm process, not the workload container), so it does not belong on the
WorkloadBase. Move it to BackupManager._backup_ca_path, derived from the ops
charm_dir (self.state.charm.charm_dir) instead of reading os.environ
["JUJU_CHARM_DIR"] directly, matching how managers/topology.py already
obtains the charm dir. Drop the now-unused os/pathlib/BACKUP_CA_FILENAME
imports from base_workload.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
A misconfigured S3 integrator can send a tls-ca-chain whose items are not
PEM certificates (base64 without an armour header, or stray strings); such
a bundle is written to disk but boto3 cannot load it. Reject the chain
unless every item carries a PEM "-----BEGIN ...-----" header, mirroring the
key check in managers/tls.py.

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
Answer two review questions inline: why _on_s3_credentials_changed trims
whitespace and strips slashes from the integrator envelope (s3-integrator
passes values through verbatim, and empty/"/" path or bucket would corrupt
S3 keys or enumerate the whole bucket), and why us-east-1 is special when
creating the bucket (AWS rejects LocationConstraint=us-east-1).

Addresses PR #59 review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VSu7jtf5pbJY7GySibCWM
Address PR #59 review nits from @reneradoi:
- add the `-> Bucket` return annotation on `_get_bucket_resource`
- use `Path.as_posix()` instead of `str()` for the CA bundle path,
  matching the idiom used across the rest of the charm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w
Replace the vendored data_platform_libs/v0/s3 charm lib with the
maintained object-storage-charmlib PyPI package (schema v1: shares
credentials via Juju secrets, common s3/azure/gcs contract), per
@reneradoi's review on PR #59.

- add object-storage-charmlib ^1.0.0 to dependencies
- import S3Requirer + StorageConnectionInfo{Changed,Gone}Event from
  object_storage and observe storage_connection_info_{changed,gone}
- read credentials via get_storage_connection_info(); copy the returned
  S3Info TypedDict into a plain dict before envelope normalisation
- delete lib/charms/data_platform_libs/v0/s3.py
- update unit-test mocks to the new getter

The s3 relation interface and endpoint name are unchanged; the new lib
stays wire-compatible with both v0 and v1 s3-integrator providers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w
Address @skourta's review nit on PR #59: replace the dict-of-str S3
envelope (and the cast on s3_parameters['bucket']) with a typed
pydantic S3Parameters model.

- add S3Parameters in core/models.py: typed fields with hyphenated
  aliases plus field validators that fold in the whitespace/separator
  normalisation and required-field validation previously inlined in
  _on_s3_credentials_changed
- ValkeyCluster.s3_credentials now returns S3Parameters | None
- _on_s3_credentials_changed validates via model_validate (ValidationError
  -> skip), stores model_dump_json(by_alias=True), and dedups by value
- BackupManager methods take S3Parameters and use typed attributes
- remaining casts (S3ServiceResource, IO[bytes], region Literal) are
  boto3-stub artifacts, not envelope casts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnE2zKaNA8hVPq1ciWyX5w
@delgod delgod merged commit 8436ea5 into 9/edge Jun 26, 2026
13 checks passed
@delgod delgod deleted the s3-backup branch June 26, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants