Skip to content

feat: backup and restore (vault + mysql)#876

Open
Deezzir wants to merge 11 commits into
canonical:mainfrom
Deezzir:feat/backup-restore
Open

feat: backup and restore (vault + mysql)#876
Deezzir wants to merge 11 commits into
canonical:mainfrom
Deezzir:feat/backup-restore

Conversation

@Deezzir

@Deezzir Deezzir commented Jul 6, 2026

Copy link
Copy Markdown

Overview

  • Adds sunbeam backup, sunbeam list-backups, and sunbeam restore commands for stateful workloads in the OpenStack model.
  • Introduces a component-driven backup/restore framework (BackupComponent) so command flow is generic while component specifics (target resolution, parse/list/restore plans) stay in one registry.
  • Supports MySQL and Vault, including backup inventory listing, restore execution, and manifest output.

Automation

In addition to the new commands, I am working on an experimental feature to automate the deployment and configuration of the s3-integrator charm for each MySQL and Vault component detected in the cluster. The branch is currently based on the current changes.

See: #890

The feature, at a minimum, deploys the s3-integrator for each target application, but the operator can choose to interactively set up the S3 location (single for all integrators).

Prerequisites

The current changes are blocked by the sunbeam-charms feature introduced in the PR, adding the pause/resume actions for the control plane services, like keystone, cinder, etc. The feature allows stopping the control-plane service's communication with the corresponding databases, so the restore can complete without concurrent writes.

QA

The new commands were validated end-to-end on a new Sunbeam Cluster (MAAS) on the personal SE-Cloud.

Demo

Backup

image

List Backups

image

Restore

image

@gboutry
gboutry self-requested a review July 9, 2026 11:45
@hmlanigan
hmlanigan requested a review from freyes July 10, 2026 19:06
@Deezzir
Deezzir force-pushed the feat/backup-restore branch from 50b43d1 to a5e8558 Compare July 15, 2026 04:38
Deezzir added 5 commits July 15, 2026 00:40
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir
Deezzir force-pushed the feat/backup-restore branch 9 times, most recently from c03c37b to 253ec99 Compare July 17, 2026 01:04
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir
Deezzir force-pushed the feat/backup-restore branch 4 times, most recently from 6d10439 to 63907a8 Compare July 17, 2026 06:39
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir
Deezzir force-pushed the feat/backup-restore branch 5 times, most recently from 691aaa8 to 141d379 Compare July 17, 2026 08:44
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir
Deezzir force-pushed the feat/backup-restore branch from 141d379 to e33a28b Compare July 18, 2026 11:07
Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
params[self.component.backup_id_param] = latest

try:
tenacity.Retrying(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I found a race condition during a MySQL component restore. After the corresponding control-plane service is paused, there is a brief window during which MySQL may have dangling client connections, so the restore action will fail to close them. Having a retry here solves the issue, but I would like to come up with something more robust and reliable. Open to suggestions

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.

issue: I find this aggressive retry policy a bit concerning.

Was this tested with applications unpaused?

Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir
Deezzir force-pushed the feat/backup-restore branch from c1983b3 to 434862a Compare July 20, 2026 07:05
@Deezzir
Deezzir marked this pull request as ready for review July 20, 2026 07:06

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

I think this PR needs another pass with a more holistic view of the backup and restore workflows.

There is a lot of indirection, which makes the operational sequence and its safety properties difficult to reason about. In particular, BackupComponent does not appear to simplify the implementation: component-specific behavior still leaks into generic steps, especially around action parameters, point-in-time restore, pause/resume coordination, and failure recovery.

The force handling illustrates the problem. It is propagated through several layers without a clear definition of what it is supposed to bypass. In some paths it has no effect, while in others it becomes an action parameter even when the target charm does not support that parameter. This makes --force both unreliable and capable of breaking otherwise valid operations.

Before adding more fixes, I think we should simplify the design around explicit MySQL and Vault workflows, define the guarantees for backup, restore, rollback, and force behavior, and then test those workflows against the actual charm action contracts and supported database topologies. The current mock-heavy tests pass while still encoding assumptions that are invalid with Juju.

params[self.component.backup_id_param] = latest

try:
tenacity.Retrying(

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.

issue: I find this aggressive retry policy a bit concerning.

Was this tested with applications unpaused?

return sorted(successful, key=lambda b: b.backup_id)[-1].backup_id


def _api_app_for_mysql(app_name: str) -> str:

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.

The default MySQL topology is single MySQL app, and if we follow this Pull request, then, to which application is mysql tied to?


return [
_PauseAppStep(jhelper, api_app, force=force, timeout=timeout, model=model),
_ScaleAppStep(

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.

I don't see in the official docs where it's advised to scale mysql down to 1: https://canonical.com/data/mysql/docs/8.0/how-to/back-up-and-restore/restore-a-backup/

Anyway, won't that leave the PVCs on the host, and just provoke more juju hooks churn?

What are we trying to achieve here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In the docs, it explicitly says to scale the replicas to 1 before the restore

@gboutry gboutry Jul 21, 2026

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.

Uh, you're right. And there's no conflict when it's scaled back up to 3 with the PVCs?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, I guess it just reattaches them back

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.

Yeah, but then we have "old" pvc on 2 units, and restored PVC 1 unit. I really would like some input on DPE, just to ensure it's a case they have considered.

@Deezzir Deezzir Jul 22, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think the behaviour is the following: K8s preserves the old PVCs for the scaled-down units. During the restore, the PRIMARY cluster member rebuilds the database on its storage. After the scale-back, the SECONDARY rebuilds its copies on the old PVCs, replacing the stale data on the existing storage using the MySQL Clone Plugin from the fresh snapshot.

super().__init__("Restore app", f"Restoring {target.app}")
self.jhelper = jhelper
self.target = target
self.restore_to_time = restore_to_time

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.

I feel like the restore_to_time is always None, why include it then?

Any reason we're not looking for PITR recovery mechanism?

return app_name


def _current_scale(jhelper: JujuHelper, app: str, model: str) -> int:

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.

If we can't figure out the scale of the application, this means juju is in a bad state, and we should not proceed with this operation. We should fail the operation.

component=target.component,
backup=backup,
)
except Exception as e:

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.

This exception catch is a bit too broad, can we narrow it down?

return backups


def _parse_backup(action_result: dict) -> BackupOutcome | None:

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.

This allows for None, and we don't gate on that result later, is that expected?

I'd surmise either we've successfully created a backup, or it's a failure?

if component is None:
continue
for app in apps:
target = component.resolve_backup_target(

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.

Isn't it concerning that a discovered app for backup is gone once we try to resolve the target?

self.scale = scale
self.timeout = timeout
self.model = model
self.force = force

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.

Is this actually used?

def run(self, context: StepContext) -> Result:
"""Dispatch the backup action, recording the outcome on ``self.result``."""
target = self.target
params: dict[str, str | bool] = {"force": True} if self.force else {}

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

What I found is that Juju will ignore provided parameters if they are not defined in the action, so it is safe to pass

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.

Is that documented behaviour? I'd rather not depend on an implementation detail that's not properly documented by the juju team if possible.

# Public component registry
# ---------------------------------------------------------------------------
BACKUP_COMPONENTS: list[BackupComponent] = [
BackupComponent(

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.

I think I'd rather have subclasses VaultBackupComponent and so on, rather than generic object with helper functions. This would make the whole ordeal much better to follow.

Signed-off-by: Deezzir <yurii.kondrakov@canonical.com>
@Deezzir

Deezzir commented Jul 22, 2026

Copy link
Copy Markdown
Author

@gboutry I've refactored the logic:

  1. Explicit VaultBackupComponent and MySQLBackupComponent classes that encapsulate the per-component backup/restore logic.
  2. Cleaned up stale step inputs (parameters)
  3. Removed tenacity retry in the Restore step; to fight the race condition, the MySQL restore plan now scales all mysql-router apps to clean up the stale client connections and proceed to restore.
  4. Cleaned up generic exceptions.
  5. Added logic to handle multi/single MySQL cluster topology

@Deezzir
Deezzir requested a review from gboutry July 22, 2026 05:38
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.

2 participants