From bd6b153c553f09a5d90de0e481908689691af0db Mon Sep 17 00:00:00 2001 From: Anuj Sahni Date: Thu, 29 Oct 2026 22:52:36 -0700 Subject: [PATCH] backup/restore commands added --- .github/workflows/ci.yml | 20 + CHANGELOG.md | 11 + README.md | 33 +- capellactl.spec | 6 +- capellactl/api/endpoints.py | 153 +- capellactl/cli/backup_common.py | 63 + capellactl/cli/bucket_backup.py | 512 ++++++ capellactl/cli/bucket_cmds.py | 2 +- capellactl/cli/cluster_backup.py | 468 ++++++ capellactl/cli/cluster_group.py | 18 +- capellactl/cli/cluster_ops.py | 183 +- capellactl/cli/configure.py | 3 + capellactl/cli/help.py | 1 + capellactl/cli/resource_cmds.py | 11 +- capellactl/cli/sync.py | 22 +- capellactl/config/crossref.py | 52 +- capellactl/config/disks.yaml | 50 + capellactl/config/editor.py | 4 +- capellactl/config/presets.py | 58 + capellactl/config/schema.json | 173 ++ capellactl/engine/planner.py | 4 +- capellactl/engine/waiter.py | 44 + capellactl/help/backup.md | 33 + capellactl/help/ci.md | 6 + capellactl/help/scaling.md | 8 + capellactl/importer/__init__.py | 60 +- capellactl/resources/backup_schedule.py | 276 +++ capellactl/resources/cluster.py | 2 +- capellactl/resources/unsupported.py | 13 +- docs/api/management-api-v4.0-operations.json | 350 +++- docs/api/management-api-v4.0-responses.json | 1482 ++++++++++++++++- docs/backup-restore.md | 218 +++ docs/custom-clusters.md | 38 + docs/getting-started.md | 36 +- docs/reference.md | 13 + docs/release-1.0.0/00-architecture.md | 9 +- docs/release-1.0.0/API-COVERAGE.md | 257 ++- .../CAP-055-windows-install-steps.md | 224 +++ .../CAP-056-cluster-backup-schedule.md | 194 +++ .../CAP-057-cluster-backup-and-restore.md | 169 ++ .../CAP-058-bucket-backup-schedule.md | 177 ++ .../CAP-059-bucket-backup-and-restore.md | 170 ++ .../release-1.0.0/CAP-060-choose-disk-type.md | 172 ++ .../CAP-061-backup-and-disk-guides.md | 107 ++ docs/release-1.0.0/README.md | 57 +- .../API-COVERAGE-APP-SERVICES.md | 4 +- docs/runbook.md | 162 +- downloads/README.md | 10 + pyproject.toml | 1 + scripts/readme-windows-steps.py | 80 + scripts/update-api-coverage.py | 144 +- scripts/update-downloads.py | 10 + scripts/update-story-index.py | 10 +- tests/binary/test_smoke.py | 3 +- tests/fakes/capella.py | 373 +++++ tests/scenario/test_backup_restore_guide.py | 48 + tests/scenario/test_bucket_backup.py | 188 +++ tests/scenario/test_bucket_backup_schedule.py | 182 ++ tests/scenario/test_cluster_backup.py | 171 ++ .../scenario/test_cluster_backup_schedule.py | 201 +++ tests/scenario/test_cluster_create_mds.py | 20 +- tests/scenario/test_cluster_disk.py | 115 ++ tests/scenario/test_configure.py | 8 + tests/scenario/test_custom_clusters_guide.py | 28 +- tests/scenario/test_resource_verbs.py | 4 +- tests/unit/api/test_endpoint_inventory.py | 7 + tests/unit/cli/test_configure_key.py | 11 + tests/unit/cli/test_help.py | 2 +- tests/unit/cli/test_make_disk.py | 26 + tests/unit/config/test_crossref_disk.py | 56 + tests/unit/docs/test_backup_guide.py | 58 + tests/unit/docs/test_runbook.py | 5 + tests/unit/docs/test_windows_docs.py | 152 ++ tests/unit/fakes/test_fake_parity.py | 36 +- 74 files changed, 7850 insertions(+), 227 deletions(-) create mode 100644 capellactl/cli/backup_common.py create mode 100644 capellactl/cli/bucket_backup.py create mode 100644 capellactl/cli/cluster_backup.py create mode 100644 capellactl/config/disks.yaml create mode 100644 capellactl/help/backup.md create mode 100644 capellactl/resources/backup_schedule.py create mode 100644 docs/backup-restore.md create mode 100644 docs/release-1.0.0/CAP-055-windows-install-steps.md create mode 100644 docs/release-1.0.0/CAP-056-cluster-backup-schedule.md create mode 100644 docs/release-1.0.0/CAP-057-cluster-backup-and-restore.md create mode 100644 docs/release-1.0.0/CAP-058-bucket-backup-schedule.md create mode 100644 docs/release-1.0.0/CAP-059-bucket-backup-and-restore.md create mode 100644 docs/release-1.0.0/CAP-060-choose-disk-type.md create mode 100644 docs/release-1.0.0/CAP-061-backup-and-disk-guides.md create mode 100644 scripts/readme-windows-steps.py create mode 100644 tests/scenario/test_backup_restore_guide.py create mode 100644 tests/scenario/test_bucket_backup.py create mode 100644 tests/scenario/test_bucket_backup_schedule.py create mode 100644 tests/scenario/test_cluster_backup.py create mode 100644 tests/scenario/test_cluster_backup_schedule.py create mode 100644 tests/scenario/test_cluster_disk.py create mode 100644 tests/unit/cli/test_make_disk.py create mode 100644 tests/unit/config/test_crossref_disk.py create mode 100644 tests/unit/docs/test_backup_guide.py create mode 100644 tests/unit/docs/test_windows_docs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f2696..c171241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,26 @@ jobs: - shell: bash run: pipx install dist/*.whl && capellactl --version + windows-install: # CAP-055 AC3: the README's Windows steps, as written, in Windows PowerShell 5.1 + needs: test + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # the version comes from git tags (scripts/version.py) + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - shell: powershell + run: | + $python = @() + py -3.12 --version + if ($LASTEXITCODE) { $python = @('--python', (Get-Command python).Source); Write-Host "py launcher has no 3.12: using $($python[1])" } + python scripts/readme-windows-steps.py --source "$env:GITHUB_WORKSPACE" --workdir "$env:RUNNER_TEMP\windows-install" --out "$env:RUNNER_TEMP\steps.ps1" @python + if ($LASTEXITCODE) { exit $LASTEXITCODE } + powershell -NoProfile -ExecutionPolicy Bypass -File "$env:RUNNER_TEMP\steps.ps1" + image-smoke: # CAP-001 AC6 needs: test runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0914179..bbbe659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ First release, 1.0.0: declarative management of Couchbase Capella through the Ma `cluster group list|add|remove`; `--single-node`, `--server-version`, `--zone`, `--cmek-id`, `--private-dns` and `--disk-auto-expansion`; node sizes Capella offers in `cluster options`, with a warning for any other size. Guide: [docs/custom-clusters.md](docs/custom-clusters.md). +- Backup and restore: cluster backups (cloud snapshots) with `cluster backup schedule show|options|set|delete` and + `cluster backup list|create|retain|delete|restore|restores|regions`; bucket backups with + `bucket backup schedule show|set|delete`, `bucket backup cycle list|get` and + `bucket backup list|get|create|delete|restore|export`. Schedules are recorded as `backupSchedule` in `capella.yaml` + and covered by `plan`, `apply`, `drift` and `sync`. Guide: [docs/backup-restore.md](docs/backup-restore.md). +- Disk types: `cluster disk-types` lists each provider's types with their size range, speed and use; interactive + `cluster create` and `cluster group add` offer them after the node size; `cluster update --disk-type`; disks get + Capella's recommended IOPS for their size, and sizes or IOPS a type does not accept are refused offline. - `sync` is the one command for `capella.yaml`: it writes the file when there is none, refreshes it, and adds the organization's projects that are missing (`--skip-project` leaves one out). `import` remains as an alias. - `sync` keeps the buckets and credentials of a cluster that is turned off (or cannot be read) instead of removing them @@ -26,3 +34,6 @@ First release, 1.0.0: declarative management of Couchbase Capella through the Ma a terminal, and `--quiet` keeps pipeline logs short (`capellactl help ci`). - Secret redaction everywhere, generated passwords saved locally, API Key Token in the OS keychain. - `make dist`: source archive, wheel and `SHA256SUMS`, reproducible and versioned from the git tag. +- Windows: step-by-step PowerShell install in the README (checked in CI on Windows PowerShell 5.1), PowerShell + equivalents for the docs' shell commands, and an API Key Token piped from PowerShell (`Get-Clipboard |`) is + accepted with its byte-order mark. diff --git a/README.md b/README.md index a04e89a..ade450d 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ else, use the Capella UI or the Management API directly. ## Install capellactl has not had its first release yet, so install it from source. It takes a few minutes and needs -[Git](https://git-scm.com/) and Python 3.10–3.13. +[Git](https://git-scm.com/) and Python 3.10–3.13. The steps below are for macOS and Linux; on Windows, follow +[On Windows (PowerShell)](#on-windows-powershell). **1. Check your Python version.** On many Macs, `python3` is older than 3.10: @@ -78,9 +79,30 @@ capellactl 1.0.0.devN+g… (Management API v4.0) Licensed under Apache-2.0 · Copyright 2026 Couchbase, Inc. ``` -On Windows, activate with `.venv\Scripts\activate` instead. To update later, run `git pull` in the `capellactl` -folder; the environment picks up the new code. Then continue with [Quick start](#quick-start), or the step-by-step -[getting started guide](docs/getting-started.md). +To update later, run `git pull` in the `capellactl` folder; the environment picks up the new code. Then continue +with [Quick start](#quick-start), or the step-by-step [getting started guide](docs/getting-started.md). + +### On Windows (PowerShell) + +Open **Windows PowerShell** (Start menu → type *PowerShell*) and run each command on its own, in order: + + +| Step | Run in PowerShell | You should see / if it fails | +|---|---|---| +| 1. Install Git and Python 3.12 | `winget install --id Git.Git -e`
`winget install --id Python.Python.3.12 -e` | Then **close PowerShell and open it again**. No `winget`: install Git from [git-scm.com](https://git-scm.com/download/win) and Python **3.12** from [python.org](https://www.python.org/downloads/windows/), not the newest version (capellactl needs 3.10–3.13), and tick *Add python.exe to PATH* | +| 2. Check Python | `py -3.12 --version` | `Python 3.12.x`. If `python` opens the Microsoft Store, ignore it: use `py` as shown here | +| 3. Download capellactl | `git clone https://github.com/couchbaselabs/capellactl.git`
`cd capellactl` | Two separate commands: Windows PowerShell does not accept `&&` | +| 4. Create its environment | `py -3.12 -m venv .venv` | A new `.venv` folder | +| 5. Install it | `.venv\Scripts\python -m pip install -e .` | `Successfully installed … capellactl-…`. `requires a different Python`: step 4 used a Python newer than 3.13; install 3.12 (step 1), delete `.venv` and repeat step 4 | +| 6. Allow activation (once per user) | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` | Answer `Y`. If your company blocks this, skip steps 6 and 7, and type `.venv\Scripts\capellactl.exe` wherever the docs say `capellactl` | +| 7. Activate (in each new window) | `.venv\Scripts\Activate.ps1` | The prompt starts with `(.venv)`. Without it you get `capellactl is not recognized`. In cmd.exe: `.venv\Scripts\activate.bat` | +| 8. Check capellactl | `capellactl --version` | `capellactl 1.0.0.devN+g… (Management API v4.0)` | +| 9. Make a folder for your environment | `mkdir ~\capella-env`
`cd ~\capella-env` | capellactl writes `capella.yaml` in the folder you run it from | + + +`configure` keeps the API Key Token in **Windows Credential Manager** (entry `capellactl`) and profiles in +`%APPDATA%\capellactl`. The docs show macOS/Linux commands; [Using the commands in PowerShell](docs/getting-started.md#using-the-commands-in-powershell) +lists the few that are typed differently (passwords in `$env:`, long commands, pasting the token). ### After the first release (1.0.0) @@ -147,7 +169,8 @@ Passwords are never written to `capella.yaml` or printed: give them as `--passwo | Document | For | |---|---| | [Getting started](docs/getting-started.md) | Step-by-step walkthrough with example output, from install to deleting a cluster, and troubleshooting | -| [Custom clusters and service groups](docs/custom-clusters.md) | Any size and service layout: several service groups (MDS) with `--group`, adding and removing groups, single-node and advanced options | +| [Custom clusters and service groups](docs/custom-clusters.md) | Any size and service layout: several service groups (MDS) with `--group`, adding and removing groups, disk types, single-node and advanced options | +| [Backup and restore](docs/backup-restore.md) | Cluster and bucket backup schedules, listing backups, backing up before a change, restoring a cluster or a bucket, downloading a bucket backup | | [Runbook](docs/runbook.md) | Day-2 tasks with the exact command and expected output, a copy-paste reference of every command, and a 20-minute demo script | | [capella.yaml reference](docs/reference.md) | Every field: type, default, whether it can change, API mapping (generated from the schema) | | [Examples](docs/release-1.0.0/examples/) | [Annotated reference](docs/release-1.0.0/examples/capella.yaml), [single-node dev](docs/release-1.0.0/examples/single-node-dev.yaml), [GCP production](docs/release-1.0.0/examples/gcp-prod.yaml), [Azure production](docs/release-1.0.0/examples/azure-prod.yaml), [minimal](docs/release-1.0.0/examples/minimal-cluster.yaml) | diff --git a/capellactl.spec b/capellactl.spec index 0813888..bd4aa5c 100644 --- a/capellactl.spec +++ b/capellactl.spec @@ -8,10 +8,12 @@ from PyInstaller.utils.hooks import collect_data_files, copy_metadata ROOT = Path(SPECPATH) # noqa: F821 - defined by PyInstaller -# Read at run time from inside the package: the config schema, the init presets, the known node sizes and the help pages. +# Read at run time from inside the package: the config schema, the init presets, the known node sizes, the disk types +# and the help pages. datas = [(str(ROOT / "capellactl" / "config" / "schema.json"), "capellactl/config"), (str(ROOT / "capellactl" / "config" / "presets.yaml"), "capellactl/config"), - (str(ROOT / "capellactl" / "config" / "compute.yaml"), "capellactl/config")] + (str(ROOT / "capellactl" / "config" / "compute.yaml"), "capellactl/config"), + (str(ROOT / "capellactl" / "config" / "disks.yaml"), "capellactl/config")] datas += [(str(p), "capellactl/help") for p in sorted((ROOT / "capellactl" / "help").glob("*.md"))] # Licence and notices, inside the executable as well as next to it in the release (CAP-044, AC7). datas += [(str(ROOT / name), ".") for name in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md", "BINARY_NOTICES.md")] diff --git a/capellactl/api/endpoints.py b/capellactl/api/endpoints.py index bf42746..d027f0b 100644 --- a/capellactl/api/endpoints.py +++ b/capellactl/api/endpoints.py @@ -1,4 +1,4 @@ -"""One function per Management API v4.0 endpoint used in release 1.0.0 (CAP-003 AC9). +"""One function per Management API v4.0 endpoint used in release 1.0.0 (CAP-003 AC9; backups: CAP-056–CAP-059). Reference: https://docs.couchbase.com/cloud/management-api-reference/index.html `get_*` functions return (body, etag). Update functions accept `if_match`. @@ -342,6 +342,142 @@ def delete_credential(c: CapellaClient, project_id: str, cluster_id: str, user_i c.delete(f"{_c(c, project_id, cluster_id)}/users/{user_id}") +# ------------------------------------ Cloud snapshot backups (cluster backups, CAP-056, CAP-057) +def _cs(c: CapellaClient, project_id: str, cluster_id: str) -> str: + return f"{_c(c, project_id, cluster_id)}/cloudsnapshotbackups" + + +def get_snapshot_schedule(c: CapellaClient, project_id: str, cluster_id: str) -> Body | None: + """GET …/cloudsnapshotbackupschedule → 200, or 204 when there is none (None) (#tag/Cloud-Snapshot-Backups-Schedule)""" + body = c.get(f"{_c(c, project_id, cluster_id)}/cloudsnapshotbackupschedule").body + return body or None + + +def upsert_snapshot_schedule(c: CapellaClient, project_id: str, cluster_id: str, body: Body) -> None: + """PUT …/cloudsnapshotbackupschedule → 204 (creates or replaces)""" + c.put(f"{_c(c, project_id, cluster_id)}/cloudsnapshotbackupschedule", body) + + +def delete_snapshot_schedule(c: CapellaClient, project_id: str, cluster_id: str) -> None: + """DELETE …/cloudsnapshotbackupschedule → 204""" + c.delete(f"{_c(c, project_id, cluster_id)}/cloudsnapshotbackupschedule") + + +def get_snapshot_schedule_options(c: CapellaClient, project_id: str, cluster_id: str) -> Body: + """GET …/cloudsnapshotbackupschedule/options → {intervals, minimumRetention, maximumRetention} (hours)""" + return dict(c.get(f"{_c(c, project_id, cluster_id)}/cloudsnapshotbackupschedule/options").body or {}) + + +def list_snapshot_regions(c: CapellaClient, project_id: str, cluster_id: str) -> list[str]: + """GET …/cloudsnapshotbackups/regions → [region] (AWS and Azure only)""" + body = c.get(f"{_cs(c, project_id, cluster_id)}/regions").body + return [str(r) for r in body] if isinstance(body, list) else [] + + +def list_snapshots(c: CapellaClient, project_id: str, cluster_id: str) -> list[Body]: + """GET …/clusters/{clusterId}/cloudsnapshotbackups (paginated; queued backups are not listed)""" + return c.paginate(_cs(c, project_id, cluster_id)) + + +def list_project_snapshots(c: CapellaClient, project_id: str) -> list[Body]: + """GET …/projects/{projectId}/cloudsnapshotbackups → one summary per cluster (most recent, oldest)""" + return c.paginate(f"{_p(c, project_id)}/cloudsnapshotbackups") + + +def create_snapshot(c: CapellaClient, project_id: str, cluster_id: str, body: Body) -> str: + """POST …/cloudsnapshotbackups → 202 {backupId}; optional retention (hours), regionsToCopy (at most two)""" + return str((c.post(_cs(c, project_id, cluster_id), body).body or {}).get("backupId", "")) + + +def update_snapshot_retention(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str, retention: int) -> None: + """PUT …/cloudsnapshotbackups/{backupId} → 204 {retention} (hours)""" + c.put(f"{_cs(c, project_id, cluster_id)}/{backup_id}", {"retention": retention}) + + +def delete_snapshot(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str) -> None: + """DELETE …/cloudsnapshotbackups/{backupId} → 202""" + c.delete(f"{_cs(c, project_id, cluster_id)}/{backup_id}") + + +def restore_snapshot(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str, body: Body) -> str: + """POST …/cloudsnapshotbackups/{backupId}/restore → 202 {restoreId}; replaces all data of the cluster""" + return str((c.post(f"{_cs(c, project_id, cluster_id)}/{backup_id}/restore", body).body or {}).get("restoreId", "")) + + +def list_snapshot_restores(c: CapellaClient, project_id: str, cluster_id: str) -> list[Body]: + """GET …/cloudsnapshotbackups/restores (paginated)""" + return c.paginate(f"{_cs(c, project_id, cluster_id)}/restores") + + +# ---------------------------------------------- Bucket backups (CAP-058, CAP-059) +def get_bucket_backup_schedule(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str) -> Body: + """GET …/buckets/{bucketId}/backup/schedules (404 when none) (#tag/Backup-Schedule-(Bucket))""" + return dict(c.get(f"{_b(c, project_id, cluster_id, bucket_id)}/backup/schedules").body or {}) + + +def create_bucket_backup_schedule(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str, body: Body) -> None: + """POST …/backup/schedules → 202 {type: weekly, weeklySchedule}""" + c.post(f"{_b(c, project_id, cluster_id, bucket_id)}/backup/schedules", body) + + +def update_bucket_backup_schedule(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str, body: Body) -> None: + """PUT …/backup/schedules → 204""" + c.put(f"{_b(c, project_id, cluster_id, bucket_id)}/backup/schedules", body) + + +def delete_bucket_backup_schedule(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str) -> None: + """DELETE …/backup/schedules → 202""" + c.delete(f"{_b(c, project_id, cluster_id, bucket_id)}/backup/schedules") + + +def list_backup_cycles(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str) -> list[Body]: + """GET …/backup/cycles → {data: [{cycleID, createdAt}]}""" + return list((c.get(f"{_b(c, project_id, cluster_id, bucket_id)}/backup/cycles").body or {}).get("data") or []) + + +def list_cycle_backups(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str, cycle_id: str) -> list[Body]: + """GET …/backup/cycles/{cycleId} → {data: [backup]}""" + path = f"{_b(c, project_id, cluster_id, bucket_id)}/backup/cycles/{cycle_id}" + return list((c.get(path).body or {}).get("data") or []) + + +def create_bucket_backup(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str) -> None: + """POST …/buckets/{bucketId}/backups → 202, no body: the new backup is found by listing (#tag/Backups-and-Restore-(Bucket))""" + c.post(f"{_b(c, project_id, cluster_id, bucket_id)}/backups", {}) + + +def list_cluster_backups(c: CapellaClient, project_id: str, cluster_id: str) -> list[Body]: + """GET …/clusters/{clusterId}/backups → {data}: the latest backup of each bucket (queued ones are not listed)""" + return list((c.get(f"{_c(c, project_id, cluster_id)}/backups").body or {}).get("data") or []) + + +def get_backup(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str) -> Body: + """GET …/clusters/{clusterId}/backups/{backupId}""" + return dict(c.get(f"{_c(c, project_id, cluster_id)}/backups/{backup_id}").body or {}) + + +def delete_backup(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str) -> None: + """DELETE …/backups/{backupId} → 202: deletes every backup of the backup's cycle""" + c.delete(f"{_c(c, project_id, cluster_id)}/backups/{backup_id}") + + +def restore_backup(c: CapellaClient, project_id: str, cluster_id: str, backup_id: str, body: Body) -> None: + """POST …/backups/{backupId}/restore → 202; targetClusterID, sourceClusterID, backupID and services required""" + c.post(f"{_c(c, project_id, cluster_id)}/backups/{backup_id}/restore", body) + + +def export_backup(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str, backup_id: str) -> str: + """POST …/buckets/{bucketId}/backups/{backupId}/export → 202 {exportId}; 409 when the cycle has one already""" + path = f"{_b(c, project_id, cluster_id, bucket_id)}/backups/{backup_id}/export" + return str((c.post(path, {}).body or {}).get("exportId", "")) + + +def get_backup_export(c: CapellaClient, project_id: str, cluster_id: str, bucket_id: str, backup_id: str, + export_id: str) -> Body: + """GET …/backups/{backupId}/exports/{exportId} → status; backupDownloadURL once complete (valid 1 hour)""" + return dict(c.get(f"{_b(c, project_id, cluster_id, bucket_id)}/backups/{backup_id}/exports/{export_id}").body or {}) + + # ------------------------------------------------------- Endpoint inventory # (method, path template) for every endpoint above. Used by tests and the fake harness (CAP-004 AC1). _ORG = "/v4/organizations/{organizationId}" @@ -375,6 +511,17 @@ def delete_credential(c: CapellaClient, project_id: str, cluster_id: str, user_i ("DELETE", _BKT + "/scopes/{scopeName}/collections/{collectionName}"), ("GET", _CLU + "/users"), ("POST", _CLU + "/users"), ("GET", _CLU + "/users/{userId}"), ("PUT", _CLU + "/users/{userId}"), ("DELETE", _CLU + "/users/{userId}"), + ("GET", _CLU + "/cloudsnapshotbackupschedule"), ("PUT", _CLU + "/cloudsnapshotbackupschedule"), + ("DELETE", _CLU + "/cloudsnapshotbackupschedule"), ("GET", _CLU + "/cloudsnapshotbackupschedule/options"), + ("GET", _CLU + "/cloudsnapshotbackups/regions"), ("GET", _CLU + "/cloudsnapshotbackups"), + ("GET", _PRJ + "/cloudsnapshotbackups"), ("POST", _CLU + "/cloudsnapshotbackups"), + ("PUT", _CLU + "/cloudsnapshotbackups/{backupId}"), ("DELETE", _CLU + "/cloudsnapshotbackups/{backupId}"), + ("POST", _CLU + "/cloudsnapshotbackups/{backupId}/restore"), ("GET", _CLU + "/cloudsnapshotbackups/restores"), + ("GET", _BKT + "/backup/schedules"), ("POST", _BKT + "/backup/schedules"), ("PUT", _BKT + "/backup/schedules"), + ("DELETE", _BKT + "/backup/schedules"), ("GET", _BKT + "/backup/cycles"), ("GET", _BKT + "/backup/cycles/{cycleId}"), + ("POST", _BKT + "/backups"), ("GET", _CLU + "/backups"), ("GET", _CLU + "/backups/{backupId}"), + ("DELETE", _CLU + "/backups/{backupId}"), ("POST", _CLU + "/backups/{backupId}/restore"), + ("POST", _BKT + "/backups/{backupId}/export"), ("GET", _BKT + "/backups/{backupId}/exports/{exportId}"), ] # Roles needed per endpoint, from the API reference, for 403 messages (CAP-003 AC2). @@ -383,6 +530,10 @@ def delete_credential(c: CapellaClient, project_id: str, cluster_id: str, user_i ("PUT", r"^/v4/organizations/[^/]+/projects/[^/]+$", "Organization Owner or Project Owner"), ("DELETE", r"^/v4/organizations/[^/]+/projects/[^/]+$", "Organization Owner or Project Owner"), ("GET", r"^/v4/organizations(/[^/]+)?(/projects)?$", "any organization role (Organization Member or above)"), + # Backups (spec): bucket backup schedules and snapshot schedule options also allow Project Manager; the rest not + ("*", r"/(buckets/[^/]+/backup/schedules|cloudsnapshotbackupschedule/options)$", + "Organization Owner, Project Owner or Project Manager"), + ("*", r"/(cloudsnapshotbackups?|cloudsnapshotbackupschedule|backups?)(/|$)", "Organization Owner or Project Owner"), ("*", r"/clusters", "Organization Owner, Project Owner or Project Manager"), ] diff --git a/capellactl/cli/backup_common.py b/capellactl/cli/backup_common.py new file mode 100644 index 0000000..3aae7f7 --- /dev/null +++ b/capellactl/cli/backup_common.py @@ -0,0 +1,63 @@ +"""Helpers shared by `cluster backup` (CAP-056, CAP-057) and `bucket backup` (CAP-058, CAP-059).""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any + +from capellactl.cli.console import note +from capellactl.cli.exit_codes import CliExit, ExitCode +from capellactl.engine.clock import format_duration +from capellactl.resources.cluster import is_free + +# Capella documents these statuses as free text; the values below are the ones it uses for done and failed. +DONE = {"complete", "completed", "succeeded", "success", "done", "ready"} +FAILED = {"failed", "error", "cancelled", "canceled", "expired"} +FREE_NO_BACKUPS = "free-tier clusters have no backups; create a provisioned cluster for data that needs them" + + +def refuse_free(cluster: dict[str, Any]) -> None: + if is_free(cluster): + raise CliExit(ExitCode.BLOCKED, FREE_NO_BACKUPS, topic="backup") + + +def when(value: Any) -> str: + """'2026-10-28T02:00:00Z' → '2026-10-28 02:00' (UTC); '' when absent.""" + if not value: + return "" + try: + t = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return str(value) + if t.tzinfo is not None: + t = t.astimezone(timezone.utc) + return t.strftime("%Y-%m-%d %H:%M") + + +def size_text(n_bytes: Any) -> str: + """Bytes → '42 GB', '120 MB'.""" + if n_bytes in (None, ""): + return "" + n = float(n_bytes) + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.0f} {unit}" if unit == "B" or n >= 10 else f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +def parse_hours(text: str, flag: str) -> int: + """'168', '168h' or '7d' → hours (exit 3 otherwise).""" + m = re.fullmatch(r"\s*(\d+)\s*([hd]?)\s*", str(text).lower()) + if not m or int(m.group(1)) == 0: + raise CliExit(ExitCode.CONFIG_INVALID, f"{flag} takes hours or days, for example 168, 168h or 7d (got " + f"'{text}')", topic="backup") + return int(m.group(1)) * (24 if m.group(2) == "d" else 1) + + +def progress(label: str) -> Any: + """on_state for wait_for_status: one line per status change.""" + def on_state(state: str, elapsed: float) -> None: + note(f" {label}: {state} ({format_duration(elapsed)})") + return on_state diff --git a/capellactl/cli/bucket_backup.py b/capellactl/cli/bucket_backup.py new file mode 100644 index 0000000..a114f48 --- /dev/null +++ b/capellactl/cli/bucket_backup.py @@ -0,0 +1,512 @@ +"""bucket backup …: backups of one bucket. + +`schedule show | set | delete` and `cycle list | get` (CAP-058): set and delete record `backupSchedule` under the +bucket in capella.yaml and apply it. `list | get | create | delete | restore | export` (CAP-059) act on the backups +themselves; they write nothing to capella.yaml. +""" + +from __future__ import annotations + +import contextlib +from typing import Any + +import click +from ruamel.yaml.comments import CommentedMap + +from capellactl.api import endpoints as ep +from capellactl.api.errors import Conflict, NotFound, ValidationFailed +from capellactl.cli import console +from capellactl.cli.backup_common import DONE, FAILED, progress, refuse_free, when +from capellactl.cli.bucket_cmds import _bucket_node, _find_bucket +from capellactl.cli.cluster_ops import change_and_apply, locate, open_editor +from capellactl.cli.console import note, out +from capellactl.cli.env import pass_state +from capellactl.cli.exit_codes import CliExit, ExitCode +from capellactl.cli.groups import SuggestingGroup +from capellactl.cli.resource_cmds import ( + _list_table, + complete_cluster_names, + find_cluster, + live_cluster, + project_of_cluster, +) +from capellactl.cli.state import AppState +from capellactl.config.editor import BUCKET_KEY_ORDER, ConfigEditor, put, to_commented +from capellactl.engine.clock import get_clock +from capellactl.engine.waiter import StatusFailed, StatusTimeout, wait_for_status +from capellactl.output.render import dumps +from capellactl.resources.backup_schedule import BUCKET_DEFAULTS, RETENTION_WORDS, bucket_summary + +PROJECT_OPT = click.option("--project", metavar="NAME", help="Project of the cluster (only needed when the name " + "repeats).") +CLUSTER_ARG = click.argument("cluster", shell_complete=complete_cluster_names) +DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] +INCREMENTS = [1, 2, 4, 6, 8, 12, 24] + + +@click.group("backup", cls=SuggestingGroup, epilog="\b\nExamples:\n" + " capellactl bucket backup schedule set nova-prod-01 orders\n" + " capellactl bucket backup list nova-prod-01\n" + " capellactl bucket backup create nova-prod-01 orders\n" + " capellactl bucket backup restore nova-prod-01 BACKUP_ID --replace-ttl none\n" + "Backups of the whole cluster: capellactl cluster backup --help. Both kinds: capellactl help backup") +def backup() -> None: + """Bucket backups (weekly full + incremental): schedule, cycles, list, get, create, delete, restore, export.""" + + +@backup.group("schedule", cls=SuggestingGroup, epilog="\b\nExamples:\n" + " capellactl bucket backup schedule set nova-prod-01 orders\n" + " capellactl bucket backup schedule set nova-prod-01 orders --day sunday --start-at 2 " + "--incremental-every 12 --retention 90days\n" + " capellactl bucket backup schedule show nova-prod-01 orders\n" + " capellactl bucket backup schedule delete nova-prod-01 orders") +def schedule() -> None: + """When a bucket is backed up and how long backups are kept (recorded in capella.yaml).""" + + +@backup.group("cycle", cls=SuggestingGroup, epilog="\b\nExamples:\n" + " capellactl bucket backup cycle list nova-prod-01 orders\n" + " capellactl bucket backup cycle get nova-prod-01 orders CYCLE_ID") +def cycle() -> None: + """Backup cycles: a weekly full backup and the incrementals that follow it.""" + + +def _live_bucket(state: AppState, cluster: str, name: str, project: str | None) -> tuple[Any, str, dict[str, Any], str]: + """(client, project id, cluster, bucket id) of a bucket that exists in Capella.""" + client, pid, cl = live_cluster(state, cluster, project) + refuse_free(cl) + b = _find_bucket(client, pid, cl, name) + if b is None: + raise CliExit(ExitCode.ERROR, f"bucket {name} not found in cluster {cluster}") + return client, pid, cl, str(b["id"]) + + +def _live(state: AppState, cluster: str, project: str | None) -> tuple[Any, str, dict[str, Any]]: + client, pid, cl = live_cluster(state, cluster, project) + refuse_free(cl) + return client, pid, cl + + +def _backup_rows(items: list[dict[str, Any]], bucket: bool = True) -> list[tuple[str, ...]]: + rows: list[tuple[str, ...]] = [(*(("BUCKET",) if bucket else ()), "DATE", "METHOD", "SOURCE", "STATUS", "SIZE", + "ITEMS", "EXPIRES", "ID")] + for b in sorted(items, key=lambda b: str(b.get("date")), reverse=True): + stats = b.get("stats") or {} + size = stats.get("sizeInMb") + rows.append((*((str(b.get("bucketName")),) if bucket else ()), when(b.get("date")), str(b.get("method", "")), + str(b.get("source", "")), str(b.get("status", "")), + f"{size / 1024:.1f} GB" if isinstance(size, (int, float)) and size >= 1024 else + (f"{size:.0f} MB" if isinstance(size, (int, float)) else ""), + f"{int(stats.get('items') or 0):,}", when(b.get("restoreBefore")), str(b.get("id")))) + return rows + + +# --------------------------------------------------------------- schedule show +@schedule.command("show", epilog="\b\nExamples:\n capellactl bucket backup schedule show nova-prod-01 orders\n" + " capellactl -o json bucket backup schedule show nova-prod-01 orders") +@CLUSTER_ARG +@click.argument("bucket") +@PROJECT_OPT +@pass_state +def schedule_show(state: AppState, cluster: str, bucket: str, project: str | None) -> None: + """Show a bucket's backup schedule (CAP-058 AC4).""" + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + try: + body: dict[str, Any] | None = ep.get_bucket_backup_schedule(client, pid, str(cl["id"]), bid) + except NotFound: + body = None + if state.json: + out(dumps({"cluster": cluster, "bucket": bucket, **(body or {})})) + return + if not body: + out(f"bucket {bucket} has no backup schedule; set one with 'capellactl bucket backup schedule set {cluster} " + f"{bucket}'") + return + out(bucket_summary(body.get("weeklySchedule") or {})) + + +# ---------------------------------------------------------------- schedule set +@schedule.command("set", epilog="\b\nExamples:\n" + " capellactl bucket backup schedule set nova-prod-01 orders\n" + " capellactl bucket backup schedule set nova-prod-01 orders --day sunday --start-at 2 " + "--incremental-every 12 --retention 90days\n" + " capellactl bucket backup schedule set nova-prod-01 orders --retention 1year (change one value)\n" + "Defaults for a new schedule: sunday, 0 (UTC), every 24 h, 30days, not cost-optimized.") +@CLUSTER_ARG +@click.argument("bucket") +@click.option("--day", metavar="DAY", help=f"Day of the weekly full backup: {', '.join(DAYS)}.") +@click.option("--start-at", type=int, metavar="HOUR", help="Hour (UTC, 0–23) the backups start.") +@click.option("--incremental-every", type=int, metavar="HOURS", + help=f"Hours between incremental backups: {', '.join(map(str, INCREMENTS))}.") +@click.option("--retention", metavar="TIME", help=f"How long backups are kept: {', '.join(RETENTION_WORDS)}.") +@click.option("--cost-optimized/--no-cost-optimized", default=None, help="Capella's cost-optimized retention.") +@PROJECT_OPT +@click.option("--no-apply", is_flag=True, help="Only record the schedule in capella.yaml; apply later.") +@pass_state +def schedule_set(state: AppState, cluster: str, bucket: str, day: str | None, start_at: int | None, + incremental_every: int | None, retention: str | None, cost_optimized: bool | None, + project: str | None, no_apply: bool) -> None: + """Create or change a bucket's backup schedule: records it in capella.yaml and applies it (CAP-058 AC1).""" + ed = open_editor(state) + proj, cl = locate(ed, cluster, project_of_cluster(state, cluster, project)) + if cl.get("tier") == "free": + raise CliExit(ExitCode.BLOCKED, "free-tier clusters have no backups; create a provisioned cluster for data " + "that needs them", topic="backup") + node = _bucket_node(cl, bucket) + if node is None: + raise CliExit(ExitCode.ERROR, f"bucket {bucket} is not in capella.yaml for {cluster}; create it with " + f"'capellactl bucket create {cluster} {bucket}' or run 'capellactl sync'") + current = node.get("backupSchedule") + new = not isinstance(current, CommentedMap) or current.get("ensure") == "absent" + given = {"dayOfWeek": day, "startAt": start_at, "incrementalEvery": incremental_every, + "retentionTime": retention, "costOptimizedRetention": cost_optimized} + values = dict(BUCKET_DEFAULTS) if new else {**BUCKET_DEFAULTS, **{k: v for k, v in dict(current).items() + if k in BUCKET_DEFAULTS}} + if new and all(v is None for v in given.values()) and console.is_interactive(): + out("Press Enter to keep the default.") + given = {"dayOfWeek": console.ask("Day of the weekly full backup", values["dayOfWeek"], + type=click.Choice(DAYS)), + "startAt": console.ask("Hour (UTC) the backups start", values["startAt"], type=click.IntRange(0, 23)), + "incrementalEvery": console.ask("Hours between incremental backups", values["incrementalEvery"], + type=click.Choice([str(i) for i in INCREMENTS])), + "retentionTime": console.ask("Keep backups for", values["retentionTime"], + type=click.Choice(list(RETENTION_WORDS))), + "costOptimizedRetention": console.ask_yes_no("Cost-optimized retention?", + bool(values["costOptimizedRetention"]))} + given["incrementalEvery"] = int(str(given["incrementalEvery"])) + values.update({k: v for k, v in given.items() if v is not None}) + put(node, "backupSchedule", to_commented(values, "backupSchedule"), order=BUCKET_KEY_ORDER) + change_and_apply(state, ed, f"bucket-backup-schedule/{cluster}/{bucket}", + f"Record this backup schedule in capella.yaml and apply it to bucket {bucket}?", no_apply, + noop_message=f"backup schedule of {bucket} already matches; capella.yaml is up to date") + + +# ------------------------------------------------------------- schedule delete +@schedule.command("delete", epilog="\b\nExamples:\n capellactl bucket backup schedule delete nova-prod-01 orders") +@CLUSTER_ARG +@click.argument("bucket") +@PROJECT_OPT +@pass_state +def schedule_delete(state: AppState, cluster: str, bucket: str, project: str | None) -> None: + """Delete a bucket's backup schedule and remove it from capella.yaml; existing backups are kept (CAP-058 AC4).""" + owner = project_of_cluster(state, cluster, project) + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + try: + body: dict[str, Any] | None = ep.get_bucket_backup_schedule(client, pid, str(cl["id"]), bid) + except NotFound: + body = None + if body: + out(f" - backup schedule {cluster}/{bucket} {bucket_summary(body.get('weeklySchedule') or {})}") + if not console.confirm(state, f"Delete the backup schedule of {bucket}? Existing backups are kept until they " + "expire.", "delete the backup schedule"): + out("Cancelled; the backup schedule is unchanged.") + return + ep.delete_bucket_backup_schedule(client, pid, str(cl["id"]), bid) + out(f"deleted the backup schedule of {bucket}") + else: + out(f"bucket {bucket} has no backup schedule; nothing to do in Capella") + path = state.config_path() + if path.exists(): + ed = ConfigEditor(path) + try: + found = ed.find_cluster(cluster, owner) + except LookupError: + found = None + node = _bucket_node(found[1], bucket) if found is not None else None + if node is not None and "backupSchedule" in node: + del node["backupSchedule"] + saved = ed.save() + note(f"{path.name} updated: backupSchedule removed for bucket {bucket} (previous version kept as " + f"{saved.name})") + + +# ---------------------------------------------------------------------- cycles +@cycle.command("list", epilog="\b\nExamples:\n capellactl bucket backup cycle list nova-prod-01 orders") +@CLUSTER_ARG +@click.argument("bucket") +@PROJECT_OPT +@pass_state +def cycle_list(state: AppState, cluster: str, bucket: str, project: str | None) -> None: + """List a bucket's backup cycles, newest first (CAP-058 AC5).""" + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + cycles = ep.list_backup_cycles(client, pid, str(cl["id"]), bid) + if state.json: + out(dumps(cycles)) + return + rows: list[tuple[str, ...]] = [("STARTED", "BACKUPS", "LATEST STATUS", "ID")] + for c in sorted(cycles, key=lambda c: str(c.get("createdAt")), reverse=True): + backups = ep.list_cycle_backups(client, pid, str(cl["id"]), bid, str(c.get("cycleID"))) + latest = max(backups, key=lambda b: str(b.get("date")), default={}) + rows.append((when(c.get("createdAt")), str(len(backups)), str(latest.get("status", "-")), str(c.get("cycleID")))) + out(_list_table(rows) if cycles else "no backup cycles") + + +@cycle.command("get", epilog="\b\nExamples:\n capellactl bucket backup cycle get nova-prod-01 orders CYCLE_ID") +@CLUSTER_ARG +@click.argument("bucket") +@click.argument("cycle_id", metavar="CYCLE_ID") +@PROJECT_OPT +@pass_state +def cycle_get(state: AppState, cluster: str, bucket: str, cycle_id: str, project: str | None) -> None: + """List the backups of one cycle: full and incremental, scheduled and on-demand (CAP-058 AC5).""" + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + try: + items = ep.list_cycle_backups(client, pid, str(cl["id"]), bid, cycle_id) + except NotFound: + raise CliExit(ExitCode.ERROR, f"cycle {cycle_id} of {bucket} not found; list them with 'capellactl bucket " + f"backup cycle list {cluster} {bucket}'") from None + out(dumps(items) if state.json else _list_table(_backup_rows(items, bucket=False))) + + +# ------------------------------------------------------------------- list, get +def _bucket_backups(client: Any, pid: str, cid: str, bid: str) -> list[dict[str, Any]]: + """Every backup of a bucket, across its cycles.""" + return [b for c in ep.list_backup_cycles(client, pid, cid, bid) + for b in ep.list_cycle_backups(client, pid, cid, bid, str(c.get("cycleID")))] + + +@backup.command("list", epilog="\b\nExamples:\n capellactl bucket backup list nova-prod-01 (the latest of " + "each bucket)\n capellactl bucket backup list nova-prod-01 orders (every backup of " + "orders)") +@CLUSTER_ARG +@click.argument("bucket", required=False) +@PROJECT_OPT +@pass_state +def backup_list(state: AppState, cluster: str, bucket: str | None, project: str | None) -> None: + """List bucket backups: the latest of each bucket, or every backup of BUCKET (CAP-059 AC1).""" + if bucket is None: + client, pid, cl = _live(state, cluster, project) + items = ep.list_cluster_backups(client, pid, str(cl["id"])) + else: + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + items = _bucket_backups(client, pid, str(cl["id"]), bid) + if state.json: + out(dumps(items)) + return + out(_list_table(_backup_rows(items)) if items else "no backups") + + +@backup.command("get", epilog="\b\nExamples:\n capellactl bucket backup get nova-prod-01 BACKUP_ID") +@CLUSTER_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@PROJECT_OPT +@pass_state +def backup_get(state: AppState, cluster: str, backup_id: str, project: str | None) -> None: + """Show one backup with its statistics (CAP-059 AC1).""" + client, pid, cl = _live(state, cluster, project) + b = _get_backup(client, pid, str(cl["id"]), backup_id, cluster) + if state.json: + out(dumps(b)) + return + out(_list_table(_backup_rows([b]))) + stats = b.get("stats") or {} + out(f" mutations {stats.get('mutations', 0):,} · tombstones {stats.get('tombstones', 0):,} · indexes " + f"{stats.get('gsi', 0)} · search {stats.get('fts', 0)} · analytics {stats.get('cbas', 0)} · eventing " + f"{stats.get('event', 0)} · took {b.get('elapsedTimeInSeconds', 0)} s · cycle {b.get('cycleID')}") + + +def _get_backup(client: Any, pid: str, cid: str, backup_id: str, cluster: str) -> dict[str, Any]: + try: + return ep.get_backup(client, pid, cid, backup_id) + except NotFound: + raise CliExit(ExitCode.ERROR, f"backup {backup_id} not found in cluster {cluster}; list them with " + f"'capellactl bucket backup list {cluster}'") from None + + +# ---------------------------------------------------------------------- create +@backup.command("create", epilog="\b\nExamples:\n capellactl bucket backup create nova-prod-01 orders\n" + " capellactl --no-wait bucket backup create nova-prod-01 orders") +@CLUSTER_ARG +@click.argument("bucket") +@PROJECT_OPT +@pass_state +def backup_create(state: AppState, cluster: str, bucket: str, project: str | None) -> None: + """Back a bucket up now and wait until the backup is ready (CAP-059 AC2).""" + client, pid, cl, bid = _live_bucket(state, cluster, bucket, project) + cid = str(cl["id"]) + + def latest() -> dict[str, Any] | None: # Capella lists the latest backup of each bucket (not queued ones) + return next((b for b in ep.list_cluster_backups(client, pid, cid) if b.get("bucketID") == bid + or b.get("bucketName") == bucket), None) + + before = (latest() or {}).get("id") + note(f"→ backing up bucket {bucket} …") + ep.create_bucket_backup(client, pid, cid, bid) + if state.no_wait: + out(f"backup of {bucket} started (--no-wait); see it with 'capellactl bucket backup list {cluster} {bucket}'") + return + found: dict[str, Any] = {} + + def status() -> str | None: + b = latest() + if b is None or b.get("id") == before: + return None # queued: not listed yet + found.update(b) + return str(b.get("status")) + + try: + wait_for_status(status, DONE, FAILED, state.timeout, get_clock(), f"backup of {bucket}", + progress(f"backup of {bucket}")) + except StatusFailed as exc: + raise CliExit(ExitCode.ERROR, f"Failed: {exc}", topic="backup") from None + except StatusTimeout as exc: + raise CliExit(ExitCode.TIMEOUT, str(exc)) from None + out(f"backup {found.get('id')} of {bucket} is ready") + + +# ---------------------------------------------------------------------- delete +@backup.command("delete", epilog="\b\nExamples:\n capellactl bucket backup delete nova-prod-01 BACKUP_ID\n" + "Deletes every backup of the backup's cycle (the weekly full and its incrementals).") +@CLUSTER_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@PROJECT_OPT +@pass_state +def backup_delete(state: AppState, cluster: str, backup_id: str, project: str | None) -> None: + """Delete a backup and every other backup of its cycle (CAP-059 AC3).""" + client, pid, cl = _live(state, cluster, project) + cid = str(cl["id"]) + try: + b = ep.get_backup(client, pid, cid, backup_id) + except NotFound: + out(f"backup {backup_id} not found in cluster {cluster}, nothing to do") + return + bucket = str(b.get("bucketName")) + try: + in_cycle = ep.list_cycle_backups(client, pid, cid, str(b.get("bucketID")), str(b.get("cycleID"))) + except NotFound: + in_cycle = [b] + first = min((str(x.get("date")) for x in in_cycle), default=str(b.get("date"))) + out(f" - deletes cycle {when(first)} with {len(in_cycle)} backup{'s' if len(in_cycle) != 1 else ''} of {bucket}") + if not console.confirm_typed(state, "Type the bucket name to confirm deletion", "delete a backup cycle", bucket): + out("Cancelled; nothing was deleted.") + return + with contextlib.suppress(NotFound): + ep.delete_backup(client, pid, cid, backup_id) + out(f"deleted the backup cycle of {bucket} that started {when(first)} ({len(in_cycle)} backups)") + + +# --------------------------------------------------------------------- restore +@backup.command("restore", epilog="\b\nExamples:\n" + " capellactl bucket backup restore nova-prod-01 BACKUP_ID --replace-ttl none\n" + " capellactl bucket backup restore nova-prod-01 BACKUP_ID --to-cluster nova-dr-01 " + "--include-data orders.sales\n" + "Without --force-updates, documents that are newer in the target are kept.") +@CLUSTER_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@click.option("--to-cluster", metavar="NAME", help="Restore into this cluster of the same project (default: the " + "backup's cluster).") +@click.option("--services", default="data", show_default=True, help="What to restore: data, query or data,query.") +@click.option("--include-data", metavar="B.S.C,…", help="Restore only these buckets, scopes or collections.") +@click.option("--exclude-data", metavar="B.S.C,…", help="Skip these buckets, scopes or collections.") +@click.option("--map-data", metavar="SRC=DST,…", help="Restore into other buckets, scopes or collections.") +@click.option("--filter-keys", metavar="REGEX", help="Only documents whose key matches.") +@click.option("--filter-values", metavar="REGEX", help="Only documents whose value matches.") +@click.option("--replace-ttl", type=click.Choice(["none", "all", "expired"]), help="Give documents a new expiry.") +@click.option("--replace-ttl-with", metavar="TIME", help="The new expiry (with --replace-ttl all or expired).") +@click.option("--force-updates", is_flag=True, help="Overwrite documents even when the target's are newer.") +@click.option("--auto-remove-collections", is_flag=True, help="Delete scopes and collections the backup knows were " + "deleted.") +@PROJECT_OPT +@pass_state +def backup_restore(state: AppState, cluster: str, backup_id: str, to_cluster: str | None, services: str, + include_data: str | None, exclude_data: str | None, map_data: str | None, filter_keys: str | None, + filter_values: str | None, replace_ttl: str | None, replace_ttl_with: str | None, + force_updates: bool, auto_remove_collections: bool, project: str | None) -> None: + """Restore a bucket backup into the same or another cluster, all of it or part of it (CAP-059 AC4, AC5).""" + names = sorted({s.strip() for s in services.split(",") if s.strip()}) + if not names or set(names) - {"data", "query"}: + raise CliExit(ExitCode.CONFIG_INVALID, f"--services takes data, query or data,query (got '{services}')", + topic="backup") + if replace_ttl_with and replace_ttl not in ("all", "expired"): + raise CliExit(ExitCode.CONFIG_INVALID, "--replace-ttl-with needs --replace-ttl all or --replace-ttl expired", + topic="backup") + client, pid, cl = _live(state, cluster, project) + cid = str(cl["id"]) + b = _get_backup(client, pid, cid, backup_id, cluster) + target = cl + if to_cluster and to_cluster != cluster: + target = find_cluster(client, pid, to_cluster) + refuse_free(target) + target_name = str(target.get("name")) + if target.get("currentState") in ("turnedOff", "turningOff"): + raise CliExit(ExitCode.BLOCKED, f"cluster {target_name} is turned off; power it on before restoring " + f"('capellactl cluster power {target_name} --on')", topic="hibernation") + body: dict[str, Any] = {"targetClusterID": str(target["id"]), "sourceClusterID": cid, "backupID": backup_id, + "services": names} + for key, value in (("includeData", include_data), ("excludeData", exclude_data), ("mapData", map_data), + ("filterKeys", filter_keys), ("filterValues", filter_values), ("replaceTTL", replace_ttl), + ("replaceTTLWith", replace_ttl_with)): + if value: + body[key] = value + if force_updates: + body["forceUpdates"] = True + if auto_remove_collections: + body["autoRemoveCollections"] = True + out(f" ~ restore {b.get('bucketName')} from the backup of {when(b.get('date'))} UTC into {target_name}") + out(f" services: {', '.join(names)} · " + ("overwrites newer documents (--force-updates)" if force_updates else + f"newer documents in {target_name} are kept")) + for label, value in (("only", include_data), ("skipping", exclude_data), ("mapped", map_data)): + if value: + out(f" {label}: {value}") + if not console.confirm_typed(state, "Type the target cluster name to confirm the restore", "restore a bucket", + target_name): + out("Cancelled; nothing was restored.") + return + ep.restore_backup(client, pid, cid, backup_id, body) + out(f"restore of {b.get('bucketName')} into {target_name} started; Capella reports its progress in the " + "cluster's activity log (the Management API has no restore status for bucket backups)") + + +# ---------------------------------------------------------------------- export +@backup.command("export", epilog="\b\nExamples:\n capellactl bucket backup export nova-prod-01 BACKUP_ID\n" + " capellactl bucket backup export nova-prod-01 BACKUP_ID --export-id EXPORT_ID\n" + "The download link is valid for one hour; run the command again for a new one.") +@CLUSTER_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@click.option("--export-id", metavar="ID", help="Show an export started earlier (its status and a new download link).") +@PROJECT_OPT +@pass_state +def backup_export(state: AppState, cluster: str, backup_id: str, export_id: str | None, project: str | None) -> None: + """Package a backup cycle as a zip and print its download link (CAP-059 AC6).""" + client, pid, cl = _live(state, cluster, project) + cid = str(cl["id"]) + b = _get_backup(client, pid, cid, backup_id, cluster) + bid = str(b.get("bucketID")) + if export_id is None: + try: + export_id = ep.export_backup(client, pid, cid, bid, backup_id) + except Conflict: + out(f"the backup cycle of {b.get('bucketName')} already has an export (Capella allows one per cycle); " + f"show it with --export-id ID (printed when it was started)") + return + except ValidationFailed as exc: + raise CliExit(ExitCode.BLOCKED, f"Capella cannot export this backup: {exc.message}", topic="backup") \ + from None + out(f"export {export_id} of {b.get('bucketName')} started") + found: dict[str, Any] = {} + + def status() -> str | None: + found.clear() + found.update(ep.get_backup_export(client, pid, cid, bid, backup_id, str(export_id))) + return str(found.get("status")) + + if state.no_wait: + status() + else: + try: + wait_for_status(status, {"complete"}, {"failed", "expired"}, state.timeout, get_clock(), + f"export {export_id}", progress(f"export of {b.get('bucketName')}")) + except StatusFailed as exc: + raise CliExit(ExitCode.ERROR, f"Failed: {exc}", topic="backup") from None + except StatusTimeout as exc: + raise CliExit(ExitCode.TIMEOUT, str(exc)) from None + if state.json: + out(dumps(found)) + return + if found.get("status") != "complete": + out(f"export {export_id}: {found.get('status')}; run this command with --export-id {export_id} to check again") + return + out(f"export {export_id} is complete: {found.get('sizeInBytes', 0) / 1024 ** 3:.1f} GB, available until " + f"{when(found.get('expiration'))} UTC") + out("download (valid for one hour; treat it like a password):") + out(f" {found.get('backupDownloadURL')}") diff --git a/capellactl/cli/bucket_cmds.py b/capellactl/cli/bucket_cmds.py index fb6b255..4873ee1 100644 --- a/capellactl/cli/bucket_cmds.py +++ b/capellactl/cli/bucket_cmds.py @@ -50,7 +50,7 @@ " capellactl bucket get nova-dev-01 orders\n" " capellactl bucket delete nova-dev-01 orders") def bucket() -> None: - """Buckets: list, get, create, update, delete.""" + """Buckets: list, get, create, update, delete, backup.""" def _file_buckets(state: AppState, cluster: str, project: str | None) -> set[str]: diff --git a/capellactl/cli/cluster_backup.py b/capellactl/cli/cluster_backup.py new file mode 100644 index 0000000..319c871 --- /dev/null +++ b/capellactl/cli/cluster_backup.py @@ -0,0 +1,468 @@ +"""cluster backup …: cloud snapshot backups of a whole cluster. + +`schedule show | options | set | delete` (CAP-056): set and delete record `backupSchedule` in capella.yaml and apply +it, like the hibernation schedule. `list | create | retain | delete | restore | restores | regions` (CAP-057) act on +the backups themselves; they write nothing to capella.yaml. +""" + +from __future__ import annotations + +import contextlib +from datetime import datetime, timedelta, timezone +from typing import Any + +import click +from ruamel.yaml.comments import CommentedMap +from ruamel.yaml.scalarstring import DoubleQuotedScalarString + +from capellactl.api import endpoints as ep +from capellactl.api.errors import Forbidden, NotFound +from capellactl.cli import console +from capellactl.cli.backup_common import DONE, FAILED, parse_hours, progress, refuse_free, size_text, when +from capellactl.cli.cluster_ops import change_and_apply, locate, open_editor +from capellactl.cli.console import note, out, wait_progress +from capellactl.cli.env import pass_state +from capellactl.cli.exit_codes import CliExit, ExitCode +from capellactl.cli.groups import SuggestingGroup +from capellactl.cli.resource_cmds import ( + _file_org, + _list_table, + _project_id, + complete_cluster_names, + live_cluster, + pick_project, + project_of_cluster, +) +from capellactl.cli.state import AppState +from capellactl.config.editor import ConfigEditor, put, to_commented +from capellactl.engine.clock import get_clock +from capellactl.engine.waiter import ( + StatusFailed, + StatusTimeout, + WaitFailed, + WaitTimeout, + wait_for_cluster, + wait_for_status, +) +from capellactl.output.render import dumps +from capellactl.resources.backup_schedule import hours_text, options_problem, snapshot_summary, start_hm + +PROJECT_OPT = click.option("--project", metavar="NAME", help="Project of the cluster (only needed when the name " + "repeats).") +NAME_ARG = click.argument("name", shell_complete=complete_cluster_names) + + +@click.group("backup", cls=SuggestingGroup, epilog="\b\nExamples:\n" + " capellactl cluster backup schedule set nova-prod-01 --every 24 --retain 30d\n" + " capellactl cluster backup list nova-prod-01\n" + " capellactl cluster backup create nova-prod-01\n" + " capellactl cluster backup restore nova-prod-01 BACKUP_ID\n" + "Backups of one bucket: capellactl bucket backup --help. Both kinds: capellactl help backup") +def backup() -> None: + """Cluster backups (cloud snapshots of the whole cluster): schedule, list, create, retain, delete, restore.""" + + +@backup.group("schedule", cls=SuggestingGroup, epilog="\b\nExamples:\n" + " capellactl cluster backup schedule options nova-prod-01\n" + " capellactl cluster backup schedule set nova-prod-01 --every 24 --retain 30d\n" + " capellactl cluster backup schedule set nova-prod-01 --start 02:00 --copy-to us-west-2\n" + " capellactl cluster backup schedule show nova-prod-01\n" + " capellactl cluster backup schedule delete nova-prod-01") +def schedule() -> None: + """When the cluster is backed up and how long backups are kept (recorded in capella.yaml).""" + + +def _live(state: AppState, name: str, project: str | None) -> tuple[Any, str, dict[str, Any]]: + client, pid, cl = live_cluster(state, name, project) + refuse_free(cl) + return client, pid, cl + + +def next_backup(body: dict[str, Any], now: datetime) -> datetime | None: + """The next scheduled backup after `now`: startTime plus whole intervals.""" + try: + start = datetime.fromisoformat(str(body.get("startTime")).replace("Z", "+00:00")) + step = timedelta(hours=int(body.get("interval") or 0)) + except (TypeError, ValueError): + return None + if not step or start.tzinfo is None: + return None + now = now.astimezone(timezone.utc) + if start > now: + return start + return start + step * ((now - start) // step + 1) + + +# --------------------------------------------------------------- schedule show +@schedule.command("show", epilog="\b\nExamples:\n capellactl cluster backup schedule show nova-prod-01\n" + " capellactl -o json cluster backup schedule show nova-prod-01") +@NAME_ARG +@PROJECT_OPT +@pass_state +def schedule_show(state: AppState, name: str, project: str | None) -> None: + """Show the cluster's backup schedule and the next backup (CAP-056 AC4).""" + client, pid, cl = _live(state, name, project) + body = ep.get_snapshot_schedule(client, pid, str(cl["id"])) + nxt = next_backup(body, get_clock().now()) if body else None + if state.json: + out(dumps({"cluster": name, **(body or {}), **({"nextBackup": nxt.strftime("%Y-%m-%dT%H:%M:%SZ")} if nxt else {})})) + return + if not body: + out(f"cluster {name} has no backup schedule; set one with 'capellactl cluster backup schedule set {name} " + "--every HOURS --retain DAYSd'") + return + out(snapshot_summary(body)) + if nxt: + out(f"next backup: {nxt.strftime('%Y-%m-%d %H:%M')} UTC") + + +# ------------------------------------------------------------ schedule options +@schedule.command("options", epilog="\b\nExamples:\n capellactl cluster backup schedule options nova-prod-01") +@NAME_ARG +@PROJECT_OPT +@pass_state +def schedule_options(state: AppState, name: str, project: str | None) -> None: + """What the cluster's support plan permits: backup intervals, retention range and copy regions (CAP-056 AC4).""" + client, pid, cl = _live(state, name, project) + options = ep.get_snapshot_schedule_options(client, pid, str(cl["id"])) + regions = ep.list_snapshot_regions(client, pid, str(cl["id"])) + if state.json: + out(dumps({"cluster": name, **options, "regions": regions})) + return + plan = (cl.get("support") or {}).get("plan", "?") + out(f"Cluster backup schedule options for {name} (support plan: {plan})") + out(f" every (hours) {', '.join(str(i) for i in options.get('intervals') or [])}") + out(f" keep {hours_text(options.get('minimumRetention') or 0)} to " + f"{hours_text(options.get('maximumRetention') or 0)}") + out(f" copy regions {', '.join(regions) + ' (up to two)' if regions else 'none (AWS and Azure only)'}") + + +# ----------------------------------------------------------------- schedule set +@schedule.command("set", epilog="\b\nExamples:\n" + " capellactl cluster backup schedule set nova-prod-01 --every 24 --retain 30d\n" + " capellactl cluster backup schedule set nova-prod-01 --every 6 --retain 7d --start 02:00 " + "--copy-to us-west-2\n" + " capellactl cluster backup schedule set nova-prod-01 --retain 90d (change one value)\n" + "Permitted values: capellactl cluster backup schedule options NAME") +@NAME_ARG +@click.option("--every", type=int, metavar="HOURS", help="Hours between backups (needed for a new schedule).") +@click.option("--retain", metavar="DURATION", help="How long each backup is kept: hours or days, e.g. 720, 720h, " + "30d (needed for a new schedule).") +@click.option("--start", metavar="HH:MM", help="Time of day (UTC) of the first backup.") +@click.option("--copy-to", "copy_to", multiple=True, metavar="REGION", help="Also keep a copy in this region " + "(repeatable; at most two).") +@click.option("--no-copies", is_flag=True, help="Stop keeping copies in other regions.") +@PROJECT_OPT +@click.option("--no-apply", is_flag=True, help="Only record the schedule in capella.yaml; apply later.") +@pass_state +def schedule_set(state: AppState, name: str, every: int | None, retain: str | None, start: str | None, + copy_to: tuple[str, ...], no_copies: bool, project: str | None, no_apply: bool) -> None: + """Create or change the backup schedule: records it in capella.yaml and applies it (CAP-056 AC1).""" + ed = open_editor(state) + _, cl = locate(ed, name, project_of_cluster(state, name, project)) + if cl.get("tier") == "free": + raise CliExit(ExitCode.BLOCKED, "free-tier clusters have no backups; create a provisioned cluster for data " + "that needs them", topic="backup") + if every is None and retain is None and start is None and not copy_to and not no_copies: + raise CliExit(ExitCode.CONFIG_INVALID, "pass at least one of --every, --retain, --start, --copy-to or " + "--no-copies", topic="backup") + if start is not None and start_hm(start) != start: + raise CliExit(ExitCode.CONFIG_INVALID, f"--start takes a UTC time of day HH:MM, e.g. 02:00 (got '{start}')", + topic="backup") + current = cl.get("backupSchedule") + new = not isinstance(current, CommentedMap) or current.get("ensure") == "absent" + if new and (every is None or retain is None): + raise CliExit(ExitCode.CONFIG_INVALID, "a new backup schedule needs --every HOURS and --retain DURATION " + f"(permitted values: capellactl cluster backup schedule options {name})", + topic="backup") + values: dict[str, Any] = {} if new else {k: v for k, v in dict(current).items() if k != "ensure"} + if every is not None: + values["interval"] = every + if retain is not None: + values["retention"] = parse_hours(retain, "--retain") + if start is not None: + values["startTime"] = DoubleQuotedScalarString(start) + if copy_to: + values["copyToRegions"] = list(copy_to) + elif no_copies: + values.pop("copyToRegions", None) + _check_permitted(state, name, project, values) + node = to_commented({k: v for k, v in values.items() if k != "copyToRegions"}, "backupSchedule") + if values.get("copyToRegions"): + node["copyToRegions"] = to_commented(values["copyToRegions"], "copyToRegions") + put(cl, "backupSchedule", node) + change_and_apply(state, ed, f"backup-schedule/{name}", + f"Record this backup schedule in capella.yaml and apply it to {name}?", no_apply, + noop_message=f"backup schedule of {name} already matches; capella.yaml is up to date") + + +def _check_permitted(state: AppState, name: str, project: str | None, values: dict[str, Any]) -> None: + """Refuse values the support plan does not permit before anything is written (CAP-056 AC3).""" + try: + client, pid, cl = live_cluster(state, name, project) + except CliExit as exc: + if exc.code is ExitCode.ERROR: # not deployed yet: Capella checks it when the cluster is applied + return + raise + try: + options = ep.get_snapshot_schedule_options(client, pid, str(cl["id"])) + regions = ep.list_snapshot_regions(client, pid, str(cl["id"])) if values.get("copyToRegions") else None + except (NotFound, Forbidden): + return + problem = options_problem(options, values, regions) + if problem: + raise CliExit(ExitCode.CONFIG_INVALID, f"{problem} (see 'capellactl cluster backup schedule options {name}')", + topic="backup") + + +# -------------------------------------------------------------- schedule delete +@schedule.command("delete", epilog="\b\nExamples:\n capellactl cluster backup schedule delete nova-prod-01") +@NAME_ARG +@PROJECT_OPT +@pass_state +def schedule_delete(state: AppState, name: str, project: str | None) -> None: + """Delete the backup schedule in Capella and remove it from capella.yaml; existing backups are kept (AC5).""" + owner = project_of_cluster(state, name, project) + client, pid, cl = _live(state, name, project) + body = ep.get_snapshot_schedule(client, pid, str(cl["id"])) + if body: + out(f" - backup schedule {name} {snapshot_summary(body)}") + if not console.confirm(state, f"Delete the backup schedule of {name}? Existing backups are kept until they " + "expire.", "delete the backup schedule"): + out("Cancelled; the backup schedule is unchanged.") + return + ep.delete_snapshot_schedule(client, pid, str(cl["id"])) + out(f"deleted the backup schedule of {name}") + else: + out(f"cluster {name} has no backup schedule; nothing to do in Capella") + path = state.config_path() + if path.exists(): + ed = ConfigEditor(path) + try: + found = ed.find_cluster(name, owner) + except LookupError: + found = None + if found is not None and "backupSchedule" in found[1]: + del found[1]["backupSchedule"] + saved = ed.save() + note(f"{path.name} updated: backupSchedule removed for {name} (previous version kept as {saved.name})") + + +# ------------------------------------------------------------------------ list +def _snapshot_rows(items: list[dict[str, Any]]) -> list[tuple[str, ...]]: + rows: list[tuple[str, ...]] = [("CREATED", "TYPE", "STATUS", "SIZE", "EXPIRES", "COPIES", "ID")] + for s in sorted(items, key=lambda s: str(s.get("createdAt")), reverse=True): + copies = ", ".join(str(c.get("regionCode")) for c in s.get("crossRegionCopies") or []) or "-" + rows.append((when(s.get("createdAt")), str(s.get("type", "")), str((s.get("progress") or {}).get("status", "")), + size_text(s.get("databaseSize")), when(s.get("expiration")), copies, str(s.get("id")))) + return rows + + +@backup.command("list", epilog="\b\nExamples:\n capellactl cluster backup list nova-prod-01\n" + " capellactl cluster backup list (every cluster of the project)\n" + " capellactl -o json cluster backup list nova-prod-01") +@click.argument("name", required=False, shell_complete=complete_cluster_names) +@PROJECT_OPT +@pass_state +def backup_list(state: AppState, name: str | None, project: str | None) -> None: + """List a cluster's backups, newest first; without NAME, the newest and oldest of each cluster (CAP-057 AC1).""" + if name is None: + client = state.client(_file_org(state)) + pid, _ = _project_id(state, client, pick_project(state, project)[0]) + items = ep.list_project_snapshots(client, pid) + if state.json: + out(dumps(items)) + return + if not items: + out("no backups") + return + rows: list[tuple[str, ...]] = [("CLUSTER", "REGION", "NEWEST", "OLDEST", "CLUSTER ID")] + for c in sorted(items, key=lambda c: str(c.get("clusterName"))): + rows.append((str(c.get("clusterName")), str(c.get("region", "")), + when((c.get("mostRecentSnapshot") or {}).get("createdAt")), + when((c.get("oldestSnapshot") or {}).get("createdAt")), str(c.get("clusterId")))) + out(_list_table(rows)) + return + client, pid, cl = _live(state, name, project) + items = ep.list_snapshots(client, pid, str(cl["id"])) + if state.json: + out(dumps(items)) + return + out(_list_table(_snapshot_rows(items)) if items else "no backups") + + +def _find_snapshot(client: Any, pid: str, cid: str, backup_id: str) -> dict[str, Any] | None: + return next((s for s in ep.list_snapshots(client, pid, cid) if s.get("id") == backup_id), None) + + +# ---------------------------------------------------------------------- create +@backup.command("create", epilog="\b\nExamples:\n capellactl cluster backup create nova-prod-01\n" + " capellactl cluster backup create nova-prod-01 --retain 7d --copy-to us-west-2\n" + " capellactl --no-wait cluster backup create nova-prod-01") +@NAME_ARG +@click.option("--retain", metavar="DURATION", help="How long to keep it: hours or days, e.g. 168 or 7d (default: " + "Capella's).") +@click.option("--copy-to", "copy_to", multiple=True, metavar="REGION", help="Also keep a copy in this region " + "(repeatable; at most two).") +@PROJECT_OPT +@pass_state +def backup_create(state: AppState, name: str, retain: str | None, copy_to: tuple[str, ...], project: str | None) -> None: + """Back the cluster up now and wait until the backup is complete (CAP-057 AC2).""" + client, pid, cl = _live(state, name, project) + cid = str(cl["id"]) + body: dict[str, Any] = {} + if retain is not None: + body["retention"] = parse_hours(retain, "--retain") + if copy_to: + regions = ep.list_snapshot_regions(client, pid, cid) + problem = options_problem({}, {"interval": 0, "retention": 0, "copyToRegions": list(copy_to)}, regions) + if problem: + raise CliExit(ExitCode.CONFIG_INVALID, problem, topic="backup") + body["regionsToCopy"] = list(copy_to) + note(f"→ backing up cluster {name} …") + backup_id = ep.create_snapshot(client, pid, cid, body) + if state.no_wait: + out(f"backup {backup_id} of {name} started (--no-wait); see it with 'capellactl cluster backup list {name}'") + return + + def status() -> str | None: + found = _find_snapshot(client, pid, cid, backup_id) + return str((found.get("progress") or {}).get("status")) if found else None + + try: + wait_for_status(status, DONE, FAILED, state.timeout, get_clock(), f"backup {backup_id} of {name}", + progress(f"backup of {name}")) + except StatusFailed as exc: + raise CliExit(ExitCode.ERROR, f"Failed: {exc}", topic="backup") from None + except StatusTimeout as exc: + raise CliExit(ExitCode.TIMEOUT, str(exc)) from None + out(f"backup {backup_id} of {name} is complete") + + +# ---------------------------------------------------------------------- retain +@backup.command("retain", epilog="\b\nExamples:\n capellactl cluster backup retain nova-prod-01 BACKUP_ID --retain 90d") +@NAME_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@click.option("--retain", required=True, metavar="DURATION", help="How long to keep it, counted from when it was " + "taken: hours or days, e.g. 2160 or 90d.") +@PROJECT_OPT +@pass_state +def backup_retain(state: AppState, name: str, backup_id: str, retain: str, project: str | None) -> None: + """Keep one backup longer (or shorter) (CAP-057 AC3).""" + hours = parse_hours(retain, "--retain") + client, pid, cl = _live(state, name, project) + found = _find_snapshot(client, pid, str(cl["id"]), backup_id) + if found is None: + raise CliExit(ExitCode.ERROR, f"backup {backup_id} of {name} not found; list them with 'capellactl cluster " + f"backup list {name}'") + if int(found.get("retention") or 0) == hours: + out(f"No changes: backup {backup_id} is already kept {hours_text(hours)}.") + return + ep.update_snapshot_retention(client, pid, str(cl["id"]), backup_id, hours) + out(f"backup {backup_id} of {name} is now kept {hours_text(hours)} (was {hours_text(found.get('retention') or 0)})") + + +# ---------------------------------------------------------------------- delete +@backup.command("delete", epilog="\b\nExamples:\n capellactl cluster backup delete nova-prod-01 BACKUP_ID") +@NAME_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@PROJECT_OPT +@pass_state +def backup_delete(state: AppState, name: str, backup_id: str, project: str | None) -> None: + """Delete one backup of the cluster (CAP-057 AC4).""" + client, pid, cl = _live(state, name, project) + found = _find_snapshot(client, pid, str(cl["id"]), backup_id) + if found is None: + out(f"backup {backup_id} of {name} not found, nothing to do") + return + out(f" - backup {backup_id} of {name} taken {when(found.get('createdAt'))} UTC · " + f"{size_text(found.get('databaseSize'))}") + if not console.confirm_typed(state, "Type the cluster name to confirm deletion", "delete a backup", name): + out("Cancelled; nothing was deleted.") + return + with contextlib.suppress(NotFound): + ep.delete_snapshot(client, pid, str(cl["id"]), backup_id) + out(f"deleted backup {backup_id} of {name}") + + +# --------------------------------------------------------------------- restore +@backup.command("restore", epilog="\b\nExamples:\n capellactl cluster backup restore nova-prod-01 BACKUP_ID\n" + " capellactl cluster backup restore nova-prod-01 BACKUP_ID --from-region us-west-2") +@NAME_ARG +@click.argument("backup_id", metavar="BACKUP_ID") +@click.option("--from-region", "from_region", multiple=True, metavar="REGION", + help="Restore from the copy in this region if the original is not restorable (repeatable, in order).") +@PROJECT_OPT +@pass_state +def backup_restore(state: AppState, name: str, backup_id: str, from_region: tuple[str, ...], + project: str | None) -> None: + """Replace all data of the cluster with a backup, and wait until the cluster is healthy (CAP-057 AC5).""" + client, pid, cl = _live(state, name, project) + cid = str(cl["id"]) + if cl.get("currentState") in ("turnedOff", "turningOff"): + raise CliExit(ExitCode.BLOCKED, f"cluster {name} is turned off; power it on before restoring " + f"('capellactl cluster power {name} --on')", topic="hibernation") + found = _find_snapshot(client, pid, cid, backup_id) + if found is None: + raise CliExit(ExitCode.ERROR, f"backup {backup_id} of {name} not found; list them with 'capellactl cluster " + f"backup list {name}'") + out(f" ± cluster {name} all data is replaced with the backup of {when(found.get('createdAt'))} UTC") + if not console.confirm_typed(state, "Type the cluster name to confirm the restore", "restore a cluster", name): + out("Cancelled; nothing was restored.") + return + note(f"→ restoring cluster {name} …") + restore_id = ep.restore_snapshot(client, pid, cid, backup_id, + {"crossRegionRestorePreference": list(from_region)} if from_region else {}) + if state.no_wait: + out(f"restore {restore_id} of {name} started (--no-wait); follow it with 'capellactl cluster backup restores " + f"{name}'") + return + + def status() -> str | None: + found_ = next((r for r in ep.list_snapshot_restores(client, pid, cid) if r.get("id") == restore_id), None) + return str(found_.get("status")) if found_ else None + + try: + wait_for_status(status, DONE, FAILED, state.timeout, get_clock(), f"restore of {name}", + progress(f"restore of {name}")) + on_state, on_tick = wait_progress(f"cluster {name}") + wait_for_cluster(client, pid, cid, "ready", state.timeout, get_clock(), on_state, name=name, on_tick=on_tick) + except (StatusFailed, WaitFailed) as exc: + raise CliExit(ExitCode.ERROR, f"Failed: {exc}", topic="backup") from None + except (StatusTimeout, WaitTimeout) as exc: + raise CliExit(ExitCode.TIMEOUT, str(exc)) from None + out(f"restored cluster {name} from the backup of {when(found.get('createdAt'))} UTC") + + +# -------------------------------------------------------------------- restores +@backup.command("restores", epilog="\b\nExamples:\n capellactl cluster backup restores nova-prod-01") +@NAME_ARG +@PROJECT_OPT +@pass_state +def backup_restores(state: AppState, name: str, project: str | None) -> None: + """List the restores of the cluster (CAP-057 AC6).""" + client, pid, cl = _live(state, name, project) + items = ep.list_snapshot_restores(client, pid, str(cl["id"])) + if state.json: + out(dumps(items)) + return + rows: list[tuple[str, ...]] = [("STARTED", "FINISHED", "SNAPSHOT", "STATUS", "ID")] + for r in sorted(items, key=lambda r: str(r.get("createdAt")), reverse=True): + rows.append((when(r.get("createdAt")), when(r.get("restoreEnd")) or "-", when(r.get("snapshot")), + str(r.get("status", "")), str(r.get("id")))) + out(_list_table(rows) if items else "no restores") + + +# --------------------------------------------------------------------- regions +@backup.command("regions", epilog="\b\nExamples:\n capellactl cluster backup regions nova-prod-01") +@NAME_ARG +@PROJECT_OPT +@pass_state +def backup_regions(state: AppState, name: str, project: str | None) -> None: + """List the regions where copies of the cluster's backups can be kept (AWS and Azure) (CAP-057 AC6).""" + client, pid, cl = _live(state, name, project) + regions = ep.list_snapshot_regions(client, pid, str(cl["id"])) + if state.json: + out(dumps(regions)) + return + out(_list_table([("REGION",), *((r,) for r in regions)]) if regions else "no copy regions (AWS and Azure only)") diff --git a/capellactl/cli/cluster_group.py b/capellactl/cli/cluster_group.py index fc3e7e1..84fb20b 100644 --- a/capellactl/cli/cluster_group.py +++ b/capellactl/cli/cluster_group.py @@ -15,6 +15,7 @@ from capellactl.cli import console from capellactl.cli.cluster_ops import ( FREE_FIXED, + _ask_disk, change_and_apply, locate, make_disk, @@ -93,7 +94,7 @@ def group_list(state: AppState, name: str, project: str | None) -> None: @click.option("--nodes", type=int, help="Nodes in the group (asked; default 2, the minimum for an extra group).") @click.option("--cpu", type=int, help="vCPUs per node (default: as the data group).") @click.option("--ram", type=int, help="RAM GB per node (default: as the data group).") -@click.option("--disk-type", help="Disk type (default: as the data group).") +@click.option("--disk-type", help="Disk type (default: as the data group; see 'capellactl cluster disk-types').") @click.option("--disk-size", type=int, help="Disk GB per node (default: as the data group).") @click.option("--disk-iops", type=int, help="Disk IOPS (AWS; default: as the data group).") @click.option("--disk-auto-expansion/--no-disk-auto-expansion", default=None, @@ -135,10 +136,17 @@ def group_add(state: AppState, name: str, services: str | None, nodes: int | Non provider = str((cl.get("cloudProvider") or {}).get("type", "aws")) base_node = _plain(base["node"]) if base is not None else {"compute": {"cpu": 4, "ram": 16}, "disk": make_disk(provider)} - disk = make_disk(provider, disk_type) if disk_type else copy.deepcopy(base_node["disk"]) - for k, v in (("storage", disk_size), ("iops", disk_iops), ("autoExpansion", disk_auto_expansion)): - if v is not None: - disk[k] = v + if disk_type: + disk = make_disk(provider, disk_type, disk_size, disk_iops) + elif disk_size is None and disk_iops is None and console.is_interactive(): + disk = _ask_disk(provider, copy.deepcopy(base_node["disk"])) # CAP-060 AC2: default is the data group's + else: + disk = copy.deepcopy(base_node["disk"]) + for k, v in (("storage", disk_size), ("iops", disk_iops)): + if v is not None: + disk[k] = v + if disk_auto_expansion is not None: + disk["autoExpansion"] = disk_auto_expansion new = {"node": {"compute": {"cpu": cpu or base_node["compute"]["cpu"], "ram": ram or base_node["compute"]["ram"]}, "disk": disk}, "numOfNodes": nodes, "services": names} diff --git a/capellactl/cli/cluster_ops.py b/capellactl/cli/cluster_ops.py index ce8d7ec..6db449f 100644 --- a/capellactl/cli/cluster_ops.py +++ b/capellactl/cli/cluster_ops.py @@ -23,7 +23,7 @@ from capellactl.cli.state import AppState from capellactl.config.editor import ConfigEditor, put from capellactl.config.loader import Config, ConfigError, load_config -from capellactl.config.presets import is_single_node, load_compute_sizes, load_presets +from capellactl.config.presets import is_single_node, load_compute_sizes, load_disk_types, load_presets from capellactl.engine.planner import build_plan from capellactl.output.render import render_plan_text from capellactl.resources.base import Plan, PlanError @@ -270,18 +270,24 @@ def parse_services(text: str, where: str) -> list[str]: def make_disk(provider: str, disk_type: str | None = None, size: int | None = None, iops: int | None = None, auto_expansion: bool | None = None) -> dict[str, Any]: - """A group's disk: the provider default, with the given values on top.""" + """A group's disk: the provider default, with the given values on top. Without IOPS, gp3, io2 and Ultra disks get + Capella's recommended IOPS for their size (CAP-060 AC3).""" + from capellactl.config.presets import default_iops, disk_facts + disk = dict(DISK_DEFAULT[provider]) if disk_type: disk = {"type": disk_type} if not fixed_size_disk(provider, disk_type): - disk["storage"] = 50 - if provider == "aws": - disk["iops"] = 3000 # the lowest IOPS Capella allows for gp3 and io2 + facts = disk_facts(provider, disk_type) or {} + disk["storage"] = int((facts.get("sizes") or facts.get("size") or [50])[0]) # the smallest size allowed if size is not None: disk["storage"] = size if iops is not None: disk["iops"] = iops + elif "storage" in disk: + recommended = default_iops(provider, str(disk["type"]), int(disk["storage"])) + if recommended is not None: + disk["iops"] = recommended if auto_expansion is not None: disk["autoExpansion"] = auto_expansion return disk @@ -359,6 +365,59 @@ def _ask_node_size(provider: str, default: tuple[int, int]) -> tuple[int, int]: return int(_ask(" vCPUs per node", default[0], kind=int)), int(_ask(" RAM GB per node", default[1], kind=int)) +def disk_rows(provider: str | None = None) -> list[tuple[str, ...]]: + """`cluster disk-types` rows: PROVIDER, TYPE, SIZE (GB), IOPS, FOR (CAP-060 AC1).""" + from capellactl.config.presets import disk_iops_text, disk_size_text, load_disk_types + + rows: list[tuple[str, ...]] = [("PROVIDER", "TYPE", "SIZE (GB)", "IOPS", "FOR")] + for prov, types in load_disk_types().items(): + if provider in (None, prov): + rows += [(prov, t, disk_size_text(f), disk_iops_text(f), str(f.get("for", ""))) for t, f in types.items()] + return rows + + +def _ask_disk(provider: str, current: dict[str, Any] | None = None) -> dict[str, Any]: + """Choose a group's disk (CAP-060 AC2): the provider's types with what they mean, then only what the type lets + you set: size (with its range) and IOPS (default: Capella's recommendation for the size).""" + from capellactl.cli.resource_cmds import _list_table + from capellactl.config.presets import default_iops, disk_facts, iops_range, load_disk_types + + types = list(load_disk_types().get(provider, {})) + if not types: + return make_disk(provider) + current = current or make_disk(provider) + out(" Disk type:") + for line in _list_table(disk_rows(provider)).splitlines(): + out(" " + line) + default = types.index(current["type"]) + 1 if current.get("type") in types else 1 + disk_type = types[int(_ask(" Choose", default, kind=click.IntRange(1, len(types)))) - 1] + facts = disk_facts(provider, disk_type) or {} + if "fixedSize" in facts: + out(f" {disk_type}: {facts['fixedSize']} GB, {facts['fixedIops']} IOPS (set by the type)") + return make_disk(provider, disk_type) + first = make_disk(provider, disk_type) + same = current.get("type") == disk_type + start = int(current.get("storage", first["storage"])) if same else int(first["storage"]) + if "sizes" in facts: + sizes = [int(s) for s in facts["sizes"]] + size = int(console.ask(f" Disk GB per node ({', '.join(map(str, sizes[:5]))}, … {sizes[-1]})", start, + type=click.Choice([str(s) for s in sizes]), show_choices=False)) + else: + lo, *hi = facts.get("size") or [50] + size = int(_ask(f" Disk GB per node ({lo}–{hi[0]})" if hi else f" Disk GB per node (at least {lo})", start, + kind=click.IntRange(lo, hi[0] if hi else None))) + if "perGb" in facts: + per = int(facts["perGb"]) // 2 + out(f" {disk_type} {size} GB: up to {per * size} read and {per * size} write IOPS") + return make_disk(provider, disk_type, size) + rng = iops_range(provider, disk_type, size) + recommended = default_iops(provider, disk_type, size) or (rng[0] if rng else 3000) + keep = int(current["iops"]) if same and current.get("iops") and int(current["iops"]) >= recommended else None + label = f" IOPS ({rng[0]}–{rng[1]}; recommended {recommended} for {size} GB)" if rng else " IOPS" + iops = int(_ask(label, keep or recommended, kind=click.IntRange(*rng) if rng else int)) + return make_disk(provider, disk_type, size, iops) + + def _ask_groups(provider: str, disk_defaults: dict[str, Any]) -> list[dict[str, Any]]: """Interactive multi-group create (CAP-051 AC6): each answer is checked as it is given.""" groups: list[dict[str, Any]] = [] @@ -391,11 +450,13 @@ def _ask_groups(provider: str, disk_defaults: dict[str, Any]) -> list[dict[str, break cpu, ram = _ask_node_size(provider, (4, 16)) disk = dict(disk_defaults) - if "storage" in make_disk(provider, disk.get("type")) and disk.get("size") is None: - disk["size"] = _ask(" Disk GB per node", make_disk(provider, disk.get("type")).get("storage", 50), kind=int) - groups.append({"node": {"compute": {"cpu": cpu, "ram": ram}, - "disk": make_disk(provider, disk.get("type"), disk.get("size"), disk.get("iops"), - disk.get("auto-expansion"))}, + if disk.get("type") is None and disk.get("size") is None and disk.get("iops") is None: + chosen = _ask_disk(provider) + else: # disk flags were given: they apply to every group, nothing to ask + chosen = make_disk(provider, disk.get("type"), disk.get("size"), disk.get("iops")) + if disk.get("auto-expansion") is not None: + chosen["autoExpansion"] = disk["auto-expansion"] + groups.append({"node": {"compute": {"cpu": cpu, "ram": ram}, "disk": chosen}, "numOfNodes": nodes, "services": services}) if sum(g["numOfNodes"] for g in groups) > 25 or not console.ask_yes_no("Add another service group?", False): return groups @@ -450,9 +511,10 @@ def _groups_line(groups: list[dict[str, Any]]) -> str: @click.option("--group", "groups", multiple=True, metavar="SPEC", help=f"A service group as {GROUP_SPEC}, e.g. data:3x8/32 (repeatable; replaces --nodes, --cpu, --ram, " "--services and --size).") -@click.option("--disk-type", help="Disk type (default per provider).") +@click.option("--disk-type", help="Disk type (default per provider; see 'capellactl cluster disk-types').") @click.option("--disk-size", type=int, help="Disk GB per node.") -@click.option("--disk-iops", type=int, help="Disk IOPS (AWS).") +@click.option("--disk-iops", type=int, help="Disk IOPS (AWS gp3/io2, Azure Ultra; default: Capella's recommendation " + "for the size).") @click.option("--services", default=None, help="Comma-separated services (default data,index,query).") @click.option("--availability", type=click.Choice(["single", "multi"]), help="Availability zones (default multi).") @click.option("--support-plan", type=click.Choice(SUPPORT_PLANS), help="Support plan (asked; default developer pro).") @@ -517,6 +579,7 @@ def cluster_create(state: AppState, name: str, project: str | None, free_tier: b provider_ = provider or (_ask("Cloud provider", answers.get("provider", "aws"), PROVIDERS) if interactive else "aws") layout: list[dict[str, Any]] | None = None + asked_disk: dict[str, Any] | None = None if groups: layout = data_first([parse_group_spec(g, provider_, disk_defaults) for g in groups]) size_ = "custom" @@ -537,6 +600,8 @@ def cluster_create(state: AppState, name: str, project: str | None, free_tier: b preset["nodes"] = nodes or _ask("Nodes", answers.get("nodes", 3), kind=int) preset["cpu"] = cpu or _ask("vCPUs per node", answers.get("cpu", 4), kind=int) preset["ram"] = ram or _ask("RAM GB per node", answers.get("ram", 16), kind=int) + if disk_type is None and disk_size is None and disk_iops is None: + asked_disk = answers.get("disk") or _ask_disk(provider_) region_ = region or (_ask("Region", answers.get("region", REGION_DEFAULT[provider_])) if interactive else REGION_DEFAULT[provider_]) cidr_ = cidr or (_ask("Network CIDR (must not overlap other clusters)", answers.get("cidr", suggest_cidr(used))) @@ -544,14 +609,16 @@ def cluster_create(state: AppState, name: str, project: str | None, free_tier: b plan_ = support_plan or (_ask("Support plan", answers.get("support_plan", "developer pro"), SUPPORT_PLANS) if interactive else "developer pro") answers = {"provider": provider_, "size": size_, "region": region_, "cidr": cidr_, "support_plan": plan_, - **{k: preset[k] for k in ("nodes", "cpu", "ram")}} + **{k: preset[k] for k in ("nodes", "cpu", "ram")}, **({"disk": asked_disk} if asked_disk else {})} if layout is not None and not groups: answers["groups"] = layout single = bool(preset.get("single")) if layout is None: + disk_ = dict(asked_disk) if asked_disk else make_disk(provider_, disk_type, disk_size, disk_iops) + if disk_auto_expansion is not None: + disk_["autoExpansion"] = disk_auto_expansion layout = [{ - "node": {"compute": {"cpu": cpu or preset["cpu"], "ram": ram or preset["ram"]}, - "disk": make_disk(provider_, disk_type, disk_size, disk_iops, disk_auto_expansion)}, + "node": {"compute": {"cpu": cpu or preset["cpu"], "ram": ram or preset["ram"]}, "disk": disk_}, "numOfNodes": nodes or preset["nodes"], "services": sorted(s.strip() for s in (services or "data,index,query").split(",") if s.strip()), }] @@ -598,7 +665,7 @@ def cluster_create(state: AppState, name: str, project: str | None, free_tier: b raise err(exc.message) err("Please try again.") - for k in bad: + for k in bad | ({"disk"} if "provider" in bad else set()): # a disk belongs to its provider answers.pop(k, None) sg = cluster["serviceGroups"] @@ -731,15 +798,18 @@ def _record_server_version(ed: ConfigEditor, name: str, project_name: str, plan: @click.command("update", epilog="\b\nExamples:\n capellactl cluster update nova-dev-01 --support-plan enterprise\n" " capellactl cluster update nova-dev-01 --cpu 8 --ram 32\n" " capellactl cluster update nova-dev-01 (asks what to change)\n" - " capellactl cluster update nova-az-01 --disk-auto-expansion (azure)") + " capellactl cluster update nova-dev-01 --group data --disk-type io2 --disk-iops 20000\n" + " capellactl cluster update nova-az-01 --disk-auto-expansion (azure)\n" + "Disk types and what they mean: capellactl cluster disk-types") @click.argument("name") @click.option("--description", help="New description.") @click.option("--support-plan", type=click.Choice(SUPPORT_PLANS)) @click.option("--support-timezone", type=click.Choice(TIMEZONES)) @click.option("--cpu", type=int, help="vCPUs per node for the group.") @click.option("--ram", type=int, help="RAM GB per node for the group.") +@click.option("--disk-type", help="Disk type for the group (see 'capellactl cluster disk-types').") @click.option("--disk-size", type=int, help="Disk GB per node for the group (cannot shrink).") -@click.option("--disk-iops", type=int, help="Disk IOPS for the group (AWS).") +@click.option("--disk-iops", type=int, help="Disk IOPS for the group (AWS gp3/io2, Azure Ultra).") @click.option("--disk-auto-expansion/--no-disk-auto-expansion", default=None, help="Azure: let the group's disk storage expand automatically.") @click.option("--group", metavar="SERVICES", help="Service group to change (default: the group running data).") @@ -747,13 +817,14 @@ def _record_server_version(ed: ConfigEditor, name: str, project_name: str, plan: @click.option("--no-apply", is_flag=True, help="Only update capella.yaml; apply later.") @click.pass_obj def cluster_update(state: AppState, name: str, project: str | None, description: str | None, support_plan: str | None, - support_timezone: str | None, cpu: int | None, ram: int | None, disk_size: int | None, - disk_iops: int | None, disk_auto_expansion: bool | None, group: str | None, no_apply: bool) -> None: + support_timezone: str | None, cpu: int | None, ram: int | None, disk_type: str | None, + disk_size: int | None, disk_iops: int | None, disk_auto_expansion: bool | None, group: str | None, + no_apply: bool) -> None: """Change a cluster's description, support plan, CPU/RAM or disk; records it in capella.yaml and applies.""" ed = open_editor(state) proj_node, cl = locate(ed, name, project) if cl.get("tier") == "free": - if any(v is not None for v in (support_plan, support_timezone, cpu, ram, disk_size, disk_iops, + if any(v is not None for v in (support_plan, support_timezone, cpu, ram, disk_type, disk_size, disk_iops, disk_auto_expansion)): raise CliExit(ExitCode.BLOCKED, FREE_FIXED) if description is None: @@ -771,15 +842,23 @@ def cluster_update(state: AppState, name: str, project: str | None, description: raise CliExit(ExitCode.CONFIG_INVALID, str(exc), topic="scaling") from None comp, disk = g["node"]["compute"], g["node"]["disk"] support = cl.get("support") or {} - fixed = fixed_size_disk((cl.get("cloudProvider") or {}).get("type"), disk.get("type")) + provider = str((cl.get("cloudProvider") or {}).get("type")) + after_plan: list[str] = [] + if disk_type is not None and disk_type != disk.get("type"): + after_plan = _change_disk_type(provider, disk, disk_type, disk_size, disk_iops) + fixed = fixed_size_disk(provider, disk.get("type")) if fixed and (disk_size is not None or disk_iops is not None): raise CliExit(ExitCode.CONFIG_INVALID, f"{name} uses Azure disk type {disk.get('type')}, whose size and IOPS " "are set by the type; --disk-size and --disk-iops apply to Ultra disks " "only", topic="scaling") - if disk_auto_expansion is not None and (cl.get("cloudProvider") or {}).get("type") != "azure": + if disk_auto_expansion is not None and provider != "azure": raise CliExit(ExitCode.CONFIG_INVALID, "auto-expansion is for azure disks only", topic="scaling") - if not any(v is not None for v in (description, support_plan, support_timezone, cpu, ram, disk_size, disk_iops, - disk_auto_expansion)): + if disk_iops is not None and provider == "gcp": + raise CliExit(ExitCode.CONFIG_INVALID, "Google Cloud pd-ssd disks get their IOPS from their size (60 per GB); " + "use --disk-size to make the disk faster (capellactl cluster disk-types)", + topic="scaling") + if not any(v is not None for v in (description, support_plan, support_timezone, cpu, ram, disk_type, disk_size, + disk_iops, disk_auto_expansion)): if not console.is_interactive(): raise CliExit(ExitCode.CONFIG_INVALID, "nothing to change; pass for example --support-plan, --cpu, " "--ram or --disk-size") @@ -808,7 +887,34 @@ def cluster_update(state: AppState, name: str, project: str | None, description: if disk_auto_expansion is not None: disk["autoExpansion"] = disk_auto_expansion change_and_apply(state, ed, f"cluster/{proj_node.get('name')}/{name}", - f"Record these changes in capella.yaml and apply them to {name}?", no_apply) + f"Record these changes in capella.yaml and apply them to {name}?", no_apply, + after_plan=after_plan) + + +def _change_disk_type(provider: str, disk: Any, new_type: str, size: int | None, iops: int | None) -> list[str]: + """Give a group's disk a new type in place (CAP-060 AC5): the size is kept where the new type allows it, IOPS + become the recommendation for the size unless given. Returns the plan lines that say what the type means.""" + from capellactl.config.presets import disk_facts, disk_iops_text + + facts = disk_facts(provider, new_type) + if facts is None: # validation names the allowed types + disk["type"] = new_type + return [] + keep = None if fixed_size_disk(provider, new_type) else (size if size is not None else disk.get("storage")) + allowed = facts.get("sizes") + if keep is not None and allowed and int(keep) not in allowed: + keep = None + fresh = make_disk(provider, new_type, int(keep) if keep is not None else None, iops) + for k in ("storage", "iops"): + disk.pop(k, None) + disk.update(fresh) + lines = [f" disk type {new_type}: {facts.get('for')} (IOPS {disk_iops_text(facts)})"] + if provider == "aws": + lines.append(" AWS changes a disk in place at most once every 6 hours; otherwise Capella replaces and " + "rebalances the nodes") + else: + lines.append(" Capella replaces and rebalances the group's nodes to change its disk type") + return lines # ------------------------------------------------------------------- scale @@ -849,6 +955,7 @@ def cluster_options(state: AppState) -> None: versions_in_use = sorted({str((c.get("couchbaseServer") or {}).get("version")) for _, c in clusters if (c.get("couchbaseServer") or {}).get("version")}) known = {prov: [f"{cpu}/{ram}" for cpu, ram in kinds.get("sizes", [])] for prov, kinds in load_compute_sizes().items()} + disks = {prov: list(types) for prov, types in load_disk_types().items()} if state.json: out(dumps({ "freeTier": {"available": in_use is None, "usedBy": in_use, "regions": FREE_TIER_REGIONS, @@ -856,7 +963,7 @@ def cluster_options(state: AppState) -> None: "provisioned": {"providers": PROVIDERS, "sizes": PRESETS, "supportPlans": SUPPORT_PLANS, "supportTimezones": TIMEZONES, "regionsInUse": regions_in_use, "regionDefaults": REGION_DEFAULT, "services": SERVICES, "groupLimits": GROUP_LIMITS, - "nodeSizes": known, "serverVersionsInUse": versions_in_use}, + "nodeSizes": known, "diskTypes": disks, "serverVersionsInUse": versions_in_use}, })) return out(f"Cluster options for organization {client.org_id}") @@ -880,6 +987,8 @@ def cluster_options(state: AppState) -> None: out(" node sizes vCPU/RAM GB (from Capella's documentation; config/compute.yaml)") for prov, sizes_ in known.items(): out(f" {prov:<6} {', '.join(sizes_)}") + out(" disk types " + " · ".join(f"{prov} {', '.join(types)}" for prov, types in disks.items())) + out(" (size, speed and use of each: capellactl cluster disk-types)") if versions_in_use: out(f" server versions in use by your clusters: {', '.join(versions_in_use)}") out(" regions any region of the provider" + (f"; in use by your clusters: {', '.join(regions_in_use)}" @@ -890,6 +999,26 @@ def cluster_options(state: AppState) -> None: out(" capellactl cluster create NAME --group data:3x8/32 --group index,query:2x8/32 … (several groups)") +@click.command("disk-types", epilog="\b\nExamples:\n capellactl cluster disk-types\n" + " capellactl cluster disk-types --provider aws\n" + " capellactl -o json cluster disk-types --provider azure") +@click.option("--provider", type=click.Choice(PROVIDERS), help="Only this cloud provider's disk types.") +@click.pass_obj +def cluster_disk_types(state: AppState, provider: str | None) -> None: + """List the disk types you can choose per service group: size range, speed (IOPS) and what each is for.""" + from capellactl.cli.resource_cmds import _list_table + from capellactl.output.render import dumps + + if state.json: + out(dumps({p: types for p, types in load_disk_types().items() if provider in (None, p)})) + return + out(_list_table(disk_rows(provider))) + out("Without --disk-iops, gp3, io2 and Ultra disks get Capella's recommended IOPS for their size; a higher value " + "is allowed.") + out("Changing a running cluster's disk: AWS changes it in place at most once every 6 hours; otherwise, and on " + "Azure, Capella replaces and rebalances the nodes.") + + # -------------------------------------------------------------- step scale diff --git a/capellactl/cli/configure.py b/capellactl/cli/configure.py index 5f57363..f848891 100644 --- a/capellactl/cli/configure.py +++ b/capellactl/cli/configure.py @@ -36,6 +36,9 @@ def clean_api_key(raw: str) -> tuple[str, list[str]]: if _PASTE_MARKERS.search(key): key = _PASTE_MARKERS.sub("", key) fixed.append("removed terminal paste markers") + if "" in key: # Windows PowerShell piping with a UTF-8 BOM + key = key.replace("", "") + fixed.append("removed a byte-order mark") key = key.strip() if len(key) >= 2 and key[0] == key[-1] and key[0] in "'\"": key = key[1:-1].strip() diff --git a/capellactl/cli/help.py b/capellactl/cli/help.py index 105ca02..cf8b92d 100644 --- a/capellactl/cli/help.py +++ b/capellactl/cli/help.py @@ -20,6 +20,7 @@ "idempotency": "plan symbols, how resources are matched, what is compared", "scaling": "scale out, in and up; limits; scale-in checks; --step", "hibernation": "power off/on, weekly on/off schedule, pause/resume", + "backup": "cluster backups and bucket backups: schedules, back up now, restore, export", "credentials": "database credentials, generated passwords, rotation", "safety": "deletion protection, prune, cascade, confirmations", "exit-codes": "what each exit code means", diff --git a/capellactl/cli/resource_cmds.py b/capellactl/cli/resource_cmds.py index 9e483b5..186c8b8 100644 --- a/capellactl/cli/resource_cmds.py +++ b/capellactl/cli/resource_cmds.py @@ -410,7 +410,8 @@ def _remove_from_file(state: AppState, name: str) -> None: " capellactl cluster scale nova-dev-01 --nodes 5\n capellactl cluster list\n" " capellactl cluster get nova-dev-01") def cluster() -> None: - """Clusters: options, list, get, create, clone, update, scale, delete, protect, power, schedule.""" + """Clusters: options, disk-types, list, get, create, clone, update, scale, delete, protect, power, schedule, group, + backup.""" def _cluster_rows(items: list[dict[str, Any]], project: bool = False) -> list[tuple[str, ...]]: @@ -525,11 +526,12 @@ def cluster_scale(state: AppState, name: str, nodes: int | None, group: str | No def _register_cluster_ops() -> None: from capellactl.cli.cluster_lifecycle import cluster_delete, cluster_protect - from capellactl.cli.cluster_ops import cluster_create, cluster_options, cluster_update + from capellactl.cli.cluster_ops import cluster_create, cluster_disk_types, cluster_options, cluster_update cluster.add_command(cluster_create) cluster.add_command(cluster_update) cluster.add_command(cluster_options) + cluster.add_command(cluster_disk_types) cluster.add_command(cluster_delete) cluster.add_command(cluster_protect) from capellactl.cli.cluster_clone import cluster_clone @@ -543,6 +545,9 @@ def _register_cluster_ops() -> None: from capellactl.cli.cluster_group import group cluster.add_command(group) + from capellactl.cli.cluster_backup import backup + + cluster.add_command(backup) _register_cluster_ops() @@ -567,8 +572,10 @@ def _credential_group() -> click.Group: def _bucket_group() -> click.Group: + from capellactl.cli.bucket_backup import backup as bucket_backup from capellactl.cli.bucket_cmds import bucket + bucket.add_command(bucket_backup) return bucket diff --git a/capellactl/cli/sync.py b/capellactl/cli/sync.py index c3bc069..7a0e5cd 100644 --- a/capellactl/cli/sync.py +++ b/capellactl/cli/sync.py @@ -67,7 +67,8 @@ def _carry_over_project(prev: dict[str, Any] | None, res: Any) -> list[str]: UNREAD_LABELS = {"allowedCidrs": "allowed CIDRs", "buckets": "buckets, scopes and collections", - "databaseCredentials": "database credentials", "hibernation": "hibernation schedule"} + "databaseCredentials": "database credentials", "hibernation": "hibernation schedule", + "backupSchedule": "backup schedule"} def _keep_unread(prev: dict[str, Any] | None, res: Any, file_name: str) -> None: @@ -90,17 +91,24 @@ def _keep_unread(prev: dict[str, Any] | None, res: Any, file_name: str) -> None: moved: dict[str, Any] = {} unkept = False for f in info["fields"]: - if f.startswith("scopes:"): - bucket = f.split(":", 1)[1] + if f.startswith(("scopes:", "bucketBackup:")): + part, bucket = f.split(":", 1) + field_ = "scopes" if part == "scopes" else "backupSchedule" ob = next((b for b in old.get("buckets") or [] if isinstance(b, dict) and b.get("name") == bucket), None) nb = next((b for b in cl.get("buckets") or [] if b.get("name") == bucket), None) - if ob and ob.get("scopes") and nb is not None: - nb["scopes"] = ob["scopes"] - kept.append(f"scopes of bucket {bucket}") + if ob and ob.get(field_) and nb is not None: + if field_ == "backupSchedule": # before the scopes + scopes_ = nb.pop("scopes", None) + nb[field_] = ob[field_] + if scopes_ is not None: + nb["scopes"] = scopes_ + else: + nb[field_] = ob[field_] + kept.append(f"{'scopes' if part == 'scopes' else 'backup schedule'} of bucket {bucket}") elif old.get(f): moved[f] = old[f] kept.append(UNREAD_LABELS[f]) - else: + elif f != "backupSchedule": # no schedule in the file: nothing to keep, nothing lost unkept = True if not kept: continue diff --git a/capellactl/config/crossref.py b/capellactl/config/crossref.py index 9bd915d..ff05db4 100644 --- a/capellactl/config/crossref.py +++ b/capellactl/config/crossref.py @@ -22,7 +22,7 @@ } SINGLE_NODE_SERVICES = {"data", "index", "query", "search"} # spec: configurationType singleNode FREE_TIER_FORBIDDEN = ("configurationType", "couchbaseServer", "serviceGroups", "availability", "support", "zones", - "cmekId", "enablePrivateDNSResolution", "hibernation") + "cmekId", "enablePrivateDNSResolution", "hibernation", "backupSchedule") PROVISIONED_REQUIRED = ("availability", "support", "serviceGroups") # Region naming per provider (shape only, so new regions pass): aws us-east-1, gcp us-east1, azure eastus. @@ -177,6 +177,45 @@ def _check_hibernation(cl: dict[str, Any], cp: tuple[Any, ...], add: Any) -> Non add(cp + ("hibernation",), "the cluster cannot be off every day of the week (Capella rule)", "hibernation") +DISK_HINT = " (see 'capellactl cluster disk-types')" + + +def _check_disk_range(provider: str, disk: dict[str, Any], dp: tuple[Any, ...], add: Any) -> None: + """Sizes and IOPS a disk type accepts, from config/disks.yaml (CAP-060 AC4).""" + from capellactl.config.presets import disk_facts, iops_range + + disk_type, size, iops = disk.get("type"), disk.get("storage"), disk.get("iops") + facts = disk_facts(provider, str(disk_type)) if disk_type else None + if facts is None or "fixedSize" in facts: # unknown type: reported above; Azure premium: set by the type + return + if isinstance(size, int): + if facts.get("sizes") and size not in facts["sizes"]: + shown = facts["sizes"] + add(dp + ("storage",), f"{disk_type} disks come in {', '.join(map(str, shown[:5]))}, … {shown[-1]} GB " + f"(got {size}){DISK_HINT}", "scaling") + elif facts.get("size") and len(facts["size"]) > 1 and size > facts["size"][1]: + add(dp + ("storage",), f"{disk_type} disks are at most {facts['size'][1]} GB (got {size}){DISK_HINT}", + "scaling") + rng = iops_range(provider, str(disk_type), size if isinstance(size, int) else None) + if isinstance(iops, int) and rng and not rng[0] <= iops <= rng[1]: + at = f" at {size} GB" if facts.get("iopsCap") and isinstance(size, int) else "" + add(dp + ("iops",), f"{disk_type} disks take {rng[0]}–{rng[1]} IOPS{at} (got {iops}){DISK_HINT}", "scaling") + + +def _low_iops(provider: str, disk: dict[str, Any]) -> int | None: + """Capella's recommended IOPS when the disk's IOPS are below it (a warning: existing disks may have less).""" + from capellactl.config.presets import default_iops + + size, iops = disk.get("storage"), disk.get("iops") + if not isinstance(size, int) or not isinstance(iops, int) or not disk.get("type"): + return None + recommended = default_iops(provider, str(disk["type"]), size) + # Capella allows 3000 for 100–199 GB disks (IOPS Defaults, note 1) + if recommended is None or iops >= recommended or (100 <= size < 200 and iops >= 3000): + return None + return recommended + + def config_warnings(cfg: Config) -> list[str]: """Valid but probably unintended settings, printed by every command that loads the file.""" from capellactl.config.loader import fmt_path @@ -192,6 +231,16 @@ def config_warnings(cfg: Config) -> list[str]: line = cfg.line_of(path) out.append(f"warning: {fmt_path(path)}: expiresAt is in the past; Capella will remove it" + (f" (line {line})" if line else "")) + provider = str((cl.get("cloudProvider") or {}).get("type")) + for gi, g in enumerate(cl.get("serviceGroups") or []): + disk = (g.get("node") or {}).get("disk") or {} + recommended = _low_iops(provider, disk) + if recommended is not None: + path = entry.clusters_base + (ci, "serviceGroups", gi, "node", "disk", "iops") + line = cfg.line_of(path) + out.append(f"warning: {fmt_path(path)}: {disk['iops']} IOPS is below Capella's recommended " + f"{recommended} for {disk['storage']} GB; Capella may refuse a lower value on the next " + "change" + (f" (line {line})" if line else "")) for ui, u in enumerate(cl.get("databaseCredentials") or []): for ai, a in enumerate(u.get("access") or []): if not (a.get("resources") or {}).get("buckets"): # CAP-027 AC5 @@ -337,6 +386,7 @@ def _check_project(cfg: Config, entry: Any, add: Any, dupes: Any) -> None: add(gp + ("node", "disk", "iops"), "iops is required for aws disks") if provider in ("aws", "gcp") and ((g.get("node") or {}).get("disk") or {}).get("autoExpansion") is not None: add(gp + ("node", "disk", "autoExpansion"), "autoExpansion is for azure disks only") + _check_disk_range(str(provider), (g.get("node") or {}).get("disk") or {}, gp + ("node", "disk"), add) if single and set(g.get("services") or []) - SINGLE_NODE_SERVICES: add(gp + ("services",), "singleNode clusters run only data, index, query and search") if not single and total > 27: diff --git a/capellactl/config/disks.yaml b/capellactl/config/disks.yaml new file mode 100644 index 0000000..57983a6 --- /dev/null +++ b/capellactl/config/disks.yaml @@ -0,0 +1,50 @@ +# Disk types Capella offers per cloud provider, and what each means (CAP-060). Change values here without changing +# code. The Management API spec gives only the type names and minimum sizes ("see documentation"), so the rest comes +# from the Capella documentation: +# aws https://docs.couchbase.com/cloud/reference/aws.html +# gcp https://docs.couchbase.com/cloud/reference/gcp.html +# azure https://docs.couchbase.com/cloud/reference/azure.html +# IOPS defaults https://docs.couchbase.com/cloud/clusters/scale-database.html#IOPS-Defaults +# Read 2026-10-29. Capella has the last word: a value it does not accept is rejected with its own message. +# +# for what the type is for (shown by `capellactl cluster disk-types` and the disk picker) +# size [min, max] GB per node ([min]: no documented maximum); sizes: the only sizes allowed; +# fixedSize: set by the type (no choice) +# iops [min, max]; iopsCap: the highest IOPS per size; fixedIops: set by the type; perGb: IOPS follow size +# defaults recommended IOPS per size, as [from GB, IOPS] steps (used when no IOPS is given) + +aws: + gp3: + for: general-purpose SSD (default) + size: [50, 16000] + iops: [3000, 16000] + defaults: &ebs [[50, 3000], [100, 4370], [200, 5740], [300, 7110], [400, 8480], [500, 9850], [600, 11220], + [700, 12590], [800, 13960], [900, 15330], [1000, 16000]] + io2: + for: provisioned-IOPS SSD for latency-sensitive, write-heavy workloads; costs more + size: [50, 16000] + iops: [3000, 64000] + defaults: *ebs + +gcp: + pd-ssd: + for: SSD persistent disk (the only type); grow the disk to make it faster + size: [50] # no documented maximum + perGb: 60 # 30 read + 30 write IOPS per GB + +azure: + P6: {for: "premium SSD; size and speed set by the type (default)", fixedSize: 64, fixedIops: 240} + P10: {for: "premium SSD; size and speed set by the type", fixedSize: 128, fixedIops: 500} + P15: {for: "premium SSD; size and speed set by the type", fixedSize: 256, fixedIops: 1100} + P20: {for: "premium SSD; size and speed set by the type", fixedSize: 512, fixedIops: 2300} + P30: {for: "premium SSD; size and speed set by the type", fixedSize: 1024, fixedIops: 5000} + P40: {for: "premium SSD; size and speed set by the type", fixedSize: 2048, fixedIops: 7500} + P50: {for: "premium SSD; size and speed set by the type", fixedSize: 4096, fixedIops: 7500} + P60: {for: "premium SSD; size and speed set by the type", fixedSize: 8192, fixedIops: 16000} + Ultra: + for: Ultra Disk for intensive, transaction-heavy workloads; lowest latency; only in regions that offer it + sizes: [64, 128, 256, 512, 1024, 2048, 3072, 4096, 5120, 6144, 7168, 8192, 9216, 10240, 11264, 12288, 13312, + 14336, 15360] + iops: [3000, 80000] + iopsCap: [[64, 19200], [128, 38400], [256, 76800], [512, 80000]] + defaults: [[64, 3000], [128, 4000], [256, 6000], [512, 8000], [1024, 16000]] diff --git a/capellactl/config/editor.py b/capellactl/config/editor.py index c2f4c8e..a04d7dc 100644 --- a/capellactl/config/editor.py +++ b/capellactl/config/editor.py @@ -46,8 +46,10 @@ def to_commented(obj: Any, key: str | None = None) -> Any: # Key order of a cluster, as import writes it; new keys are inserted in this order, not appended at the end. CLUSTER_KEY_ORDER = ("id", "name", "description", "configurationType", "cloudProvider", "couchbaseServer", "availability", "support", "serviceGroups", "cmekId", "enablePrivateDNSResolution", - "deletionProtection", "allowAnyIp", "hibernation", "allowedCidrs", "buckets", + "deletionProtection", "allowAnyIp", "hibernation", "backupSchedule", "allowedCidrs", "buckets", "databaseCredentials") +# A bucket's backup schedule goes before its scopes (as sync writes it). +BUCKET_KEY_ORDER = ("backupSchedule", "scopes") def put(node: CommentedMap, key: str, value: Any, order: tuple[str, ...] = CLUSTER_KEY_ORDER) -> None: diff --git a/capellactl/config/presets.py b/capellactl/config/presets.py index f129d59..cf0647c 100644 --- a/capellactl/config/presets.py +++ b/capellactl/config/presets.py @@ -36,3 +36,61 @@ def known_size(provider: str, cpu: int, ram: int, single: bool) -> bool: if kinds is None: return True return (cpu, ram) in kinds.get("sizes", []) or (single and (cpu, ram) in kinds.get("singleNode", [])) + + +@cache +def load_disk_types() -> dict[str, dict[str, dict[str, Any]]]: + """Disk types per provider and what each means, from disks.yaml (CAP-060): {provider: {type: facts}}.""" + text = resources.files("capellactl.config").joinpath("disks.yaml").read_text(encoding="utf-8") + return {str(p): {str(t): dict(v) for t, v in (types or {}).items()} for p, types in (yaml.safe_load(text) or {}).items()} + + +def _step(table: list[list[int]], size: int) -> int | None: + """The value of the last [from, value] step at or below `size` (None below the first step).""" + found = None + for start, value in table: + if size >= start: + found = value + return found + + +def disk_facts(provider: str, disk_type: str) -> dict[str, Any] | None: + return load_disk_types().get(provider, {}).get(disk_type) + + +def default_iops(provider: str, disk_type: str, size: int | None) -> int | None: + """Capella's recommended IOPS for a disk of this size (gp3, io2, Ultra); None when the type sets or derives it.""" + facts = disk_facts(provider, disk_type) + if not facts or not facts.get("defaults"): + return None + lowest = facts["defaults"][0] + return _step(facts["defaults"], size if size is not None else lowest[0]) or lowest[1] + + +def iops_range(provider: str, disk_type: str, size: int | None) -> tuple[int, int] | None: + """(min, max) IOPS a disk of this type and size takes; None when IOPS cannot be set.""" + facts = disk_facts(provider, disk_type) + if not facts or "iops" not in facts: + return None + lo, hi = facts["iops"] + cap = _step(facts.get("iopsCap") or [], size) if size is not None else None + return int(lo), int(min(hi, cap) if cap else hi) + + +def disk_size_text(facts: dict[str, Any]) -> str: + if "fixedSize" in facts: + return str(facts["fixedSize"]) + if "sizes" in facts: + s = facts["sizes"] + return f"{s[0]}, {s[1]}, … {s[-1]}" + size = facts["size"] + return f"{size[0]}–{size[1]}" if len(size) > 1 else f"{size[0]} and up" + + +def disk_iops_text(facts: dict[str, Any]) -> str: + if "fixedIops" in facts: + return str(facts["fixedIops"]) + if "perGb" in facts: + return f"{facts['perGb']} per GB" + lo, hi = facts["iops"] + return f"{lo}–{hi}" diff --git a/capellactl/config/schema.json b/capellactl/config/schema.json index 05c314e..5e8a863 100644 --- a/capellactl/config/schema.json +++ b/capellactl/config/schema.json @@ -734,6 +734,72 @@ } } }, + "backupSchedule": { + "type": "object", + "description": "Cloud snapshot backups of the whole cluster (CAP-056): how often, how long each is kept, copies in other regions. Not available on the free tier.", + "additionalProperties": false, + "properties": { + "interval": { + "type": "integer", + "description": "Hours between backups; one of the intervals the cluster's support plan permits ('capellactl cluster backup schedule options NAME').", + "x-mutability": "updatable", + "x-api": "interval (Cloud Snapshot Backup Schedule)", + "minimum": 1, + "examples": [ + 24 + ] + }, + "retention": { + "type": "integer", + "description": "Hours each backup is kept, within the support plan's range (720 = 30 days).", + "x-mutability": "updatable", + "x-api": "retention (Cloud Snapshot Backup Schedule)", + "minimum": 1, + "examples": [ + 720 + ] + }, + "startTime": { + "type": "string", + "description": "Time of day (UTC) of the first backup, HH:MM. Sent as the next such instant.", + "x-mutability": "updatable", + "x-api": "startTime (Cloud Snapshot Backup Schedule)", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "examples": [ + "02:00" + ] + }, + "copyToRegions": { + "type": "array", + "description": "Other regions that keep a copy of each backup (AWS and Azure; at most two).", + "x-mutability": "updatable", + "x-api": "copyToRegions (Cloud Snapshot Backup Schedule)", + "items": { + "type": "string" + }, + "maxItems": 2, + "examples": [ + [ + "us-west-2" + ] + ] + }, + "ensure": { + "type": "string", + "description": "present (default) or absent to delete the schedule.", + "x-mutability": "tool", + "enum": [ + "present", + "absent" + ], + "default": "present" + } + }, + "required": [ + "interval", + "retention" + ] + }, "buckets": { "type": "array", "description": "Buckets in this cluster.", @@ -918,6 +984,113 @@ "name" ] } + }, + "backupSchedule": { + "type": "object", + "description": "Bucket backups (CAP-058): a weekly full backup starts a cycle, followed by incremental backups. Not available on the free tier.", + "additionalProperties": false, + "properties": { + "dayOfWeek": { + "type": "string", + "description": "Day of the weekly full backup.", + "x-mutability": "updatable", + "x-api": "weeklySchedule.dayOfWeek (Backup Schedule (Bucket))", + "enum": [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday" + ], + "default": "sunday" + }, + "startAt": { + "type": "integer", + "description": "Hour (UTC, 0\u201323) the backups start.", + "x-mutability": "updatable", + "x-api": "weeklySchedule.startAt (Backup Schedule (Bucket))", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "default": 0 + }, + "incrementalEvery": { + "type": "integer", + "description": "Hours between incremental backups.", + "x-mutability": "updatable", + "x-api": "weeklySchedule.incrementalEvery (Backup Schedule (Bucket))", + "enum": [ + 1, + 2, + 4, + 6, + 8, + 12, + 24 + ], + "default": 24 + }, + "retentionTime": { + "type": "string", + "description": "How long backups are kept.", + "x-mutability": "updatable", + "x-api": "weeklySchedule.retentionTime (Backup Schedule (Bucket))", + "enum": [ + "30days", + "60days", + "90days", + "180days", + "1year", + "2years", + "3years", + "4years", + "5years" + ], + "default": "30days" + }, + "costOptimizedRetention": { + "type": "boolean", + "description": "Capella's cost-optimized retention.", + "x-mutability": "updatable", + "x-api": "weeklySchedule.costOptimizedRetention (Backup Schedule (Bucket))", + "default": false + }, + "ensure": { + "type": "string", + "description": "present (default) or absent to delete the schedule.", + "x-mutability": "tool", + "enum": [ + "present", + "absent" + ], + "default": "present" + } + } } }, "required": [ diff --git a/capellactl/engine/planner.py b/capellactl/engine/planner.py index 7c18adb..765d898 100644 --- a/capellactl/engine/planner.py +++ b/capellactl/engine/planner.py @@ -135,8 +135,8 @@ def walk(rec: Reconciler, parent_desired: Any, parent: Action | None, parent_tar # Within one cluster, every resource of a kind comes before the next kind (CAP-030 AC1): all buckets, then all # scopes, then all collections, so apply reads top-down and destroy (the reverse) bottom-up. Dependencies still hold: # each kind ranks after its parent's. -KIND_ORDER = ("project", "cluster", "deletion-protection", "schedule", "allowed-cidr", "bucket", "scope", "collection", - "credential") +KIND_ORDER = ("project", "cluster", "deletion-protection", "schedule", "backup-schedule", "allowed-cidr", "bucket", + "bucket-backup-schedule", "scope", "collection", "credential") def _by_kind(actions: list[Action]) -> list[Action]: diff --git a/capellactl/engine/waiter.py b/capellactl/engine/waiter.py index 5878ae7..43fdd29 100644 --- a/capellactl/engine/waiter.py +++ b/capellactl/engine/waiter.py @@ -116,3 +116,47 @@ def wait_for_cluster( if rest > 0: clock.sleep(rest) interval = min(interval * POLL_FACTOR, MAX_POLL) + + +class StatusFailed(Exception): + def __init__(self, label: str, state: str) -> None: + super().__init__(f"{label} ended {state}; check the Capella UI activity log") + self.state = state + + +class StatusTimeout(Exception): + def __init__(self, label: str, state: str, timeout: timedelta) -> None: + super().__init__(f"{label} still {state} after {format_duration(timeout.total_seconds())}; it continues in " + "Capella") + self.state = state + + +def wait_for_status( + read: Callable[[], str | None], + done: set[str], + failed: set[str], + timeout: timedelta, + clock: Clock, + label: str, + on_state: Callable[[str, float], None] | None = None, +) -> str: + """Poll `read()` (a backup's, export's or restore's status; None while Capella does not list it yet) with the + cluster waiter's intervals until a `done` or `failed` status, or the timeout (CAP-057, CAP-059).""" + start = clock.monotonic() + interval = FIRST_POLL + last: str | None = None + while True: + state = read() or "queued" + elapsed = clock.monotonic() - start + if state != last: + if on_state: + on_state(state, elapsed) + last = state + if state.lower() in done: + return state + if state.lower() in failed: + raise StatusFailed(label, state) + if elapsed >= timeout.total_seconds(): + raise StatusTimeout(label, state, timeout) + clock.sleep(min(interval, MAX_POLL)) + interval = min(interval * POLL_FACTOR, MAX_POLL) diff --git a/capellactl/help/backup.md b/capellactl/help/backup.md new file mode 100644 index 0000000..2abfac4 --- /dev/null +++ b/capellactl/help/backup.md @@ -0,0 +1,33 @@ +# Backup and restore + +Capella has two kinds of backup, and capellactl manages both: + +| | Cluster backup | Bucket backup | +|---|---|---| +| Commands | `capellactl cluster backup …` | `capellactl bucket backup …` | +| Capella name | Cloud snapshot backup | Backup (bucket) | +| What is saved | The whole cluster: every bucket, index and setting | One bucket: a weekly **full** backup starts a **cycle**, then **incrementals** | +| Schedule | `cluster backup schedule set NAME --every HOURS --retain 30d [--start HH:MM] [--copy-to REGION]` | `bucket backup schedule set CLUSTER BUCKET [--day sunday --start-at 2 --incremental-every 24 --retention 30days]` | +| Restore | Replaces all data of the same cluster | Same or another cluster, all of it or selected scopes and collections | + +**Schedules are part of the environment.** `schedule set` records `backupSchedule` in capella.yaml (on the cluster, +or on the bucket) and applies it; `plan`, `apply`, `drift` and `sync` include it, and `apply --prune` deletes a +schedule the file no longer declares. Without a `backupSchedule` key, an existing schedule is left alone. The +intervals and retention a cluster may use depend on its support plan: `cluster backup schedule options NAME`. + +**Backups themselves are not.** `create`, `retain`, `delete`, `restore` and `export` act on Capella only: +- Back up before a change: `capellactl cluster backup create NAME`, `capellactl bucket backup create CLUSTER BUCKET` + (both wait until the backup is done; `--no-wait` returns at once). +- List: `capellactl cluster backup list NAME` (without NAME: every cluster of the project), + `capellactl bucket backup list CLUSTER [BUCKET]`, `capellactl bucket backup cycle list CLUSTER BUCKET`. +- Restore: `capellactl cluster backup restore NAME BACKUP_ID` replaces all data of the cluster; + `capellactl bucket backup restore CLUSTER BACKUP_ID [--to-cluster NAME] [--include-data B.S.C]` keeps newer + documents unless `--force-updates`. Both ask you to type the cluster name (or `--auto-approve`). +- Delete: deleting a bucket backup deletes its whole cycle; the command says how many backups that is. +- Download: `capellactl bucket backup export CLUSTER BACKUP_ID` packages the backup's cycle as a zip and prints a + download link that is valid for one hour. Treat it like a password. + +Free-tier clusters have no backups. Most backup operations need an API key with the **Organization Owner** or +**Project Owner** role; with a Project Manager key, `sync` keeps the schedules capella.yaml already has. + +Step by step: docs/backup-restore.md. diff --git a/capellactl/help/ci.md b/capellactl/help/ci.md index 253fbf6..858d90f 100644 --- a/capellactl/help/ci.md +++ b/capellactl/help/ci.md @@ -52,6 +52,12 @@ jobs: if: github.ref == 'refs/heads/main' ``` +On a Windows runner (`runs-on: windows-latest`), steps run in PowerShell, so the gate is written differently: + +```yaml + - run: capellactl plan --detailed-exitcode; if ($LASTEXITCODE -notin 0, 2) { exit $LASTEXITCODE }; exit 0 +``` + ## GitLab CI ```yaml diff --git a/capellactl/help/scaling.md b/capellactl/help/scaling.md index 8ca015e..0e87859 100644 --- a/capellactl/help/scaling.md +++ b/capellactl/help/scaling.md @@ -17,6 +17,14 @@ cluster is healthy again. `--allow-service-group-removal`. If cluster stats cannot be read: `--skip-capacity-check`. - **Disk size** cannot be reduced. +**Disk types**: each service group has its own disk; `capellactl cluster disk-types [--provider P]` lists the +types with their size range, speed and use. AWS: `gp3` (general purpose, default) or `io2` (provisioned IOPS, up to +64000). Google Cloud: `pd-ssd` only, 60 IOPS per GB, so a larger disk is a faster one. Azure: `P6`…`P60` (size and +speed set by the type) or `Ultra` (size and IOPS chosen). Choose with `--disk-type` on `cluster create`, `cluster +group add` and `cluster update NAME --group SERVICES --disk-type io2`; interactive commands offer the list. Without +`--disk-iops`, capellactl uses Capella's recommended IOPS for the disk size. Changing a disk type replaces and +rebalances the group's nodes, except an in-place change on AWS (at most once every 6 hours). + **Limits**: first service group 3–27 nodes; additional groups 2–24; at most 27 nodes per cluster; `singleNode` clusters have exactly 1 node and cannot be scaled. diff --git a/capellactl/importer/__init__.py b/capellactl/importer/__init__.py index 2dd78ad..92d97ba 100644 --- a/capellactl/importer/__init__.py +++ b/capellactl/importer/__init__.py @@ -19,7 +19,7 @@ from capellactl import __version__ from capellactl.api import endpoints as ep from capellactl.api.client import CapellaClient -from capellactl.api.errors import CapellaApiError +from capellactl.api.errors import CapellaApiError, Forbidden, NotFound from capellactl.config.loader import BUCKET_DEFAULTS NEW_CLUSTER_TEMPLATE = """\ @@ -132,6 +132,31 @@ def _day_value(d: dict[str, Any]) -> str: return f"{_hhmm(d.get('from'))}-{_hhmm(d.get('to'), end=True)}" +BACKUP_ROLE_WARNING = ("backup schedules not read: the API key needs the Organization Owner or Project Owner role; " + "capella.yaml keeps the ones it has") + + +def export_snapshot_schedule(body: dict[str, Any]) -> dict[str, Any]: + """Cloud snapshot schedule → capella.yaml (CAP-056): startTime as the UTC time of day.""" + from capellactl.resources.backup_schedule import start_hm + + out: dict[str, Any] = {"interval": body.get("interval"), "retention": body.get("retention")} + hm = start_hm(body.get("startTime")) + if hm: + out["startTime"] = hm + if body.get("copyToRegions"): + out["copyToRegions"] = list(body["copyToRegions"]) + return out + + +def export_bucket_backup_schedule(body: dict[str, Any]) -> dict[str, Any]: + """Bucket backup schedule → capella.yaml (CAP-058): the weeklySchedule fields, flat.""" + from capellactl.resources.backup_schedule import BUCKET_DEFAULTS + + weekly = body.get("weeklySchedule") or {} + return {f: weekly.get(f, BUCKET_DEFAULTS[f]) for f in BUCKET_DEFAULTS} + + def export_schedule(body: dict[str, Any]) -> dict[str, Any]: """API form (7 days, Monday→Sunday) → friendly form; compacts weekdays/weekend when uniform (AC7).""" by_day = {d.get("day"): _day_value(d) for d in body.get("days") or []} @@ -294,6 +319,24 @@ def guarded(what: str, fn: Any, *args: Any, key: str, _cl: dict[str, Any] = cl, cl["hibernation"] = export_schedule(sched) counts["hibernation schedule"] += 1 + backups_readable = not free and state not in POWERED_OFF + if backups_readable: + try: + snap = ep.get_snapshot_schedule(client, pid, cid) + except Forbidden: + snap, backups_readable = None, False + not_read("backupSchedule", "the API key cannot read backups") + if BACKUP_ROLE_WARNING not in res.warnings: + res.warnings.append(BACKUP_ROLE_WARNING) + except CapellaApiError as exc: + snap = None + not_read("backupSchedule", f"Capella returned {exc.http_status}") + res.warnings.append(f"cluster {body['name']}: backup schedule not imported ({exc.http_status})") + if snap: + cl["backupSchedule"] = export_snapshot_schedule(snap) + elif not free: + not_read("backupSchedule", state) + cidrs = guarded("allowed CIDRs", ep.list_allowed_cidrs, client, pid, cid, key="allowedCidrs") if cidrs: active = [export_cidr(x) for x in cidrs if x.get("status", "active") != "expired"] @@ -327,6 +370,21 @@ def guarded(what: str, fn: Any, *args: Any, key: str, _cl: dict[str, Any] = cl, scopes = guarded(f"scopes of bucket {b['name']}", ep.list_scopes, client, pid, cid, b["id"], key=f"scopes:{b['name']}") eb = export_bucket(b, scopes, full) + if backups_readable: + try: + bsched: dict[str, Any] | None = ep.get_bucket_backup_schedule(client, pid, cid, b["id"]) + except NotFound: + bsched = None + except CapellaApiError as exc: + bsched = None + not_read(f"bucketBackup:{b['name']}", f"Capella returned {exc.http_status}") + if bsched: # before the scopes, as `bucket backup schedule set` writes it + scopes_ = eb.pop("scopes", None) + eb["backupSchedule"] = export_bucket_backup_schedule(bsched) + if scopes_ is not None: + eb["scopes"] = scopes_ + elif not free: + not_read(f"bucketBackup:{b['name']}", "the API key cannot read backups") exported.append(eb) counts["bucket"] += 1 counts["scope"] += len(eb.get("scopes") or []) diff --git a/capellactl/resources/backup_schedule.py b/capellactl/resources/backup_schedule.py new file mode 100644 index 0000000..de5139f --- /dev/null +++ b/capellactl/resources/backup_schedule.py @@ -0,0 +1,276 @@ +"""Backup schedule reconcilers: cloud snapshot backups of a cluster (CAP-056) and bucket backups (CAP-058). + +Both are optional children: without a `backupSchedule` key in the file an existing schedule is left alone, and only +`drift` and `apply --prune` look for one (most backup endpoints need an Organization or Project Owner key, so plans +made with a Project Manager key must not read them). A schedule the API key may not read is treated as absent. +""" + +from __future__ import annotations + +import contextlib +from datetime import datetime, timedelta, timezone +from typing import Any + +from capellactl.api import endpoints as ep +from capellactl.api.errors import Forbidden, NotFound +from capellactl.engine.context import RunContext +from capellactl.resources.base import Action, ActionType, Change, Observed, Reconciler + +BUCKET_DEFAULTS: dict[str, Any] = {"dayOfWeek": "sunday", "startAt": 0, "incrementalEvery": 24, + "retentionTime": "30days", "costOptimizedRetention": False} +RETENTION_WORDS = {"30days": "30 days", "60days": "60 days", "90days": "90 days", "180days": "180 days", + "1year": "1 year", "2years": "2 years", "3years": "3 years", "4years": "4 years", "5years": "5 years"} + + +# ------------------------------------------------------------------ cluster (cloud snapshots) +def hours_text(hours: Any) -> str: + """720 → '30 days', 36 → '36 h'.""" + h = int(hours) + if h and h % 24 == 0: + d = h // 24 + return f"{d} day" + ("" if d == 1 else "s") + return f"{h} h" + + +def start_hm(value: Any) -> str | None: + """'02:00' or an ISO 8601 instant → 'HH:MM' in UTC; None when absent or unreadable.""" + if not value: + return None + text = str(value) + if len(text) == 5 and text[2] == ":": + return text + try: + t = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if t.tzinfo is not None: + t = t.astimezone(timezone.utc) + return f"{t.hour:02d}:{t.minute:02d}" + + +def next_instant(hm: str, now: datetime) -> str: + """The next time of day `hm` (UTC) after `now`, as Capella's startTime.""" + now = now.astimezone(timezone.utc) + h, m = (int(x) for x in hm.split(":")) + at = now.replace(hour=h, minute=m, second=0, microsecond=0) + if at <= now: + at += timedelta(days=1) + return at.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def snapshot_summary(body: dict[str, Any]) -> str: + """`every 24 h · kept 30 days · first at 02:00 UTC · copies: us-west-2`.""" + parts = [f"every {body.get('interval')} h", f"kept {hours_text(body.get('retention') or 0)}"] + hm = start_hm(body.get("startTime")) + if hm: + parts.append(f"first at {hm} UTC") + if body.get("copyToRegions"): + parts.append("copies: " + ", ".join(body["copyToRegions"])) + return " · ".join(parts) + + +def snapshot_body(desired: dict[str, Any], now: datetime) -> dict[str, Any]: + body: dict[str, Any] = {"interval": int(desired["interval"]), "retention": int(desired["retention"])} + if desired.get("startTime"): + body["startTime"] = next_instant(str(desired["startTime"]), now) + body["copyToRegions"] = list(desired.get("copyToRegions") or []) + return body + + +def options_problem(options: dict[str, Any], desired: dict[str, Any], regions: list[str] | None = None) -> str | None: + """Why a schedule is outside what the cluster's support plan permits (CAP-056 AC3), or None.""" + intervals = [int(i) for i in options.get("intervals") or []] + if intervals and int(desired["interval"]) not in intervals: + return (f"interval {desired['interval']} is not permitted: use one of {', '.join(map(str, intervals))} hours " + "for this cluster's support plan") + lo, hi = options.get("minimumRetention"), options.get("maximumRetention") + ret = int(desired["retention"]) + if (lo is not None and ret < int(lo)) or (hi is not None and ret > int(hi)): + return (f"retention {hours_text(ret)} is not permitted: keep backups {hours_text(lo or 0)} to " + f"{hours_text(hi or 0)} for this cluster's support plan") + copies = list(desired.get("copyToRegions") or []) + if len(copies) > 2: + return "copyToRegions takes at most two regions" + if regions is not None: + bad = [r for r in copies if r not in regions] + if bad: + return f"copies cannot go to {', '.join(bad)}: available regions are {', '.join(regions) or 'none'}" + return None + + +def _cluster_ids(cluster: Action | None) -> tuple[str, str]: + project = cluster.ancestor("project") if cluster is not None else None + assert cluster is not None and cluster.id is not None and project is not None and project.id is not None + return project.id, cluster.id + + +class ClusterBackupScheduleReconciler(Reconciler): + kind = "backup-schedule" + story = "CAP-056" + prunable = True # `apply --prune` deletes a schedule the file does not declare; `drift` lists it + + def desired_items(self, parent_desired: Any) -> list[Any]: + if isinstance(parent_desired, dict) and isinstance(parent_desired.get("backupSchedule"), dict): + return [parent_desired["backupSchedule"]] + return [] + + def key(self, desired: Any, parent: Action | None) -> str: + return parent.key if parent else "" + + def unmanaged_label(self, observed: Observed, parent: Action | None) -> str: + return f"backup-schedule {parent.key if parent else ''} {snapshot_summary(observed.body)}" + + def observe(self, ctx: RunContext, parent: Action | None) -> list[Observed]: + if parent is not None and parent.data.get("free"): + return [] # no backups on the free tier + pid, cid = _cluster_ids(parent) + try: + body = ep.get_snapshot_schedule(ctx.client, pid, cid) + except (NotFound, Forbidden): + return [] + return [Observed(cid, body)] if body else [] + + def match(self, desired: Any, observed: list[Observed]) -> list[Observed]: + return observed[:1] + + def diff(self, ctx: RunContext, desired: Any, observed: Observed | None, parent: Action | None) -> Action: + key = self.key(desired, parent) + if desired.get("ensure") == "absent": + if observed is None: + return Action(self.kind, key, ActionType.NOOP, self, desired=desired, parent=parent, hidden=True) + return Action(self.kind, key, ActionType.DELETE, self, desired=desired, observed=observed, parent=parent, + summary=snapshot_summary(observed.body)) + if observed is None: + a = Action(self.kind, key, ActionType.CREATE, self, desired=desired, parent=parent, + summary=snapshot_summary(desired)) + else: + have = observed.body + changes = [Change(f, have.get(f), desired[f]) for f in ("interval", "retention") + if int(have.get(f) or 0) != int(desired[f])] + if sorted(have.get("copyToRegions") or []) != sorted(desired.get("copyToRegions") or []): + changes.append(Change("copyToRegions", have.get("copyToRegions") or [], + desired.get("copyToRegions") or [])) + if desired.get("startTime") and start_hm(have.get("startTime")) != desired["startTime"]: + changes.append(Change("startTime", start_hm(have.get("startTime")), desired["startTime"])) + a = Action(self.kind, key, ActionType.UPDATE if changes else ActionType.NOOP, self, desired=desired, + observed=observed, parent=parent, changes=changes) + if a.type is not ActionType.NOOP and parent is not None and parent.id is not None: + self._check_options(ctx, a, parent) + return a + + def _check_options(self, ctx: RunContext, action: Action, parent: Action) -> None: + """Values the support plan does not permit are blocked before any write (CAP-056 AC3).""" + pid, cid = _cluster_ids(parent) + try: + options = ep.get_snapshot_schedule_options(ctx.client, pid, cid) + regions = ep.list_snapshot_regions(ctx.client, pid, cid) if action.desired.get("copyToRegions") else None + except (NotFound, Forbidden): + return # Capella checks it on the write + problem = options_problem(options, action.desired, regions) + if problem: + action.type, action.reason, action.topic = ActionType.BLOCKED, problem, "backup" + + def delete_action(self, ctx: RunContext, observed: Observed, parent: Action | None) -> Action: + return Action(self.kind, self.key({}, parent), ActionType.DELETE, self, desired={"ensure": "absent"}, + observed=observed, parent=parent, summary=snapshot_summary(observed.body)) + + def apply(self, ctx: RunContext, action: Action) -> None: + pid, cid = _cluster_ids(action.parent) + if action.type is ActionType.DELETE: + with contextlib.suppress(NotFound): + ep.delete_snapshot_schedule(ctx.client, pid, cid) + return + ep.upsert_snapshot_schedule(ctx.client, pid, cid, snapshot_body(action.desired, ctx.clock.now())) + + def refresh(self, ctx: RunContext, action: Action) -> Action: + found = self.observe(ctx, action.parent) + return self.diff(ctx, action.desired, found[0] if found else None, action.parent) + + +# ------------------------------------------------------------------ bucket backups +def bucket_schedule(desired: dict[str, Any]) -> dict[str, Any]: + """The file's flat form with defaults → the API's weeklySchedule.""" + return {f: desired.get(f, BUCKET_DEFAULTS[f]) for f in BUCKET_DEFAULTS} + + +def bucket_summary(weekly: dict[str, Any]) -> str: + """`weekly full on sunday at 02:00 UTC · incremental every 12 h · kept 90 days`.""" + w = {**BUCKET_DEFAULTS, **weekly} + text = (f"weekly full on {w['dayOfWeek']} at {int(w['startAt']):02d}:00 UTC · incremental every " + f"{w['incrementalEvery']} h · kept {RETENTION_WORDS.get(str(w['retentionTime']), w['retentionTime'])}") + return text + (" · cost-optimized" if w.get("costOptimizedRetention") else "") + + +def _bucket_ids(bucket: Action | None) -> tuple[str, str, str]: + cluster = bucket.parent if bucket is not None else None + project = cluster.ancestor("project") if cluster is not None else None + assert bucket is not None and cluster is not None and project is not None + assert project.id and cluster.id and bucket.id + return project.id, cluster.id, bucket.id + + +class BucketBackupScheduleReconciler(Reconciler): + kind = "bucket-backup-schedule" + story = "CAP-058" + prunable = True + + def desired_items(self, parent_desired: Any) -> list[Any]: + if isinstance(parent_desired, dict) and isinstance(parent_desired.get("backupSchedule"), dict): + return [parent_desired["backupSchedule"]] + return [] + + def key(self, desired: Any, parent: Action | None) -> str: + return parent.key if parent else "" + + def unmanaged_label(self, observed: Observed, parent: Action | None) -> str: + return f"bucket-backup-schedule {parent.key if parent else ''} {bucket_summary(observed.body)}" + + def observe(self, ctx: RunContext, parent: Action | None) -> list[Observed]: + cluster = parent.parent if parent is not None else None + if cluster is not None and cluster.data.get("free"): + return [] + pid, cid, bid = _bucket_ids(parent) + try: + body = ep.get_bucket_backup_schedule(ctx.client, pid, cid, bid) + except (NotFound, Forbidden): + return [] + weekly = body.get("weeklySchedule") + return [Observed(bid, dict(weekly))] if isinstance(weekly, dict) else [] + + def match(self, desired: Any, observed: list[Observed]) -> list[Observed]: + return observed[:1] + + def diff(self, ctx: RunContext, desired: Any, observed: Observed | None, parent: Action | None) -> Action: + key = self.key(desired, parent) + if desired.get("ensure") == "absent": + if observed is None: + return Action(self.kind, key, ActionType.NOOP, self, desired=desired, parent=parent, hidden=True) + return Action(self.kind, key, ActionType.DELETE, self, desired=desired, observed=observed, parent=parent, + summary=bucket_summary(observed.body)) + want = bucket_schedule(desired) + if observed is None: + return Action(self.kind, key, ActionType.CREATE, self, desired=desired, parent=parent, + summary=bucket_summary(want)) + changes = [Change(f, observed.body.get(f), want[f]) for f in BUCKET_DEFAULTS if observed.body.get(f) != want[f]] + return Action(self.kind, key, ActionType.UPDATE if changes else ActionType.NOOP, self, desired=desired, + observed=observed, parent=parent, changes=changes) + + def delete_action(self, ctx: RunContext, observed: Observed, parent: Action | None) -> Action: + return Action(self.kind, self.key({}, parent), ActionType.DELETE, self, desired={"ensure": "absent"}, + observed=observed, parent=parent, summary=bucket_summary(observed.body)) + + def apply(self, ctx: RunContext, action: Action) -> None: + pid, cid, bid = _bucket_ids(action.parent) + if action.type is ActionType.DELETE: + with contextlib.suppress(NotFound): + ep.delete_bucket_backup_schedule(ctx.client, pid, cid, bid) + return + body = {"type": "weekly", "weeklySchedule": bucket_schedule(action.desired)} + if action.type is ActionType.CREATE: + ep.create_bucket_backup_schedule(ctx.client, pid, cid, bid, body) + else: + ep.update_bucket_backup_schedule(ctx.client, pid, cid, bid, body) + + def refresh(self, ctx: RunContext, action: Action) -> Action: + found = self.observe(ctx, action.parent) + return self.diff(ctx, action.desired, found[0] if found else None, action.parent) diff --git a/capellactl/resources/cluster.py b/capellactl/resources/cluster.py index 369029a..6cc6857 100644 --- a/capellactl/resources/cluster.py +++ b/capellactl/resources/cluster.py @@ -26,7 +26,7 @@ "availability", "support", "zones", "cmekId", "enablePrivateDNSResolution"] IMMUTABLE = ["configurationType", "cloudProvider", "availability", "zones", "cmekId", "enablePrivateDNSResolution", "couchbaseServer"] -NOT_API = {"id", "tier", "deletionProtection", "allowAnyIp", "allowedCidrs", "hibernation", "buckets", +NOT_API = {"id", "tier", "deletionProtection", "allowAnyIp", "allowedCidrs", "hibernation", "backupSchedule", "buckets", "databaseCredentials"} FREE_SERVICES = "data,index,query,search" diff --git a/capellactl/resources/unsupported.py b/capellactl/resources/unsupported.py index bae8af2..3d6b169 100644 --- a/capellactl/resources/unsupported.py +++ b/capellactl/resources/unsupported.py @@ -42,10 +42,15 @@ def diff(self, ctx: RunContext, desired: Any, observed: Observed | None, parent: def placeholders() -> list[Reconciler]: """Cluster children in dependency order (00-architecture.md section 4.2). - Every kind is a real reconciler now: deletion protection (CAP-013), the on/off schedule (CAP-020), allowed CIDRs - (CAP-022), buckets (CAP-023), scopes and collections (CAP-025) and database credentials (CAP-027). + Every kind is a real reconciler now: deletion protection (CAP-013), the on/off schedule (CAP-020), the cluster + backup schedule (CAP-056), allowed CIDRs (CAP-022), buckets (CAP-023) and their backup schedules (CAP-058), scopes + and collections (CAP-025) and database credentials (CAP-027). """ from capellactl.resources.allowed_cidr import AllowedCidrReconciler + from capellactl.resources.backup_schedule import ( + BucketBackupScheduleReconciler, + ClusterBackupScheduleReconciler, + ) from capellactl.resources.bucket import BucketReconciler from capellactl.resources.credential import CredentialReconciler from capellactl.resources.deletion_protection import DeletionProtectionReconciler @@ -55,7 +60,9 @@ def placeholders() -> list[Reconciler]: return [ DeletionProtectionReconciler(), ScheduleReconciler(), + ClusterBackupScheduleReconciler(), # CAP-056 AllowedCidrReconciler(), - BucketReconciler(children=[ScopeReconciler(children=[CollectionReconciler()])]), + BucketReconciler(children=[BucketBackupScheduleReconciler(), # CAP-058 + ScopeReconciler(children=[CollectionReconciler()])]), CredentialReconciler(), # last: after the data model it grants access to (CAP-027 AC7) ] diff --git a/docs/api/management-api-v4.0-operations.json b/docs/api/management-api-v4.0-operations.json index 3b79d86..229bcf5 100644 --- a/docs/api/management-api-v4.0-operations.json +++ b/docs/api/management-api-v4.0-operations.json @@ -1,7 +1,7 @@ { "source": "https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml", "apiVersion": "v4.0", - "retrieved": "2026-10-06", + "retrieved": "2026-10-29", "operations": [ { "tag": "CMEK", @@ -1059,6 +1059,18 @@ "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups", "summary": "Create Backup" }, + { + "tag": "Backups & Restore (Bucket)", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups/{backupId}/export", + "summary": "Create Backup Export" + }, + { + "tag": "Backups & Restore (Bucket)", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups/{backupId}/exports/{exportId}", + "summary": "Get Backup Export" + }, { "tag": "Backups & Restore (Bucket)", "method": "GET", @@ -1149,6 +1161,12 @@ "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule", "summary": "Delete Backup Schedule" }, + { + "tag": "Cloud Snapshot Backups Schedule", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule/options", + "summary": "Get Backup Schedule Options" + }, { "tag": "Backup Schedule (Bucket)", "method": "POST", @@ -1257,6 +1275,198 @@ "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/networkPeers", "summary": "List Network Peering Records" }, + { + "tag": "Operational Insights Clusters", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters", + "summary": "Create New Operational Insights Cluster" + }, + { + "tag": "Operational Insights Clusters", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters", + "summary": "List Project Level Operational Insights Clusters" + }, + { + "tag": "Operational Insights Clusters", + "method": "GET", + "path": "/v4/organizations/{organizationId}/operationalInsightsClusters", + "summary": "List Organization Level Operational Insights Clusters" + }, + { + "tag": "Operational Insights Clusters", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}", + "summary": "Get Operational Insights Cluster Details" + }, + { + "tag": "Operational Insights Clusters", + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}", + "summary": "Update an Operational Insights Cluster" + }, + { + "tag": "Operational Insights Clusters", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}", + "summary": "Delete Operational Insights Cluster" + }, + { + "tag": "Operational Insights Clusters", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/activationState", + "summary": "Turn On Operational Insights Cluster" + }, + { + "tag": "Operational Insights Clusters", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/activationState", + "summary": "Turn Off Operational Insights Cluster" + }, + { + "tag": "Operational Insights On/Off Schedule", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule", + "summary": "Create Operational Insights Cluster On/Off Schedule" + }, + { + "tag": "Operational Insights On/Off Schedule", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule", + "summary": "Get Operational Insights Cluster On/Off Schedule" + }, + { + "tag": "Operational Insights On/Off Schedule", + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule", + "summary": "Update Operational Insights Cluster On/Off Schedule" + }, + { + "tag": "Operational Insights On/Off Schedule", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule", + "summary": "Delete Operational Insights Cluster On/Off Schedule" + }, + { + "tag": "Allowed CIDRs (Operational Insights Cluster)", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs", + "summary": "Create Allowed CIDR" + }, + { + "tag": "Allowed CIDRs (Operational Insights Cluster)", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs", + "summary": "List Allowed CIDRs" + }, + { + "tag": "Allowed CIDRs (Operational Insights Cluster)", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}", + "summary": "Get Allowed CIDR" + }, + { + "tag": "Allowed CIDRs (Operational Insights Cluster)", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}", + "summary": "Delete Allowed CIDR" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups", + "summary": "Create Cloud Snapshot Backup" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups", + "summary": "List Cloud Snapshot Backups" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}", + "summary": "Update Backup Retention" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}", + "summary": "Delete Backup" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups Schedule", + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule", + "summary": "Upsert Backup Schedule" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups Schedule", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule", + "summary": "Get Backup Schedule" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups Schedule", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule", + "summary": "Delete Backup Schedule" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}/restore", + "summary": "Restore Backup" + }, + { + "tag": "Operational Insights Cloud Snapshot Backups & Restore", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/restores", + "summary": "List Cloud Snapshot Restores" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService", + "summary": "Get Operational Insights Private Endpoint Service Status" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService", + "summary": "Enable Operational Insights Private Endpoint Service" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService", + "summary": "Disable Operational Insights Private Endpoint Service." + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpointCommand", + "summary": "Get Operational Insights Private Endpoint CLI Command Required to Setup Private Endpoint for Specific CSP" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints", + "summary": "List Operational Insights Private Endpoint Connections" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/associate", + "summary": "Accept or Associate Operational Insights Private Endpoint Request" + }, + { + "tag": "Operational Insights Private Endpoint Service", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/unassociate", + "summary": "Reject or Disassociate Operational Insights Private Endpoint Connection." + }, { "tag": "Network Peers", "method": "GET", @@ -1455,6 +1665,144 @@ "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/workflows/supportedExternalEmbeddingModels", "summary": "Get Supported External Embedding Models" }, + { + "tag": "AI Agent Memory Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers", + "summary": "List Agent Memory Servers" + }, + { + "tag": "AI Agent Memory Servers", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers", + "summary": "Create Agent Memory Server" + }, + { + "tag": "AI Agent Memory Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}", + "summary": "Get Agent Memory Server" + }, + { + "tag": "AI Agent Memory Servers", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}", + "summary": "Delete Agent Memory Server" + }, + { + "tag": "AI Agent Memory Servers", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/copy", + "summary": "Copy Agent Memory Server" + }, + { + "tag": "AI Agent Memory Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/deploymentOptions", + "summary": "Get Agent Memory Server Deployment Options" + }, + { + "tag": "AI Agent Memory Allowed CIDRs", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/allowedCidrs", + "summary": "List Agent Memory Server Allowed CIDRs" + }, + { + "tag": "AI Agent Memory Allowed CIDRs", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/allowedCidrs", + "summary": "Create Agent Memory Server Allowed CIDR" + }, + { + "tag": "AI Agent Memory Allowed CIDRs", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/allowedCidrs", + "summary": "Delete Agent Memory Server Allowed CIDR" + }, + { + "tag": "AI Agent Memory API Keys", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/apiKeys", + "summary": "List Agent Memory API Keys" + }, + { + "tag": "AI Agent Memory API Keys", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/apiKeys", + "summary": "Create Agent Memory API Key" + }, + { + "tag": "AI Agent Memory API Keys", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/apiKeys/{apiKeyId}", + "summary": "Get Agent Memory API Key" + }, + { + "tag": "AI Agent Memory API Keys", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/aiServices/agentMemoryServers/{agentMemoryServerId}/apiKeys/{apiKeyId}", + "summary": "Delete Agent Memory API Key" + }, + { + "tag": "AI Agent Memory Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/aiServices/agentMemoryServers", + "summary": "List Organization Agent Memory Servers" + }, + { + "tag": "AI Agent Memory Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/aiServices/agentMemoryServers/eligibleClusters", + "summary": "List Agent Memory Server Eligible Clusters" + }, + { + "tag": "AI Agent Memory Allowed CIDRs", + "method": "GET", + "path": "/v4/organizations/{organizationId}/aiServices/agentMemoryAllowedCidrs", + "summary": "List Organization Agent Memory Allowed CIDRs" + }, + { + "tag": "AI Agent Memory API Keys", + "method": "GET", + "path": "/v4/organizations/{organizationId}/aiServices/agentMemoryApiKeys", + "summary": "List Organization Agent Memory API Keys" + }, + { + "tag": "MCP Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/mcpServers", + "summary": "List MCP Servers" + }, + { + "tag": "MCP Servers", + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/mcpServers", + "summary": "Create MCP Server" + }, + { + "tag": "MCP Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/mcpServers/{mcpServerId}", + "summary": "Get MCP Server" + }, + { + "tag": "MCP Servers", + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/mcpServers/{mcpServerId}", + "summary": "Delete MCP Server" + }, + { + "tag": "MCP Servers", + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/mcpServers/{mcpServerId}/clientConfig", + "summary": "Get MCP Server Client Config" + }, + { + "tag": "MCP Tools", + "method": "GET", + "path": "/v4/organizations/{organizationId}/mcpTools", + "summary": "List MCP Tools" + }, { "tag": "Models (AI Data Plane)", "method": "GET", diff --git a/docs/api/management-api-v4.0-responses.json b/docs/api/management-api-v4.0-responses.json index 553a982..59e55b3 100644 --- a/docs/api/management-api-v4.0-responses.json +++ b/docs/api/management-api-v4.0-responses.json @@ -1,7 +1,7 @@ { - "source": "/private/tmp/claude-503/-Users-anuj-sahni-couchbase-cbtools-capellactl/c682a2d1-3559-4121-8362-a2ac0dee55f3/scratchpad/spec/openapi.yaml", + "source": "https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml", "apiVersion": "v4.0", - "retrieved": "2026-10-08", + "retrieved": "2026-10-29", "operations": [ { "method": "GET", @@ -648,7 +648,8 @@ "enum": [ "basic", "developer pro", - "enterprise" + "enterprise", + "free" ] }, "timezone": { @@ -1077,7 +1078,8 @@ "enum": [ "basic", "developer pro", - "enterprise" + "enterprise", + "free" ] }, "timezone": { @@ -3105,6 +3107,1478 @@ "responses": { "204": null } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule", + "documented": true, + "summary": "Get Backup Schedule", + "responses": { + "200": { + "type": "object", + "properties": { + "interval": { + "type": "integer" + }, + "retention": { + "type": "integer" + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "copyToRegions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "204": null + } + }, + { + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule", + "documented": true, + "summary": "Upsert Backup Schedule", + "responses": { + "204": null + } + }, + { + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule", + "documented": true, + "summary": "Delete Backup Schedule", + "responses": { + "204": null + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackupschedule/options", + "documented": true, + "summary": "Get Backup Schedule Options", + "responses": { + "200": { + "type": "object", + "properties": { + "intervals": { + "type": "array", + "items": { + "type": "integer" + } + }, + "minimumRetention": { + "type": "integer" + }, + "maximumRetention": { + "type": "integer" + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups/regions", + "documented": true, + "summary": "List Available Geographic Regions for Cross-region Operations", + "responses": { + "200": { + "type": "array", + "required": [ + "items" + ], + "items": { + "type": "string" + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups", + "documented": true, + "summary": "List Cloud Snapshot Backups", + "responses": { + "200": { + "type": "object", + "required": [ + "data", + "cursor" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiration": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "progress": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + }, + "projectId": { + "type": "string" + }, + "appService": { + "type": "string" + }, + "cmek": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "providerId": { + "type": "string" + } + } + } + }, + "crossRegionCopies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "regionCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + } + }, + "retention": { + "type": "integer" + }, + "server": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "databaseSize": { + "type": "integer", + "format": "uint64" + }, + "tenantId": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "cursor": { + "type": "object", + "required": [ + "pages", + "hrefs" + ], + "properties": { + "pages": { + "type": "object", + "required": [ + "page", + "last", + "perPage", + "totalItems" + ], + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer" + }, + "previous": { + "type": "integer" + }, + "last": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + } + } + }, + "hrefs": { + "type": "object", + "required": [ + "first", + "last", + "previous", + "next" + ], + "properties": { + "first": { + "type": "string", + "format": "uri" + }, + "last": { + "type": "string", + "format": "uri" + }, + "previous": { + "type": "string", + "format": "uri" + }, + "next": { + "type": "string", + "format": "uri" + } + } + } + } + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/cloudsnapshotbackups", + "documented": true, + "summary": "List Cloud Snapshot Backups at the Project Level", + "responses": { + "200": { + "type": "object", + "required": [ + "data", + "cursor" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "clusterName": { + "type": "string" + }, + "creationDateTime": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "currentStatus": { + "type": "string" + }, + "cloudProvider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "mostRecentSnapshot": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiration": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "progress": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + }, + "projectId": { + "type": "string" + }, + "appService": { + "type": "string" + }, + "cmek": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "providerId": { + "type": "string" + } + } + } + }, + "crossRegionCopies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "regionCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + } + }, + "retention": { + "type": "integer" + }, + "server": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "databaseSize": { + "type": "integer", + "format": "uint64" + }, + "tenantId": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oldestSnapshot": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiration": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "progress": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + }, + "projectId": { + "type": "string" + }, + "appService": { + "type": "string" + }, + "cmek": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "providerId": { + "type": "string" + } + } + } + }, + "crossRegionCopies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "regionCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + } + } + } + }, + "retention": { + "type": "integer" + }, + "server": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "databaseSize": { + "type": "integer", + "format": "uint64" + }, + "tenantId": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + } + }, + "cursor": { + "type": "object", + "required": [ + "pages", + "hrefs" + ], + "properties": { + "pages": { + "type": "object", + "required": [ + "page", + "last", + "perPage", + "totalItems" + ], + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer" + }, + "previous": { + "type": "integer" + }, + "last": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + } + } + }, + "hrefs": { + "type": "object", + "required": [ + "first", + "last", + "previous", + "next" + ], + "properties": { + "first": { + "type": "string", + "format": "uri" + }, + "last": { + "type": "string", + "format": "uri" + }, + "previous": { + "type": "string", + "format": "uri" + }, + "next": { + "type": "string", + "format": "uri" + } + } + } + } + } + } + } + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups", + "documented": true, + "summary": "Create Cloud Snapshot Backup", + "responses": { + "202": { + "type": "object", + "properties": { + "backupId": { + "type": "string" + } + } + } + } + }, + { + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups/{backupId}", + "documented": true, + "summary": "Edit Backup Retention", + "responses": { + "204": null + } + }, + { + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups/{backupId}", + "documented": true, + "summary": "Delete Backup", + "responses": { + "202": { + "type": "object", + "properties": {} + } + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups/{backupId}/restore", + "documented": true, + "summary": "Restore Backup", + "responses": { + "202": { + "type": "object", + "properties": { + "restoreId": { + "type": "string" + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/cloudsnapshotbackups/restores", + "documented": true, + "summary": "List Cloud Snapshot Restores", + "responses": { + "200": { + "type": "object", + "required": [ + "data", + "cursor" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "projectId": { + "type": "string" + }, + "restoreEnd": { + "type": "string", + "format": "date-time" + }, + "restoreTo": { + "type": "string" + }, + "snapshot": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + }, + "tenantId": { + "type": "string" + } + } + } + }, + "cursor": { + "type": "object", + "required": [ + "pages", + "hrefs" + ], + "properties": { + "pages": { + "type": "object", + "required": [ + "page", + "last", + "perPage", + "totalItems" + ], + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer" + }, + "previous": { + "type": "integer" + }, + "last": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + } + } + }, + "hrefs": { + "type": "object", + "required": [ + "first", + "last", + "previous", + "next" + ], + "properties": { + "first": { + "type": "string", + "format": "uri" + }, + "last": { + "type": "string", + "format": "uri" + }, + "previous": { + "type": "string", + "format": "uri" + }, + "next": { + "type": "string", + "format": "uri" + } + } + } + } + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/schedules", + "documented": true, + "summary": "Get Backup Schedule", + "responses": { + "200": { + "type": "object", + "required": [ + "type", + "clusterID", + "bucketId", + "weeklySchedule" + ], + "properties": { + "type": { + "type": "string" + }, + "clusterID": { + "type": "string", + "format": "uuid" + }, + "bucketId": { + "type": "string" + }, + "weeklySchedule": { + "type": "object", + "required": [ + "dayOfWeek", + "startAt", + "incrementalEvery", + "retentionTime", + "costOptimizedRetention" + ], + "properties": { + "dayOfWeek": { + "type": "string" + }, + "startAt": { + "type": "integer" + }, + "incrementalEvery": { + "type": "integer" + }, + "retentionTime": { + "type": "string" + }, + "costOptimizedRetention": { + "type": "boolean" + } + } + } + } + } + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/schedules", + "documented": true, + "summary": "Create Backup Schedule", + "responses": { + "202": null + } + }, + { + "method": "PUT", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/schedules", + "documented": true, + "summary": "Update Backup Schedule", + "responses": { + "204": null + } + }, + { + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/schedules", + "documented": true, + "summary": "Delete Backup Schedule", + "responses": { + "202": null + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/cycles", + "documented": true, + "summary": "List Cycles", + "responses": { + "200": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "cycleID", + "createdAt" + ], + "properties": { + "cycleID": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backup/cycles/{cycleId}", + "documented": true, + "summary": "List Backups", + "responses": { + "200": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "clusterID", + "tenantID", + "projectID", + "cycleID", + "status", + "method", + "bucketName", + "bucketID", + "source", + "provider", + "stats", + "elapsedTimeInSeconds", + "scheduleInfo" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "clusterID": { + "type": "string", + "format": "uuid" + }, + "tenantID": { + "type": "string", + "format": "uuid" + }, + "projectID": { + "type": "string", + "format": "uuid" + }, + "cycleID": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "restoreBefore": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "ready", + "failed" + ] + }, + "method": { + "type": "string", + "enum": [ + "incremental", + "full" + ] + }, + "bucketName": { + "type": "string" + }, + "bucketID": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "scheduled" + ] + }, + "provider": { + "type": "string" + }, + "stats": { + "type": "object", + "required": [ + "cbas", + "event", + "fts", + "gsi", + "tombstones", + "mutations", + "items", + "sizeInMb" + ], + "properties": { + "sizeInMb": { + "type": "number", + "format": "float" + }, + "items": { + "type": "integer" + }, + "mutations": { + "type": "integer" + }, + "tombstones": { + "type": "integer" + }, + "gsi": { + "type": "integer" + }, + "fts": { + "type": "integer" + }, + "cbas": { + "type": "integer" + }, + "event": { + "type": "integer" + } + } + }, + "elapsedTimeInSeconds": { + "type": "integer" + }, + "scheduleInfo": { + "type": "object", + "required": [ + "backupType", + "backupTime", + "increment", + "retention" + ], + "properties": { + "backupType": { + "type": "string" + }, + "backupTime": { + "type": "string" + }, + "increment": { + "type": "integer" + }, + "retention": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups", + "documented": true, + "summary": "Create Backup", + "responses": { + "202": null + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/backups", + "documented": true, + "summary": "List Cluster Backups", + "responses": { + "200": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "clusterID", + "tenantID", + "projectID", + "cycleID", + "status", + "method", + "bucketName", + "bucketID", + "source", + "provider", + "stats", + "elapsedTimeInSeconds", + "scheduleInfo" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "clusterID": { + "type": "string", + "format": "uuid" + }, + "tenantID": { + "type": "string", + "format": "uuid" + }, + "projectID": { + "type": "string", + "format": "uuid" + }, + "cycleID": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "restoreBefore": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "ready", + "failed" + ] + }, + "method": { + "type": "string", + "enum": [ + "incremental", + "full" + ] + }, + "bucketName": { + "type": "string" + }, + "bucketID": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "scheduled" + ] + }, + "provider": { + "type": "string" + }, + "stats": { + "type": "object", + "required": [ + "cbas", + "event", + "fts", + "gsi", + "tombstones", + "mutations", + "items", + "sizeInMb" + ], + "properties": { + "sizeInMb": { + "type": "number", + "format": "float" + }, + "items": { + "type": "integer" + }, + "mutations": { + "type": "integer" + }, + "tombstones": { + "type": "integer" + }, + "gsi": { + "type": "integer" + }, + "fts": { + "type": "integer" + }, + "cbas": { + "type": "integer" + }, + "event": { + "type": "integer" + } + } + }, + "elapsedTimeInSeconds": { + "type": "integer" + }, + "scheduleInfo": { + "type": "object", + "required": [ + "backupType", + "backupTime", + "increment", + "retention" + ], + "properties": { + "backupType": { + "type": "string" + }, + "backupTime": { + "type": "string" + }, + "increment": { + "type": "integer" + }, + "retention": { + "type": "string" + } + } + }, + "bucketDownloadsCount": { + "type": "integer" + } + } + } + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/backups/{backupId}", + "documented": true, + "summary": "Get Backup", + "responses": { + "200": { + "type": "object", + "required": [ + "id", + "clusterID", + "tenantID", + "projectID", + "cycleID", + "status", + "method", + "bucketName", + "bucketID", + "source", + "provider", + "stats", + "elapsedTimeInSeconds", + "scheduleInfo" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "clusterID": { + "type": "string", + "format": "uuid" + }, + "tenantID": { + "type": "string", + "format": "uuid" + }, + "projectID": { + "type": "string", + "format": "uuid" + }, + "cycleID": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "restoreBefore": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "ready", + "failed" + ] + }, + "method": { + "type": "string", + "enum": [ + "incremental", + "full" + ] + }, + "bucketName": { + "type": "string" + }, + "bucketID": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "scheduled" + ] + }, + "provider": { + "type": "string" + }, + "stats": { + "type": "object", + "required": [ + "cbas", + "event", + "fts", + "gsi", + "tombstones", + "mutations", + "items", + "sizeInMb" + ], + "properties": { + "sizeInMb": { + "type": "number", + "format": "float" + }, + "items": { + "type": "integer" + }, + "mutations": { + "type": "integer" + }, + "tombstones": { + "type": "integer" + }, + "gsi": { + "type": "integer" + }, + "fts": { + "type": "integer" + }, + "cbas": { + "type": "integer" + }, + "event": { + "type": "integer" + } + } + }, + "elapsedTimeInSeconds": { + "type": "integer" + }, + "scheduleInfo": { + "type": "object", + "required": [ + "backupType", + "backupTime", + "increment", + "retention" + ], + "properties": { + "backupType": { + "type": "string" + }, + "backupTime": { + "type": "string" + }, + "increment": { + "type": "integer" + }, + "retention": { + "type": "string" + } + } + } + } + } + } + }, + { + "method": "DELETE", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/backups/{backupId}", + "documented": true, + "summary": "Delete Backup Cycle", + "responses": { + "202": null + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/backups/{backupId}/restore", + "documented": true, + "summary": "Restore Backup", + "responses": { + "202": null + } + }, + { + "method": "POST", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups/{backupId}/export", + "documented": true, + "summary": "Create Backup Export", + "responses": { + "202": { + "type": "object", + "required": [ + "exportId", + "createdAt" + ], + "properties": { + "exportId": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + { + "method": "GET", + "path": "/v4/organizations/{organizationId}/projects/{projectId}/clusters/{clusterId}/buckets/{bucketId}/backups/{backupId}/exports/{exportId}", + "documented": true, + "summary": "Get Backup Export", + "responses": { + "200": { + "type": "object", + "required": [ + "id", + "tenantId", + "projectId", + "clusterId", + "cycleId", + "bucketName", + "status", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "tenantId": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "clusterId": { + "type": "string", + "format": "uuid" + }, + "cycleId": { + "type": "string", + "format": "uuid" + }, + "bucketName": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "complete", + "failed", + "expired" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "sizeInBytes": { + "type": "integer", + "format": "int64" + }, + "sha256Checksum": { + "type": "string" + }, + "expiration": { + "type": "string", + "format": "date-time" + }, + "backupDownloadURL": { + "type": "string", + "format": "uri" + } + } + } + } } ] } diff --git a/docs/backup-restore.md b/docs/backup-restore.md new file mode 100644 index 0000000..eff93b0 --- /dev/null +++ b/docs/backup-restore.md @@ -0,0 +1,218 @@ +# Backup and restore + +This guide shows how to protect your data with `capellactl`: set backup schedules, take a backup before a risky +change, and restore a whole cluster or a single bucket. Everything is done with commands; you never edit +`capella.yaml`, and every schedule you set is recorded there so `plan`, `drift` and `sync` see it. + +You need a working setup first (a profile, a `capella.yaml` and a provisioned cluster): follow +[Getting started](getting-started.md) up to "Manage a provisioned cluster". The examples use cluster `prod-01` in +project `sandbox`, its bucket `orders`, and a second cluster `prod-01-copy`. Free-tier clusters have no backups. Most +backup commands need an API key with the **Organization Owner** or **Project Owner** role. + +- [Two kinds of backup](#two-kinds-of-backup) +- [1. See what your plan allows](#1-see-what-your-plan-allows) +- [2. Schedule cluster backups](#2-schedule-cluster-backups) +- [3. Schedule bucket backups](#3-schedule-bucket-backups) +- [4. Back up now, before a change](#4-back-up-now-before-a-change) +- [5. List backups](#5-list-backups) +- [6. Restore a bucket](#6-restore-a-bucket) +- [7. Restore a whole cluster](#7-restore-a-whole-cluster) +- [8. Download a bucket backup](#8-download-a-bucket-backup) +- [9. What capella.yaml records](#9-what-capellayaml-records) +- [Troubleshooting](#troubleshooting) + +## Two kinds of backup + +| | Cluster backup (`cluster backup …`) | Bucket backup (`bucket backup …`) | +|---|---|---| +| Capella name | Cloud snapshot backup | Backup (bucket) | +| What is saved | The whole cluster: every bucket, index and setting, at the cloud-disk level | One bucket's data: a weekly **full** backup starts a **cycle**, then **incrementals** every few hours | +| Schedule | Every N hours, kept for a number of hours or days, optional copies in up to two other regions | Day and hour of the weekly full backup, incremental interval, retention from 30 days to 5 years | +| Restore | Replaces all data of the same cluster | To the same or another cluster, all of it or selected scopes and collections | +| Use it for | Recovering a cluster after a bad change or an outage | Recovering one bucket, copying a bucket to another cluster, keeping data for years | + +## 1. See what your plan allows + +The backup intervals and retention a cluster can use depend on its support plan, so ask Capella first: + +```bash +capellactl cluster backup schedule options prod-01 +``` +``` +Cluster backup schedule options for prod-01 (support plan: developer pro) + every (hours) 1, 2, 4, 6, 8, 12, 24 + keep 1 day to 90 days + copy regions us-east-2, us-west-2, eu-west-1 (up to two) +``` + +## 2. Schedule cluster backups + +```bash +capellactl cluster backup schedule set prod-01 --every 24 --retain 30d --start 02:00 +capellactl cluster backup schedule show prod-01 +``` +``` + + backup-schedule prod-01 every 24 h · kept 30 days · first at 02:00 UTC + +Plan: 1 to add, 0 to change, 0 to delete, 0 blocked. +Record this backup schedule in capella.yaml and apply it to prod-01? [y/N]: y + +every 24 h · kept 30 days · first at 02:00 UTC +next backup: 2026-10-30 02:00 UTC +``` + +To keep a copy in another region as well, add `--copy-to us-west-2` (AWS and Azure). Running the same command again +changes nothing. `capellactl cluster backup schedule delete prod-01` removes the schedule; existing backups are kept +until they expire. + +## 3. Schedule bucket backups + +With no options, the bucket gets a full backup every Sunday at 00:00 UTC and an incremental every 24 hours, kept for +30 days. Give only the values you want to change: + +```bash +capellactl bucket backup schedule set prod-01 orders --start-at 2 --incremental-every 12 +capellactl bucket backup schedule show prod-01 orders +``` +``` +weekly full on sunday at 02:00 UTC · incremental every 12 h · kept 30 days +``` + +| Option | Values | +|---|---| +| `--day` | `sunday` … `saturday` | +| `--start-at` | hour 0–23 (UTC) | +| `--incremental-every` | 1, 2, 4, 6, 8, 12, 24 hours | +| `--retention` | `30days`, `60days`, `90days`, `180days`, `1year` … `5years` | +| `--cost-optimized` | Capella's cost-optimized retention (`costOptimizedRetention`); off by default | + +## 4. Back up now, before a change + +```bash +capellactl cluster backup create prod-01 +capellactl bucket backup create prod-01 orders +``` +``` +→ backing up cluster prod-01 … + backup of prod-01: pending (0s) + backup of prod-01: complete (6m10s) +backup 3f1c… of prod-01 is complete +``` + +Both wait until the backup is done (`--no-wait` returns at once). + +## 5. List backups + +Every list looks the same: a number first, the ID last. + +```bash +capellactl cluster backup list prod-01 +capellactl bucket backup list prod-01 +capellactl bucket backup cycle list prod-01 orders +``` +``` +S.NO CREATED TYPE STATUS SIZE EXPIRES COPIES ID +1 2026-10-29 10:12 onDemand complete 42 GB 2026-11-28 10:12 - 3f1c… +2 2026-10-28 02:00 scheduled complete 41 GB 2026-11-27 02:00 - 9b07… + +S.NO BUCKET DATE METHOD SOURCE STATUS SIZE ITEMS EXPIRES ID +1 orders 2026-10-29 10:20 full manual ready 3.1 GB 912,000 2026-11-28 10:20 7a2e… +``` + +`capellactl cluster backup list` without a cluster name shows the newest and oldest backup of every cluster in the +project; `capellactl bucket backup list prod-01 orders` shows every backup of one bucket. In the steps below, use an +ID from these lists in place of `CLUSTER_BACKUP_ID` or `BUCKET_BACKUP_ID`. To keep an important cluster backup longer: + +```bash +capellactl cluster backup retain prod-01 CLUSTER_BACKUP_ID --retain 90d +``` + +## 6. Restore a bucket + +The safest restore puts the backup back into the same bucket, keeps documents that are newer in the cluster, and +leaves document expiry as it was: + +```bash +capellactl bucket backup restore prod-01 BUCKET_BACKUP_ID --replace-ttl none +``` +``` + ~ restore orders from the backup of 2026-10-29 10:20 UTC into prod-01 + services: data · newer documents in prod-01 are kept +Type the target cluster name to confirm the restore: prod-01 +restore of orders into prod-01 started; Capella reports its progress in the cluster's activity log (the Management API has no restore status for bucket backups) +``` + +Restore only one collection into another cluster, for example to check data before touching production: + +```bash +capellactl bucket backup restore prod-01 BUCKET_BACKUP_ID --to-cluster prod-01-copy --include-data orders.sales +``` + +## 7. Restore a whole cluster + +A cluster restore replaces **all** data of the cluster with the backup, so you type the cluster name to confirm: + +```bash +capellactl cluster backup restore prod-01 CLUSTER_BACKUP_ID +capellactl cluster backup restores prod-01 +``` +``` + ± cluster prod-01 all data is replaced with the backup of 2026-10-29 10:12 UTC +Type the cluster name to confirm the restore: prod-01 +→ restoring cluster prod-01 … + restore of prod-01: complete (12m30s) + cluster prod-01: healthy (13m02s) +restored cluster prod-01 from the backup of 2026-10-29 10:12 UTC +``` + +## 8. Download a bucket backup + +```bash +capellactl bucket backup export prod-01 BUCKET_BACKUP_ID +``` +``` +export 9d78… of orders started + export of orders: complete (4m20s) +export 9d78… is complete: 3.1 GB, available until 2026-10-29 22:00 UTC +download (valid for one hour; treat it like a password): + https://… +``` + +Capella packages the backup's whole cycle as a zip. The link is shown only in your terminal; for a new link later, +add `--export-id 9d78…` to the same command. + +## 9. What capella.yaml records + +Schedules are part of your environment; backups themselves are not. + +```yaml +clusters: + - name: prod-01 + backupSchedule: # cluster backups + interval: 24 + retention: 720 + startTime: "02:00" + buckets: + - name: orders + backupSchedule: # bucket backups + dayOfWeek: sunday + startAt: 2 + incrementalEvery: 12 + retentionTime: 30days + costOptimizedRetention: false +``` + +`capellactl drift` reports a schedule someone changed in the Capella UI, and `capellactl sync` records it. +Without a `backupSchedule` key, an existing schedule is left alone; `capellactl apply --prune` deletes it. + +## Troubleshooting + +| Message | What to do | +|---|---| +| `interval 3 is not permitted: use one of 1, 2, 4, 6, 8, 12, 24 hours …` | Pick a value from `cluster backup schedule options` | +| `free-tier clusters have no backups` | Create a provisioned cluster for data that needs backups | +| `turned off; power it on before restoring` | `capellactl cluster power prod-01 --on`, then restore | +| `backup … not found` | List the backups again; a backup past its retention is deleted by Capella | +| `deletes cycle … with 7 backups` | Deleting a bucket backup deletes its whole weekly cycle; take a new backup first if you still need one | +| `already has an export (Capella allows one per cycle)` | Show the earlier export with `--export-id ID` | +| `backup schedules not read: the API key needs the Organization Owner or Project Owner role` | `sync` kept the schedules capella.yaml has; use a key with one of those roles to read them | diff --git a/docs/custom-clusters.md b/docs/custom-clusters.md index dd9f73f..a14706b 100644 --- a/docs/custom-clusters.md +++ b/docs/custom-clusters.md @@ -17,6 +17,7 @@ step 3. Every example below uses project `sandbox`. - [7. Change the layout later](#7-change-the-layout-later) - [8. Single-node and advanced options](#8-single-node-and-advanced-options) - [9. Keep capella.yaml in step with Capella](#9-keep-capellayaml-in-step-with-capella) +- [10. Choose the disk type](#10-choose-the-disk-type) - [Troubleshooting](#troubleshooting) ## Service groups in one minute @@ -296,6 +297,43 @@ added project non-prod (5 clusters) capella.yaml updated from Capella (previous version kept as capella.yaml.bak). ``` +## 10. Choose the disk type + +Each service group has its own disk. You choose its type the way you choose vCPU and RAM; the type decides how large +and how fast the disk can be: + +```bash +capellactl cluster disk-types --provider aws +``` +``` +S.NO PROVIDER TYPE SIZE (GB) IOPS FOR +1 aws gp3 50–16000 3000–16000 general-purpose SSD (default) +2 aws io2 50–16000 3000–64000 provisioned-IOPS SSD for latency-sensitive, write-heavy workloads; costs more +``` + +| Provider | Type | Size per node | Speed (IOPS) | +|---|---|---|---| +| AWS | `gp3` (default) | 50 GB – 16 TB | 3,000 – 16,000, set on its own | +| AWS | `io2` | 50 GB – 16 TB | 3,000 – 64,000 | +| Google Cloud | `pd-ssd` (only type) | 50 GB and up | follows the size: 60 per GB (50 GB → 1,500 read + 1,500 write) | +| Azure | `P6` `P10` `P15` `P20` `P30` `P40` `P50` `P60` | fixed by the type: 64, 128, 256, 512, 1,024, 2,048, 4,096, 8,192 GiB | fixed: 240, 500, 1,100, 2,300, 5,000, 7,500, 7,500, 16,000 | +| Azure | `Ultra` (regions that offer it) | 64, 128, 256, 512 GiB, 1–15 TiB | set by you, up to 19,200 (64 GiB) … 80,000 (512 GiB+) | + +When you don't give `--disk-iops`, capellactl uses Capella's recommended IOPS for the disk size (for gp3 and io2: +3,000 up to 99 GB, 4,370 for 100–199 GB, … 16,000 from 1,000 GB). A higher value is allowed. + +```bash +capellactl cluster create prod-05 --provider aws --region us-east-1 --size medium --disk-type gp3 --disk-size 200 +capellactl cluster group add prod-05 --services search --nodes 2 --disk-type io2 --disk-size 200 +capellactl cluster update prod-05 --group data,index,query --disk-type io2 --disk-iops 20000 +``` + +Answering questions instead (`capellactl cluster create prod-05`, size `custom`), the disk type is asked right after +the node size, with the same table, and then only what that type lets you set: the size (with its range) and the +IOPS (with the recommendation for that size). `cluster group add` asks the same, starting from the data group's disk. +Changing the disk type of a running cluster replaces and rebalances the group's nodes, except an in-place change on +AWS (at most once every 6 hours); the plan says so before you confirm. + ## Troubleshooting | Message | What to do | diff --git a/docs/getting-started.md b/docs/getting-started.md index b36d432..08ae49f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,7 +22,8 @@ Two things to know before you start: **A Capella API key.** In the Capella UI, go to **Organization settings → API Keys** and create a key: - Role: **Organization Owner**, or **Project Creator** plus **Project Owner** on the project you will use. -- Allowed IPs: add this machine's public IP (`curl -s https://checkip.amazonaws.com` shows it). +- Allowed IPs: add this machine's public IP (`curl -s https://checkip.amazonaws.com` shows it; in PowerShell, + `curl.exe -s https://checkip.amazonaws.com`). - Copy the **API Key Token** when the key is created. Capella shows it only once, and the API Key ID will not work. **capellactl installed.** Follow [Install](../README.md#install), then check it: @@ -38,6 +39,26 @@ capellactl --version # capellactl 1.0.0.devN+g… (Management API mkdir ~/capella-env && cd ~/capella-env ``` +### Using the commands in PowerShell + +This guide and the [runbook](runbook.md) show macOS/Linux commands. Every `capellactl` command is typed the same way +in PowerShell; only these shell parts differ: + + +| In the docs (macOS/Linux) | In PowerShell | +|---|---| +| `mkdir ~/capella-env && cd ~/capella-env` | `mkdir ~\capella-env; cd ~\capella-env` | +| `export ORDERS_APP_PASSWORD='…'` | `$env:ORDERS_APP_PASSWORD = '…'` (in cmd.exe: `set ORDERS_APP_PASSWORD=…`) | +| a long command with lines ending in `\` | end those lines with a backtick `` ` `` instead, or type it on one line | +| `"$(curl -s https://checkip.amazonaws.com)/32"` | `"$(curl.exe -s https://checkip.amazonaws.com)/32"` (plain `curl` is a different command in Windows PowerShell) | +| `pbpaste \| capellactl configure --api-key-stdin` | `Get-Clipboard \| capellactl configure --api-key-stdin` | +| `capellactl plan --detailed-exitcode \|\| [ $? -eq 2 ]` | `capellactl plan --detailed-exitcode; if ($LASTEXITCODE -notin 0, 2) { exit $LASTEXITCODE }` | +| `capellactl completion zsh` | not available in PowerShell | + + +At the hidden `configure` prompt, paste with right-click or Ctrl+V: nothing appears, but the token is there; press +Enter. + ## Step by step ### Connect to Capella @@ -379,11 +400,16 @@ capellactl cluster create dev-01 --project sandbox --provider azure --region eas ``` On AWS use `--provider aws --region us-east-1`, on Google Cloud `--provider gcp --region us-east1`; capellactl picks -each provider's disk type for you (Azure: Premium SSD, such as P6). +each provider's default disk type for you (Azure: Premium SSD P6). To choose another one, `capellactl cluster +disk-types` lists each type with its size range and speed; pass it with `--disk-type` (see +[Choose the disk type](custom-clusters.md#10-choose-the-disk-type)). For a production layout with separate groups of nodes per service (multi-dimensional scaling), for example data on 3 × 8 vCPU/32 GB and index and query on 2 × 8/32, see [Custom clusters and service groups](custom-clusters.md). +To back the cluster up on a schedule, take a backup before a risky change, or restore a cluster or a bucket, see +[Backup and restore](backup-restore.md). + **12. Change a cluster** A cluster can grow in two ways: **scale out** adds nodes of the same size, which adds capacity and spreads the load; @@ -749,7 +775,11 @@ Disks never shrink, and failed or degraded clusters are not changed. A turned-of Organization settings → API Keys → your key) not including this machine's public IP (`curl -s https://checkip.amazonaws.com`). Also check that you copied the **API Key Token**, not the API Key ID. The message shows Capella's own hint and a `curl` command to test the key. To avoid pasting into the hidden prompt: - `pbpaste | capellactl configure --api-key-stdin`. + `pbpaste | capellactl configure --api-key-stdin` (PowerShell: `Get-Clipboard | capellactl configure --api-key-stdin`). +- Windows: `capellactl is not recognized`: this PowerShell window is not activated. Run + `.venv\Scripts\Activate.ps1` in the `capellactl` folder, or type the full path `…\capellactl\.venv\Scripts\capellactl.exe`. + `running scripts is disabled on this system`: run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once, or + use the full path ([Install on Windows](../README.md#on-windows-powershell)). - `sync` or `configure` says `buckets and database credentials not imported (turnedOff)`: Capella does not return them while a cluster is off. Turn it on (`capellactl cluster power NAME --on`) and run `capellactl sync` again. - `plan` blocks a project with `probably renamed in Capella`: the project was renamed in the UI and the file has no diff --git a/docs/reference.md b/docs/reference.md index 16758f5..86d1655 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -78,6 +78,12 @@ Clusters in this project. | `hibernation.friday` | boolean or string | | updatable | days[] (On/Off Schedule) | Friday: 'on', 'off', or an on-window 'HH:MM-HH:MM' (minutes 00 or 30). Overrides weekdays/weekend. | | `hibernation.saturday` | boolean or string | | updatable | days[] (On/Off Schedule) | Saturday: 'on', 'off', or an on-window 'HH:MM-HH:MM' (minutes 00 or 30). Overrides weekdays/weekend. | | `hibernation.sunday` | boolean or string | | updatable | days[] (On/Off Schedule) | Sunday: 'on', 'off', or an on-window 'HH:MM-HH:MM' (minutes 00 or 30). Overrides weekdays/weekend. | +| `backupSchedule` | object | | | | Cloud snapshot backups of the whole cluster (CAP-056): how often, how long each is kept, copies in other regions. Not available on the free tier. | +| `backupSchedule.interval` | integer (1–…) | | updatable | interval (Cloud Snapshot Backup Schedule) | Hours between backups; one of the intervals the cluster's support plan permits ('capellactl cluster backup schedule options NAME'). | +| `backupSchedule.retention` | integer (1–…) | | updatable | retention (Cloud Snapshot Backup Schedule) | Hours each backup is kept, within the support plan's range (720 = 30 days). | +| `backupSchedule.startTime` | string | | updatable | startTime (Cloud Snapshot Backup Schedule) | Time of day (UTC) of the first backup, HH:MM. Sent as the next such instant. | +| `backupSchedule.copyToRegions` | list of string | | updatable | copyToRegions (Cloud Snapshot Backup Schedule) | Other regions that keep a copy of each backup (AWS and Azure; at most two). | +| `backupSchedule.ensure` | string: `present`, `absent` | `present` | capellactl only | | present (default) or absent to delete the schedule. | | `buckets` | list of objects | | | | Buckets in this cluster. | | `databaseCredentials` | list of objects | | | | Application credentials. | @@ -129,6 +135,13 @@ Buckets in this cluster. | `priority` | integer (0–…) | `0` | updatable | priority (Create/Update Bucket) | Recovery priority during failover (server 7.6+). | | `enableCrossClusterVersioning` | boolean | | updatable | enableCrossClusterVersioning (Update Bucket) | Store XDCR versioning metadata (prerequisite for some XDCR features). | | `scopes` | list of objects | | | | Scopes in this bucket. | +| `backupSchedule` | object | | | | Bucket backups (CAP-058): a weekly full backup starts a cycle, followed by incremental backups. Not available on the free tier. | +| `backupSchedule.dayOfWeek` | string: `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday` | `sunday` | updatable | weeklySchedule.dayOfWeek (Backup Schedule (Bucket)) | Day of the weekly full backup. | +| `backupSchedule.startAt` | integer: `0`, `1`, `2`, `3` … (24 values: `capellactl help config projects.clusters.buckets.backupSchedule.startAt`) | `0` | updatable | weeklySchedule.startAt (Backup Schedule (Bucket)) | Hour (UTC, 0–23) the backups start. | +| `backupSchedule.incrementalEvery` | integer: `1`, `2`, `4`, `6`, `8`, `12`, `24` | `24` | updatable | weeklySchedule.incrementalEvery (Backup Schedule (Bucket)) | Hours between incremental backups. | +| `backupSchedule.retentionTime` | string: `30days`, `60days`, `90days`, `180days`, `1year`, `2years`, `3years`, `4years`, `5years` | `30days` | updatable | weeklySchedule.retentionTime (Backup Schedule (Bucket)) | How long backups are kept. | +| `backupSchedule.costOptimizedRetention` | boolean | `false` | updatable | weeklySchedule.costOptimizedRetention (Backup Schedule (Bucket)) | Capella's cost-optimized retention. | +| `backupSchedule.ensure` | string: `present`, `absent` | `present` | capellactl only | | present (default) or absent to delete the schedule. | ## projects.clusters.buckets.scopes diff --git a/docs/release-1.0.0/00-architecture.md b/docs/release-1.0.0/00-architecture.md index a86ca35..756dd35 100644 --- a/docs/release-1.0.0/00-architecture.md +++ b/docs/release-1.0.0/00-architecture.md @@ -200,8 +200,13 @@ Resources cluster group list | add | remove NAME (service groups, CAP-052) cluster power NAME --on [--with-app-service] | --off cluster schedule show|apply|pause|resume|delete NAME + cluster disk-types [--provider P] (CAP-060) + cluster backup list|create|retain|delete|restore|restores|regions (cloud snapshots, CAP-057) + cluster backup schedule show|options|set|delete NAME (CAP-056) cidr list | create | delete bucket list | get | create | update | delete + bucket backup list|get|create|delete|restore|export (CAP-059) + bucket backup schedule show|set|delete · backup cycle list|get (CAP-058) scope list | create | delete collection list | create | update | delete credential list | get | create | update [--rotate-password] | delete @@ -458,6 +463,6 @@ Every exporter in `importer/` is covered by the **round-trip AC** (CAP-036 AC2): ## 14. Release scope -**In 1.0.0:** `help` with topics and per-field config help; shell completion; profiles with keychain-stored API keys; `init` wizard; `import` of existing projects and clusters; projects; provisioned clusters (create, edit, protect, delete); scale out, in and vertically; power off/on; hibernation (on/off) schedules with pause/resume; cluster allowed CIDRs; buckets, scopes, collections; basic database credentials; full-environment apply/destroy; drift. +**In 1.0.0:** `help` with topics and per-field config help; shell completion; profiles with keychain-stored API keys; `init` wizard; `import` of existing projects and clusters; projects; provisioned clusters (create, edit, protect, delete); scale out, in and vertically; power off/on; free-tier clusters; disk type choice; hibernation (on/off) schedules with pause/resume; cluster and bucket backup schedules, backups and restore; cluster allowed CIDRs; buckets, scopes, collections; basic database credentials; full-environment apply/destroy; drift. -**Not in 1.0.0 (candidates for 1.1):** free-tier clusters; advanced credentials (`userRoles`) and custom database roles; App Services and App Endpoints; private endpoints and network peering; backup schedules; audit logging; CMEK key management; organization users and API keys; server version upgrades; sample buckets. +**Not in 1.0.0 (candidates for 1.1):** advanced credentials (`userRoles`) and custom database roles; App Services and App Endpoints; private endpoints and network peering; cloning a cluster from a backup; Operational Insights clusters; audit logging; CMEK key management; organization users and API keys; server version upgrades; sample buckets. diff --git a/docs/release-1.0.0/API-COVERAGE.md b/docs/release-1.0.0/API-COVERAGE.md index 3eb0f98..eb325b5 100644 --- a/docs/release-1.0.0/API-COVERAGE.md +++ b/docs/release-1.0.0/API-COVERAGE.md @@ -1,13 +1,13 @@ # Management API coverage — operational database -> Generated by `scripts/update-api-coverage.py` from the Capella Management API v4.0 spec ([source](https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml), snapshot of 2026-10-06 in [docs/api](../api/management-api-v4.0-operations.json)), the command dictionary in that script and the **Status** of each story. Every in-scope operation in the spec appears exactly once. Don't edit by hand: change a story's status and run `make coverage`. CI fails when this file is stale. Story progress: [README.md](README.md). +> Generated by `scripts/update-api-coverage.py` from the Capella Management API v4.0 spec ([source](https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml), snapshot of 2026-10-29 in [docs/api](../api/management-api-v4.0-operations.json)), the command dictionary in that script and the **Status** of each story. Every in-scope operation in the spec appears exactly once. Don't edit by hand: change a story's status and run `make coverage`. CI fails when this file is stale. Story progress: [README.md](README.md). ## Scope -The spec has **285 operations**. Release 1.0.0 is about the operational database, so this report covers the **185 operational-database operations**. Two groups are left out: +The spec has **343 operations**. Release 1.0.0 is about the operational database, so this report covers the **220 operational-database operations**. Two groups are left out: - **73 App Services / App Endpoints operations**, including the 4 free-tier App Service operations. They are tracked in [release 2.0.0: App Services API coverage](../release-2.0.0/API-COVERAGE-APP-SERVICES.md). -- **27 AI operations** (AI Data Plane, AI Workflows, Models). Not planned for now. +- **50 AI operations** (AI Data Plane, AI Workflows, Models, AI Agent Memory, MCP servers and tools). Not planned for now. **Status legend** @@ -25,16 +25,16 @@ The spec has **285 operations**. Release 1.0.0 is about the operational database | | Operations | ✅ Done | 🟡 Partial | ⬜ Not built | ➖ Not needed | ⏭️ Not in 1.0 | Coverage (done / total) | |---|---|---|---|---|---|---|---| -| **Planned for 1.0.0** | **56** | 55 | 0 | 0 | 1 | — | **100%** (done / needed) | -| All operational-database operations | 185 | 55 | 0 | 0 | 1 | 129 | 30% | +| **Planned for 1.0.0** | **81** | 80 | 0 | 0 | 1 | — | **100%** (done / needed) | +| All operational-database operations | 220 | 80 | 0 | 0 | 1 | 139 | 36% | ### Commands | | Commands | ✅ Done | 🟡 Partial | ⬜ Not built (stub) | ⏭️ Not in 1.0 (proposed) | Coverage (done / total) | |---|---|---|---|---|---|---| -| **Resource commands planned for 1.0.0** | **45** | 45 | 0 | 0 | — | **100%** | -| Workflow commands (`plan`, `apply`, …) | 11 | 11 | 0 | 0 | — | 100% | -| All resource commands, including proposed ones | 160 | 45 | 0 | 0 | 115 | 28% | +| **Resource commands planned for 1.0.0** | **67** | 67 | 0 | 0 | — | **100%** | +| Workflow commands (`plan`, `apply`, …) | 12 | 12 | 0 | 0 | — | 100% | +| All resource commands, including proposed ones | 192 | 67 | 0 | 0 | 125 | 35% | *Each verb counts as one command (for example `cluster schedule pause`). Each command maps to one or more API operations. For example, `bucket create` calls both the provisioned and the free-tier create.* @@ -56,10 +56,10 @@ The spec has **285 operations**. Release 1.0.0 is about the operational database | [Eventing functions](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Eventing-Functions) | No | 9 | | | | | 9 | | [Data API](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Data-API) | No | 6 | | | | | 6 | | [Replications (XDCR)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Replications) | No | 9 | | | | | 9 | -| [Backups & restore (bucket)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backups-and-Restore-(Bucket)) | No | 5 | | | | | 5 | -| [Backup schedule (bucket)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backup-Schedule-(Bucket)) | No | 6 | | | | | 6 | -| [Cloud snapshot backups & restore](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-and-Restore) | No | 9 | | | | | 9 | -| [Cloud snapshot backup schedule](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-Schedule) | No | 3 | | | | | 3 | +| [Backups & restore (bucket)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backups-and-Restore-(Bucket)) | Yes | 7 | 7 | | | | | +| [Backup schedule (bucket)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backup-Schedule-(Bucket)) | Yes | 6 | 6 | | | | | +| [Cloud snapshot backups & restore (cluster)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-and-Restore) | Yes | 9 | 8 | | | | 1 | +| [Cloud snapshot backup schedule (cluster)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-Schedule) | Yes | 4 | 4 | | | | | | [Network peers](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Network-Peers) | No | 5 | | | | | 5 | | [Private endpoint service](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Private-Endpoint-Service) | No | 8 | | | | | 8 | | [Certificates](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Certificates) | No | 1 | | | | | 1 | @@ -70,7 +70,13 @@ The spec has **285 operations**. Release 1.0.0 is about the operational database | [Organization users](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Users) | No | 5 | | | | | 5 | | [API keys](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Api-Keys) | No | 5 | | | | | 5 | | [Billing](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Billing) | No | 6 | | | | | 6 | -| **Total** | | **185** | **55** | **0** | **0** | **1** | **129** | +| [Operational Insights clusters](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Clusters) | No | 8 | | | | | 8 | +| [Operational Insights on/off schedule](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-On-Off-Schedule) | No | 4 | | | | | 4 | +| [Allowed CIDRs (Operational Insights)](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Allowed-CIDRs-(Operational-Insights-Cluster)) | No | 4 | | | | | 4 | +| [Operational Insights snapshot backups & restore](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Cloud-Snapshot-Backups-and-Restore) | No | 6 | | | | | 6 | +| [Operational Insights snapshot backup schedule](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Cloud-Snapshot-Backups-Schedule) | No | 3 | | | | | 3 | +| [Operational Insights private endpoints](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Private-Endpoint-Service) | No | 7 | | | | | 7 | +| **Total** | | **220** | **80** | **0** | **0** | **1** | **139** | ## What is left for 1.0.0 @@ -81,8 +87,7 @@ The spec has **285 operations**. Release 1.0.0 is about the operational database ### Gaps found -1. **Scope note out of date.** [00-architecture.md §14](00-architecture.md) still lists *free-tier clusters* under "Not in 1.0.0", but CAP-041 is Implemented. Update that line. -2. **`GET …/allowedcidrs/{id}`** is wired in `capellactl/api/endpoints.py` but never called. Keep it for symmetry or drop it from `INVENTORY`. +1. **`GET …/allowedcidrs/{id}`** is wired in `capellactl/api/endpoints.py` but never called. Keep it for symmetry or drop it from `INVENTORY`. ## Command dictionary @@ -100,11 +105,11 @@ Every resource command: built, stubbed, or proposed, with the API operations it | `capellactl cluster options` | ✅ Done | CAP-041 | | | | `capellactl cluster list` | ✅ Done | CAP-038 | `GET {org}/projects/{p}/clusters` | | | `capellactl cluster get` | ✅ Done | CAP-038, CAP-041 | `GET {org}/projects/{p}/clusters/freeTier/{c}`
`GET {org}/projects/{p}/clusters/{c}` | | -| `capellactl cluster create` | ✅ Done | CAP-011, CAP-038, CAP-041, CAP-051, CAP-053 | `POST {org}/projects/{p}/clusters/freeTier`
`POST {org}/projects/{p}/clusters` | | -| `capellactl cluster update` | ✅ Done | CAP-012, CAP-017, CAP-053 | `PUT {org}/projects/{p}/clusters/freeTier/{c}`
`PUT {org}/projects/{p}/clusters/{c}` | | +| `capellactl cluster create` | ✅ Done | CAP-011, CAP-038, CAP-041, CAP-051, CAP-053, CAP-060 | `POST {org}/projects/{p}/clusters/freeTier`
`POST {org}/projects/{p}/clusters` | | +| `capellactl cluster update` | ✅ Done | CAP-012, CAP-017, CAP-053, CAP-060 | `PUT {org}/projects/{p}/clusters/freeTier/{c}`
`PUT {org}/projects/{p}/clusters/{c}` | | | `capellactl cluster scale` | ✅ Done | CAP-015, CAP-016, CAP-018 | `PUT {org}/projects/{p}/clusters/{c}` | | | `capellactl cluster group list` | ✅ Done | CAP-052 | `GET {org}/projects/{p}/clusters/{c}` | | -| `capellactl cluster group add` | ✅ Done | CAP-052 | `PUT {org}/projects/{p}/clusters/{c}` | | +| `capellactl cluster group add` | ✅ Done | CAP-052, CAP-060 | `PUT {org}/projects/{p}/clusters/{c}` | | | `capellactl cluster group remove` | ✅ Done | CAP-052 | `PUT {org}/projects/{p}/clusters/{c}` | | | `capellactl cluster clone` | ✅ Done | CAP-049 | `POST {org}/projects/{p}/clusters` | | | `capellactl cluster delete` | ✅ Done | CAP-014, CAP-041 | `DELETE {org}/projects/{p}/clusters/freeTier/{c}`
`DELETE {org}/projects/{p}/clusters/{c}` | | @@ -137,6 +142,28 @@ Every resource command: built, stubbed, or proposed, with the API operations it | `capellactl credential create` | ✅ Done | CAP-027, CAP-046 | `POST {org}/projects/{p}/clusters/{c}/users` | | | `capellactl credential update` | ✅ Done | CAP-027, CAP-028, CAP-046 | `PUT {org}/projects/{p}/clusters/{c}/users/{userId}` | | | `capellactl credential delete` | ✅ Done | CAP-029 | `DELETE {org}/projects/{p}/clusters/{c}/users/{userId}` | | +| `capellactl cluster backup schedule show` | ✅ Done | CAP-056 | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | | +| `capellactl cluster backup schedule options` | ✅ Done | CAP-056 | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/regions`
`GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule/options` | | +| `capellactl cluster backup schedule set` | ✅ Done | CAP-056 | `PUT {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule`
`GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule/options` | | +| `capellactl cluster backup schedule delete` | ✅ Done | CAP-056 | `DELETE {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | | +| `capellactl cluster backup list` | ✅ Done | CAP-057 | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups`
`GET {org}/projects/{p}/cloudsnapshotbackups` | | +| `capellactl cluster backup create` | ✅ Done | CAP-057 | `POST {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | | +| `capellactl cluster backup retain` | ✅ Done | CAP-057 | `PUT {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | | +| `capellactl cluster backup delete` | ✅ Done | CAP-057 | `DELETE {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | | +| `capellactl cluster backup restore` | ✅ Done | CAP-057 | `POST {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}/restore` | | +| `capellactl cluster backup restores` | ✅ Done | CAP-057 | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/restores` | | +| `capellactl cluster backup regions` | ✅ Done | CAP-057 | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/regions` | | +| `capellactl bucket backup schedule show` | ✅ Done | CAP-058 | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | | +| `capellactl bucket backup schedule set` | ✅ Done | CAP-058 | `POST {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules`
`PUT {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | | +| `capellactl bucket backup schedule delete` | ✅ Done | CAP-058 | `DELETE {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | | +| `capellactl bucket backup cycle list` | ✅ Done | CAP-058 | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles` | | +| `capellactl bucket backup cycle get` | ✅ Done | CAP-058 | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles/{cycleId}` | | +| `capellactl bucket backup list` | ✅ Done | CAP-059 | `GET {org}/projects/{p}/clusters/{c}/backups` | | +| `capellactl bucket backup get` | ✅ Done | CAP-059 | `GET {org}/projects/{p}/clusters/{c}/backups/{backupId}` | | +| `capellactl bucket backup create` | ✅ Done | CAP-059 | `POST {org}/projects/{p}/clusters/{c}/buckets/{b}/backups` | | +| `capellactl bucket backup delete` | ✅ Done | CAP-059 | `DELETE {org}/projects/{p}/clusters/{c}/backups/{backupId}` | | +| `capellactl bucket backup restore` | ✅ Done | CAP-059 | `POST {org}/projects/{p}/clusters/{c}/backups/{backupId}/restore` | | +| `capellactl bucket backup export` | ✅ Done | CAP-059 | `POST {org}/projects/{p}/clusters/{c}/buckets/{b}/backups/{backupId}/export`
`GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backups/{backupId}/exports/{exportId}` | | ### Workflow commands (not one-to-one with an API operation) @@ -153,10 +180,11 @@ Every resource command: built, stubbed, or proposed, with the API operations it | `capellactl destroy` | ✅ Done | CAP-030 | Deletes every declared resource in reverse order; capella.yaml is kept. | | `capellactl drift` | ✅ Done | CAP-031 | Read-only: changed, missing and unmanaged resources; `--detailed-exitcode`. | | `capellactl help / completion` | ✅ Done | CAP-034 | No API calls. | +| `capellactl cluster disk-types` | ✅ Done | CAP-060 | No API calls: disk types, sizes and IOPS from `config/disks.yaml` (Capella's documentation; the spec has only the type names). | ### Proposed commands outside 1.0.0 -
115 proposed commands +
125 proposed commands | Command | API operations | |---|---| @@ -201,27 +229,7 @@ Every resource command: built, stubbed, or proposed, with the API operations it | `capellactl sample-bucket list` | `GET {org}/projects/{p}/clusters/{c}/sampleBuckets` | | `capellactl sample-bucket get` | `GET {org}/projects/{p}/clusters/{c}/sampleBuckets/{bucketId}` | | `capellactl sample-bucket delete` | `DELETE {org}/projects/{p}/clusters/{c}/sampleBuckets/{bucketId}` | -| `capellactl backup create` | `POST {org}/projects/{p}/clusters/{c}/buckets/{b}/backups` | -| `capellactl backup list` | `GET {org}/projects/{p}/clusters/{c}/backups` | -| `capellactl backup get` | `GET {org}/projects/{p}/clusters/{c}/backups/{backupId}` | -| `capellactl backup delete` | `DELETE {org}/projects/{p}/clusters/{c}/backups/{backupId}` | -| `capellactl backup restore` | `POST {org}/projects/{p}/clusters/{c}/backups/{backupId}/restore` | -| `capellactl backup schedule show` | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | -| `capellactl backup schedule set` | `POST {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules`
`PUT {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | -| `capellactl backup schedule delete` | `DELETE {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | -| `capellactl backup cycle list` | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles` | -| `capellactl backup cycle get` | `GET {org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles/{cycleId}` | -| `capellactl snapshot create` | `POST {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | -| `capellactl snapshot list` | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups`
`GET {org}/projects/{p}/cloudsnapshotbackups` | -| `capellactl snapshot restores` | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/restores` | -| `capellactl snapshot regions` | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/regions` | -| `capellactl snapshot update` | `PUT {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | -| `capellactl snapshot delete` | `DELETE {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | -| `capellactl snapshot restore` | `POST {org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}/restore` | -| `capellactl snapshot clone` | `POST {org}/projects/{p}/cloudsnapshotbackups/{backupId}/clone` | -| `capellactl snapshot schedule show` | `GET {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | -| `capellactl snapshot schedule set` | `PUT {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | -| `capellactl snapshot schedule delete` | `DELETE {org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | +| `capellactl cluster backup clone` | `POST {org}/projects/{p}/cloudsnapshotbackups/{backupId}/clone` | | `capellactl peer create` | `POST {org}/projects/{p}/clusters/{c}/networkPeers` | | `capellactl peer list` | `GET {org}/projects/{p}/clusters/{c}/networkPeers` | | `capellactl peer get` | `GET {org}/projects/{p}/clusters/{c}/networkPeers/{peerId}` | @@ -275,6 +283,36 @@ Every resource command: built, stubbed, or proposed, with the API operations it | `capellactl eventing code set` | `PUT {org}/projects/{p}/clusters/{c}/eventingFunctions/{functionName}/code` | | `capellactl eventing state` | `PUT {org}/projects/{p}/clusters/{c}/eventingFunctions/{functionName}/activationState` | | `capellactl eventing logs` | `GET {org}/projects/{p}/clusters/{c}/eventingFunctions/{functionName}/logs` | +| `capellactl insights list` | `GET {org}/projects/{p}/operationalInsightsClusters`
`GET {org}/operationalInsightsClusters` | +| `capellactl insights get` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | +| `capellactl insights create` | `POST {org}/projects/{p}/operationalInsightsClusters` | +| `capellactl insights update` | `PUT {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | +| `capellactl insights delete` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | +| `capellactl insights power on` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/activationState` | +| `capellactl insights power off` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/activationState` | +| `capellactl insights schedule show` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | +| `capellactl insights schedule set` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule`
`PUT {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | +| `capellactl insights schedule delete` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | +| `capellactl insights cidr list` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs` | +| `capellactl insights cidr get` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}` | +| `capellactl insights cidr create` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs` | +| `capellactl insights cidr delete` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}` | +| `capellactl insights backup list` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups` | +| `capellactl insights backup create` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups` | +| `capellactl insights backup retain` | `PUT {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}` | +| `capellactl insights backup delete` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}` | +| `capellactl insights backup restore` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}/restore` | +| `capellactl insights backup restores` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/restores` | +| `capellactl insights backup schedule show` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | +| `capellactl insights backup schedule set` | `PUT {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | +| `capellactl insights backup schedule delete` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | +| `capellactl insights private-endpoint show` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | +| `capellactl insights private-endpoint enable` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | +| `capellactl insights private-endpoint disable` | `DELETE {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | +| `capellactl insights private-endpoint command` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpointCommand` | +| `capellactl insights private-endpoint list` | `GET {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints` | +| `capellactl insights private-endpoint accept` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/associate` | +| `capellactl insights private-endpoint reject` | `POST {org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/unassociate` |
@@ -477,56 +515,59 @@ Paths are abbreviated: `{org}` = `/v4/organizations/{organizationId}`, `{p}` = p | Pause a replication | `DELETE` | `{org}/projects/{p}/clusters/{c}/replications/{replicationId}/activationStatus` | `replication pause` | ⏭️ Not in 1.0 | | | Resume a replication | `POST` | `{org}/projects/{p}/clusters/{c}/replications/{replicationId}/activationStatus` | `replication resume` | ⏭️ Not in 1.0 | | -### Backups & restore (bucket) — 5 ops (5 ⏭️) +### Backups & restore (bucket) — 7 ops (7 ✅) -[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backups-and-Restore-(Bucket)) · out of 1.0.0 scope +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backups-and-Restore-(Bucket)) | Operation | Method | Path | capellactl command | Status | Notes / what's left | |---|---|---|---|---|---| -| Create Backup | `POST` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backups` | `backup create` | ⏭️ Not in 1.0 | | -| List Cluster Backups | `GET` | `{org}/projects/{p}/clusters/{c}/backups` | `backup list` | ⏭️ Not in 1.0 | | -| Get Backup | `GET` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}` | `backup get` | ⏭️ Not in 1.0 | | -| Delete Backup Cycle | `DELETE` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}` | `backup delete` | ⏭️ Not in 1.0 | | -| Restore Backup | `POST` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}/restore` | `backup restore` | ⏭️ Not in 1.0 | | +| Create Backup | `POST` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backups` | `bucket backup create` | ✅ Done | | +| Create Backup Export | `POST` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backups/{backupId}/export` | `bucket backup export` | ✅ Done | Packages a backup cycle as a zip. | +| Get Backup Export | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backups/{backupId}/exports/{exportId}` | `bucket backup export` | ✅ Done | Polled for the download URL. | +| List Cluster Backups | `GET` | `{org}/projects/{p}/clusters/{c}/backups` | `bucket backup list` | ✅ Done | | +| Get Backup | `GET` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}` | `bucket backup get` | ✅ Done | | +| Delete Backup Cycle | `DELETE` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}` | `bucket backup delete` | ✅ Done | Deletes the whole backup cycle. | +| Restore Backup | `POST` | `{org}/projects/{p}/clusters/{c}/backups/{backupId}/restore` | `bucket backup restore` | ✅ Done | Same or another cluster; partial restores. | -### Backup schedule (bucket) — 6 ops (6 ⏭️) +### Backup schedule (bucket) — 6 ops (6 ✅) -[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backup-Schedule-(Bucket)) · out of 1.0.0 scope +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Backup-Schedule-(Bucket)) | Operation | Method | Path | capellactl command | Status | Notes / what's left | |---|---|---|---|---|---| -| Create Backup Schedule | `POST` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `backup schedule set` | ⏭️ Not in 1.0 | | -| Get Backup Schedule | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `backup schedule show` | ⏭️ Not in 1.0 | | -| Update Backup Schedule | `PUT` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `backup schedule set` | ⏭️ Not in 1.0 | | -| Delete Backup Schedule | `DELETE` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `backup schedule delete` | ⏭️ Not in 1.0 | | -| List Cycles | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles` | `backup cycle list` | ⏭️ Not in 1.0 | | -| List Backups | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles/{cycleId}` | `backup cycle get` | ⏭️ Not in 1.0 | | +| Create Backup Schedule | `POST` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `bucket backup schedule set` | ✅ Done | | +| Get Backup Schedule | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `bucket backup schedule show` | ✅ Done | Also read by plan, sync and drift. | +| Update Backup Schedule | `PUT` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `bucket backup schedule set` | ✅ Done | | +| Delete Backup Schedule | `DELETE` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/schedules` | `bucket backup schedule delete` | ✅ Done | | +| List Cycles | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles` | `bucket backup cycle list` | ✅ Done | | +| List Backups | `GET` | `{org}/projects/{p}/clusters/{c}/buckets/{b}/backup/cycles/{cycleId}` | `bucket backup cycle get` | ✅ Done | | -### Cloud snapshot backups & restore — 9 ops (9 ⏭️) +### Cloud snapshot backups & restore (cluster) — 9 ops (8 ✅, 1 ⏭️) -[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-and-Restore) · out of 1.0.0 scope +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-and-Restore) | Operation | Method | Path | capellactl command | Status | Notes / what's left | |---|---|---|---|---|---| -| Create Cloud Snapshot Backup | `POST` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | `snapshot create` | ⏭️ Not in 1.0 | | -| List Cloud Snapshot Backups | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | `snapshot list` | ⏭️ Not in 1.0 | | -| List Cloud Snapshot Restores | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/restores` | `snapshot restores` | ⏭️ Not in 1.0 | | -| List Available Geographic Regions for Cross-region Operations | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/regions` | `snapshot regions` | ⏭️ Not in 1.0 | | -| Edit Backup Retention | `PUT` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | `snapshot update` | ⏭️ Not in 1.0 | Changes retention. | -| Delete Backup | `DELETE` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | `snapshot delete` | ⏭️ Not in 1.0 | | -| Restore Backup | `POST` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}/restore` | `snapshot restore` | ⏭️ Not in 1.0 | | -| List Cloud Snapshot Backups at the Project Level | `GET` | `{org}/projects/{p}/cloudsnapshotbackups` | `snapshot list` | ⏭️ Not in 1.0 | Project-wide listing (`--project`). | -| Clone Cluster Backup | `POST` | `{org}/projects/{p}/cloudsnapshotbackups/{backupId}/clone` | `snapshot clone` | ⏭️ Not in 1.0 | | +| Create Cloud Snapshot Backup | `POST` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | `cluster backup create` | ✅ Done | | +| List Cloud Snapshot Backups | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups` | `cluster backup list` | ✅ Done | | +| List Cloud Snapshot Restores | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/restores` | `cluster backup restores` | ✅ Done | | +| List Available Geographic Regions for Cross-region Operations | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/regions` | `cluster backup regions` / `cluster backup schedule options` | ✅ Done | Regions a copy can go to. | +| Edit Backup Retention | `PUT` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | `cluster backup retain` | ✅ Done | Changes retention. | +| Delete Backup | `DELETE` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}` | `cluster backup delete` | ✅ Done | | +| Restore Backup | `POST` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackups/{backupId}/restore` | `cluster backup restore` | ✅ Done | | +| List Cloud Snapshot Backups at the Project Level | `GET` | `{org}/projects/{p}/cloudsnapshotbackups` | `cluster backup list` | ✅ Done | Project-wide listing (no cluster name). | +| Clone Cluster Backup | `POST` | `{org}/projects/{p}/cloudsnapshotbackups/{backupId}/clone` | `cluster backup clone` | ⏭️ Not in 1.0 | Creates a cluster; out of scope of CAP-057. | -### Cloud snapshot backup schedule — 3 ops (3 ⏭️) +### Cloud snapshot backup schedule (cluster) — 4 ops (4 ✅) -[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-Schedule) · out of 1.0.0 scope +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Cloud-Snapshot-Backups-Schedule) | Operation | Method | Path | capellactl command | Status | Notes / what's left | |---|---|---|---|---|---| -| Upsert Backup Schedule | `PUT` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `snapshot schedule set` | ⏭️ Not in 1.0 | | -| Get Backup Schedule | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `snapshot schedule show` | ⏭️ Not in 1.0 | | -| Delete Backup Schedule | `DELETE` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `snapshot schedule delete` | ⏭️ Not in 1.0 | | +| Upsert Backup Schedule | `PUT` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `cluster backup schedule set` | ✅ Done | | +| Get Backup Schedule | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `cluster backup schedule show` | ✅ Done | Also read by plan, sync and drift. | +| Delete Backup Schedule | `DELETE` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule` | `cluster backup schedule delete` | ✅ Done | | +| Get Backup Schedule Options | `GET` | `{org}/projects/{p}/clusters/{c}/cloudsnapshotbackupschedule/options` | `cluster backup schedule options` / `cluster backup schedule set` | ✅ Done | Permitted intervals and retention for the support plan; read before every set. | ### Network peers — 5 ops (5 ⏭️) @@ -663,3 +704,77 @@ Paths are abbreviated: `{org}` = `/v4/organizations/{organizationId}`, `{p}` = p | Download Categorized Billing | `POST` | `{org}/billing/download` | `billing download` | ⏭️ Not in 1.0 | | | Download Itemized Billing | `POST` | `{org}/projects/{p}/clusters/{c}/billing/download` | `billing download` | ⏭️ Not in 1.0 | Itemized for one cluster (`--cluster`). | +### Operational Insights clusters — 8 ops (8 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Clusters) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Create New Operational Insights Cluster | `POST` | `{org}/projects/{p}/operationalInsightsClusters` | `insights create` | ⏭️ Not in 1.0 | | +| List Project Level Operational Insights Clusters | `GET` | `{org}/projects/{p}/operationalInsightsClusters` | `insights list` | ⏭️ Not in 1.0 | | +| List Organization Level Operational Insights Clusters | `GET` | `{org}/operationalInsightsClusters` | `insights list` | ⏭️ Not in 1.0 | Organization level. | +| Get Operational Insights Cluster Details | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | `insights get` | ⏭️ Not in 1.0 | | +| Update an Operational Insights Cluster | `PUT` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | `insights update` | ⏭️ Not in 1.0 | | +| Delete Operational Insights Cluster | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}` | `insights delete` | ⏭️ Not in 1.0 | | +| Turn On Operational Insights Cluster | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/activationState` | `insights power on` | ⏭️ Not in 1.0 | | +| Turn Off Operational Insights Cluster | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/activationState` | `insights power off` | ⏭️ Not in 1.0 | | + +### Operational Insights on/off schedule — 4 ops (4 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-On-Off-Schedule) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Create Operational Insights Cluster On/Off Schedule | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | `insights schedule set` | ⏭️ Not in 1.0 | | +| Get Operational Insights Cluster On/Off Schedule | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | `insights schedule show` | ⏭️ Not in 1.0 | | +| Update Operational Insights Cluster On/Off Schedule | `PUT` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | `insights schedule set` | ⏭️ Not in 1.0 | | +| Delete Operational Insights Cluster On/Off Schedule | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/onOffSchedule` | `insights schedule delete` | ⏭️ Not in 1.0 | | + +### Allowed CIDRs (Operational Insights) — 4 ops (4 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Allowed-CIDRs-(Operational-Insights-Cluster)) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Create Allowed CIDR | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs` | `insights cidr create` | ⏭️ Not in 1.0 | | +| List Allowed CIDRs | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs` | `insights cidr list` | ⏭️ Not in 1.0 | | +| Get Allowed CIDR | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}` | `insights cidr get` | ⏭️ Not in 1.0 | | +| Delete Allowed CIDR | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/allowedcidrs/{allowedCidrId}` | `insights cidr delete` | ⏭️ Not in 1.0 | | + +### Operational Insights snapshot backups & restore — 6 ops (6 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Cloud-Snapshot-Backups-and-Restore) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Create Cloud Snapshot Backup | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups` | `insights backup create` | ⏭️ Not in 1.0 | | +| List Cloud Snapshot Backups | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups` | `insights backup list` | ⏭️ Not in 1.0 | | +| Update Backup Retention | `PUT` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}` | `insights backup retain` | ⏭️ Not in 1.0 | | +| Delete Backup | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}` | `insights backup delete` | ⏭️ Not in 1.0 | | +| Restore Backup | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/{backupId}/restore` | `insights backup restore` | ⏭️ Not in 1.0 | | +| List Cloud Snapshot Restores | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackups/restores` | `insights backup restores` | ⏭️ Not in 1.0 | | + +### Operational Insights snapshot backup schedule — 3 ops (3 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Cloud-Snapshot-Backups-Schedule) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Upsert Backup Schedule | `PUT` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | `insights backup schedule set` | ⏭️ Not in 1.0 | | +| Get Backup Schedule | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | `insights backup schedule show` | ⏭️ Not in 1.0 | | +| Delete Backup Schedule | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/cloudSnapshotBackupSchedule` | `insights backup schedule delete` | ⏭️ Not in 1.0 | | + +### Operational Insights private endpoints — 7 ops (7 ⏭️) + +[API reference](https://docs.couchbase.com/cloud/management-api-reference/index.html#tag/Operational-Insights-Private-Endpoint-Service) · out of 1.0.0 scope + +| Operation | Method | Path | capellactl command | Status | Notes / what's left | +|---|---|---|---|---|---| +| Get Operational Insights Private Endpoint Service Status | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | `insights private-endpoint show` | ⏭️ Not in 1.0 | | +| Enable Operational Insights Private Endpoint Service | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | `insights private-endpoint enable` | ⏭️ Not in 1.0 | | +| Disable Operational Insights Private Endpoint Service. | `DELETE` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService` | `insights private-endpoint disable` | ⏭️ Not in 1.0 | | +| Get Operational Insights Private Endpoint CLI Command Required to Setup Private Endpoint for Specific CSP | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpointCommand` | `insights private-endpoint command` | ⏭️ Not in 1.0 | | +| List Operational Insights Private Endpoint Connections | `GET` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints` | `insights private-endpoint list` | ⏭️ Not in 1.0 | | +| Accept or Associate Operational Insights Private Endpoint Request | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/associate` | `insights private-endpoint accept` | ⏭️ Not in 1.0 | | +| Reject or Disassociate Operational Insights Private Endpoint Connection. | `POST` | `{org}/projects/{p}/operationalInsightsClusters/{analyticsClusterId}/privateEndpointService/endpoints/{endpointId}/unassociate` | `insights private-endpoint reject` | ⏭️ Not in 1.0 | | + diff --git a/docs/release-1.0.0/CAP-055-windows-install-steps.md b/docs/release-1.0.0/CAP-055-windows-install-steps.md new file mode 100644 index 0000000..8f2837c --- /dev/null +++ b/docs/release-1.0.0/CAP-055-windows-install-steps.md @@ -0,0 +1,224 @@ +# CAP-055 — Install and run capellactl on Windows: step-by-step PowerShell table in the README + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E9 Lifecycle & release | P0 | 3 | CAP-033, CAP-045 | In progress | + +## User story +As a **customer operator on a Windows laptop**, I want the README's Install section to give me the exact PowerShell +command for each step, with what I should see and what to do when it fails, so that I can install capellactl and +run the quick start without translating macOS/Linux commands myself. + +## Context & discovery +- Found 2026-10-28 with a client: they could not get from the README to a working `capellactl --version` on Windows. + The Install section is written for macOS/Linux. Windows gets one sentence ("activate with + `.venv\Scripts\activate` instead"), and nothing after Install mentions Windows at all. +- Windows is supported. CI runs the test suite on `windows-latest`, CAP-045 ships a Windows `.exe`, and the keychain, + profile folder and console output already have Windows code paths. The code works on Windows; the docs do not + explain how to use it there. +- The release checklist's "time to first environment under 60 minutes" check (CAP-033 AC5) is run on a Mac only. + On Windows it would fail today. That makes this story P0. + +### Where a Windows user needs help, step by step +Each step of the README and the getting-started guide, run as written in **Windows PowerShell 5.1**, which is the +default shell on Windows 10/11 and Server: + +| Step | As written today | What happens on Windows | Help needed? | +|---|---|---|---| +| Install Python | "`brew install python@3.12`, or from python.org" | python.org's main download button gives the newest Python, 3.14 or later. `pyproject.toml` allows only `>=3.10,<3.14`, so pip later fails with `requires a different Python`, which does not say what to do. If "Add python.exe to PATH" was not ticked, no `python` command exists | **Yes**: pin 3.12 (`winget install --id Python.Python.3.12 -e`) and use the `py` launcher | +| Check Python | `python3 --version` | `python3` and `python` open the **Microsoft Store** (App execution aliases), or print nothing | **Yes**: `py -3.12 --version` | +| Install Git | link to git-scm.com | Usually missing on customer laptops | **Yes**: `winget install --id Git.Git -e`, then open a new PowerShell window | +| Clone | `git clone … && cd capellactl` | Windows PowerShell 5.1 rejects `&&` (`The token '&&' is not a valid statement separator`) | **Yes**: two lines | +| Create the environment | `python3.12 -m venv .venv` | `python3.12` does not exist on Windows | **Yes**: `py -3.12 -m venv .venv` | +| Install | `.venv/bin/pip install -e .` | Windows has no `.venv/bin`; the folder is `.venv\Scripts` | **Yes**: `.venv\Scripts\python -m pip install -e .` | +| Activate | `source .venv/bin/activate` (Windows: `.venv\Scripts\activate`) | `source` does not exist. `Activate.ps1` is blocked by the default execution policy (`running scripts is disabled on this system`). On a managed laptop, group policy may block it permanently | **Yes**: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once, or skip activation and run `.venv\Scripts\capellactl.exe` | +| Folder for the environment | `mkdir ~/capella-env && cd ~/capella-env` | `&&` again | **Yes**: `mkdir ~\capella-env; cd ~\capella-env` | +| Find the public IP for the API key | `curl -s https://checkip.amazonaws.com` | In 5.1, `curl` is an alias for `Invoke-WebRequest`, and `-s` is ambiguous, so the command fails | **Yes**: `curl.exe -s https://checkip.amazonaws.com` | +| `configure` (paste the token) | hidden prompt, or `pbpaste \| capellactl configure --api-key-stdin` | Pasting into a hidden prompt shows nothing, so users think it failed. `pbpaste` is macOS only. PowerShell pipes text to programs with a trailing CRLF | **Yes**: right-click or Ctrl+V pastes even though nothing shows; `Get-Clipboard \| capellactl configure --api-key-stdin`. The CRLF must be stripped (AC4) | +| Passwords | `export ORDERS_APP_PW='…'` | `export` does not exist | **Yes**: `$env:ORDERS_APP_PW = '…'` (`set` in cmd.exe) | +| Long commands | lines ending in `\` (getting-started, runbook) | In PowerShell, `\` does not continue a line, so the rest of the command is lost | **Yes**: a trailing backtick `` ` ``, or one line | +| CI | `capellactl plan --detailed-exitcode \|\| [ $? -eq 2 ]` (`help ci`) | bash only | **Yes**, one row: `$LASTEXITCODE` | +| Standalone `.exe` (after 1.0.0) | `downloads/README.md`: hash, `Unblock-File`, run `.\capellactl-…exe` | The checksum and unblock steps are covered. Nothing explains how to type `capellactl` from any folder, so every command in the docs fails unless the user keeps the long file name and `.\` | **Yes**: rename it to `capellactl.exe` and add its folder to the user PATH | +| Keychain | `configure` stores the token in the OS keychain | Windows Credential Manager, through keyring (CAP-045 AC5 tests it on Windows) | No; say where the token is stored | +| Profiles, credentials folder | `profiles.yaml`, `.capellactl/credentials/` | Profiles are in `%APPDATA%\capellactl`; the credentials folder is under the user's profile | No; say where they are | +| Output characters (✓, →, box lines) | — | `main.py` switches stdout to UTF-8 | No | +| Shell completion | `capellactl completion bash\|zsh\|fish` | No PowerShell completion (Click has none) | No; say it is not available | +| `capellactl` commands themselves | `--password-env`, `--password-file`, `-o json`, exit codes | Work the same | No | + +So help is needed in three places: +1. **Install**: every step from Python to `capellactl --version`. This is where the client got stuck, and it gets + the table the README asks for. +2. **The handful of shell differences** after install: env vars, line continuation, clipboard, IP check, `&&`, exit + codes in CI. One translation table covers every command in the docs, so the docs do not need a Windows copy. +3. **The `.exe` on PATH**, for 1.0.0 and later. + +## Implementation details + +### README: Install → "On Windows (PowerShell)" +A subsection of **Install**, after the macOS/Linux steps. The macOS/Linux steps stay as they are, apart from saying +they are for macOS and Linux. The table sits between `` and +`` so tests can find it. The **Run in PowerShell** column holds exactly the commands to +type, one backticked command per line: + +| Step | Run in PowerShell | You should see / if it fails | +|---|---|---| +| 1. Install Git and Python 3.12 | `winget install --id Git.Git -e`
`winget install --id Python.Python.3.12 -e` | Then **close and reopen PowerShell**. No `winget`: install Git from git-scm.com and Python **3.12** from python.org (not the newest version: capellactl needs 3.10–3.13), and tick *Add python.exe to PATH* | +| 2. Check Python | `py -3.12 --version` | `Python 3.12.x`. If `python` opens the Microsoft Store, ignore it and use `py` | +| 3. Download capellactl | `git clone https://github.com/couchbaselabs/capellactl.git`
`cd capellactl` | Run these as two commands: Windows PowerShell does not accept `&&` | +| 4. Create its environment | `py -3.12 -m venv .venv` | A `.venv` folder | +| 5. Install it | `.venv\Scripts\python -m pip install -e .` | `Successfully installed … capellactl-…`. `requires a different Python`: step 4 used a Python newer than 3.13 | +| 6. Allow activation (once per user) | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` | Answer `Y`. If your company blocks this, skip steps 6–7 and type `.venv\Scripts\capellactl.exe` wherever the docs say `capellactl` | +| 7. Activate (in each new window) | `.venv\Scripts\Activate.ps1` | The prompt starts with `(.venv)`. In cmd.exe: `.venv\Scripts\activate.bat` | +| 8. Check | `capellactl --version` | `capellactl 1.0.0.devN+g… (Management API v4.0)` | +| 9. Make a folder for your environment | `mkdir ~\capella-env`
`cd ~\capella-env` | capellactl writes `capella.yaml` here | + +Followed by one line on where things are kept (the API Key Token in **Windows Credential Manager** under +`capellactl`, profiles in `%APPDATA%\capellactl`), and a link to the translation table below. + +### getting-started: "Using the commands in PowerShell" +A short table in `docs/getting-started.md` under **Before you start**, linked from the README and from the top of +`docs/runbook.md`. It does not duplicate any command: + +| In the docs (macOS/Linux) | In PowerShell | +|---|---| +| `export ORDERS_APP_PASSWORD='…'` | `$env:ORDERS_APP_PASSWORD = '…'` | +| a line ending in `\` | end the line with a backtick `` ` `` instead, or join the lines | +| `a && b` | `a; b` (Windows PowerShell 5.1) | +| `curl -s https://checkip.amazonaws.com` | `curl.exe -s https://checkip.amazonaws.com` | +| `pbpaste \| capellactl configure --api-key-stdin` | `Get-Clipboard \| capellactl configure --api-key-stdin` | +| `~/capella-env` | `~\capella-env` | +| `capellactl plan --detailed-exitcode \|\| [ $? -eq 2 ]` | `capellactl plan --detailed-exitcode; if ($LASTEXITCODE -notin 0, 2) { exit $LASTEXITCODE }` | +| `capellactl completion zsh` | not available in PowerShell | + +The **When something goes wrong** section gets a Windows bullet: `capellactl` is not recognized (activate again in +the new window, or use `.venv\Scripts\capellactl.exe`), and the execution-policy message. + +### downloads/README.md (generated by `scripts/update-downloads.py`) +After the Windows unblock line, add how to install the `.exe` for the current user: + +```powershell +mkdir $env:LOCALAPPDATA\Programs\capellactl -Force +Move-Item .\capellactl-*-windows-x86_64.exe $env:LOCALAPPDATA\Programs\capellactl\capellactl.exe +[Environment]::SetEnvironmentVariable("Path", "$([Environment]::GetEnvironmentVariable('Path','User'));$env:LOCALAPPDATA\Programs\capellactl", "User") +# open a new PowerShell window, then: +capellactl --version +``` + +No administrator rights are needed. The root README's Download block is regenerated from the same template. + +### `help ci` +Add a Windows runner row next to the GitHub Actions example (`shell: pwsh`, the `$LASTEXITCODE` check above). + +## Acceptance criteria & tests + +### AC1 — The README has a Windows install table +**Given** `README.md`, **then** **Install** contains an **On Windows (PowerShell)** subsection with the table between +the `windows-install` markers. The table covers each step from installing Git and Python 3.12 to `capellactl --version` +and making the environment folder. Each row says what the user should see, and the rows for steps that can fail say +what to do (Python version, execution policy, `&&`). + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac1_readme_install_table` | `tests/unit/docs/test_windows_docs.py` | Markers present; steps include `py -3.12`, `.venv\Scripts\python -m pip install -e .`, `Set-ExecutionPolicy -Scope CurrentUser`, `Activate.ps1`, `capellactl --version` | + +### AC2 — The Windows commands contain nothing that only works in bash +**Given** every command in the Windows table, the PowerShell translation table, `downloads/README.md`'s PowerShell +block and `help ci`'s Windows example, **then** none uses `&&`, `||`, `export `, `source `, `.venv/bin`, a trailing `\`, +`pbpaste`, `python3` or a bare `curl `. + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac2_bash_only_syntax_is_caught` | `tests/unit/docs/test_windows_docs.py` | Each forbidden pattern is caught in a deliberately bad example | +| `test_windows_docs_ac2_no_bash_only_syntax` | `tests/unit/docs/test_windows_docs.py` | None appears in the 27 Windows commands of the README, guide, downloads README and `help ci` | + +### AC3 — The steps run as written on Windows PowerShell 5.1 +**Given** a `windows-latest` runner, **when** a CI job extracts the README table's commands and runs them in +Windows PowerShell 5.1 (`shell: powershell`, not `pwsh`), **then** `capellactl --version` succeeds. Step 1 +(`winget`) is skipped because the runner image already has Git and Python, and step 6 uses `-Scope Process`. A +change to the README table changes what CI runs. + +| Test | Location | Verifies | +|---|---|---| +| `windows-install` job | `.github/workflows/ci.yml` | Commands come from the README (`scripts/readme-windows-steps.py`), not a copy | +| `test_windows_docs_ac3_steps_extracted_from_readme` | `tests/unit/docs/test_windows_docs.py` | The extractor returns the table's commands in order and drops only step 1 | +| `test_windows_docs_ac3_ci_runs_the_readme_in_windows_powershell` | `tests/unit/docs/test_windows_docs.py` | The job exists, uses `shell: powershell` and runs the extracted script | + +### AC4 — Piping the token from PowerShell works +**Given** an API Key Token piped by Windows PowerShell (`Get-Clipboard | capellactl configure --api-key-stdin`), which +adds a trailing CRLF and, with some `$OutputEncoding` settings, a UTF-8 BOM, **then** `configure` stores the token +without them, as it does for the pasted prompt. + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac4_api_key_stdin_crlf_bom` | `tests/unit/cli/test_configure_key.py` | `clean_api_key` removes CRLF and BOM | +| `test_windows_docs_ac4_powershell_piped_key_stored_clean` | `tests/scenario/test_configure.py` | `"\ufeffKEY\r\n"` on stdin is stored in the keychain and sent to Capella as `KEY` | + +### AC5 — Every bash-only construct in the docs has a PowerShell translation +**Given** the shell blocks in `README.md` Quick start, `docs/getting-started.md`, `docs/runbook.md` and `help ci`, +**then** every bash-only construct they use (`export`, a trailing `\`, `&&`, `||`, `pbpaste`, `curl -s`, `$?`) has a +row in the getting-started PowerShell table. A new doc that uses a construct the table lacks fails CI. + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac5_translation_table_complete` | `tests/unit/docs/test_windows_docs.py` | | + +### AC6 — The `.exe` can be run as `capellactl` from any folder +**Given** the generated `downloads/README.md`, **then** its Windows section explains how to put the executable on the +user PATH as `capellactl.exe` without administrator rights. `scripts/update-downloads.py --check` stays green. + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac6_exe_on_user_path` | `tests/unit/docs/test_windows_docs.py` | The generated README has the user-PATH block, never the machine PATH | + +## Out of scope +- A Windows installer (MSI), winget or Chocolatey package, or Scoop manifest (CAP-045 out of scope). +- PowerShell tab completion (Click has none). +- Windows copies of the getting-started guide or the runbook. The translation table covers them. +- WSL. WSL users follow the Linux steps as they are. +- cmd.exe beyond the activation line. PowerShell is the documented shell. + +## Open questions +- Does `py -3.12` find the Python that `actions/setup-python` installs on the `windows-latest` runner? The AC3 job + checks first and, if it does not, passes setup-python's interpreter with `--python` and says so in its log. The + README keeps `py`. To be confirmed by the job's first run. +- ~~Lead with activation (`Set-ExecutionPolicy`) or with `.venv\Scripts\capellactl.exe`?~~ Decided (2026-10-29): + activation first, so the commands match the rest of the docs; the full path is offered in the same row for + laptops whose policy cannot be changed. +- ~~Run the time-to-first-environment check once on Windows before 1.0.0?~~ Decided (2026-10-29): yes; added to the + release checklist in `scripts/update-story-index.py`. + +## Implementation notes (as built) +- README **Install** → **On Windows (PowerShell)**: the 9-step table as designed, between the `windows-install` + markers. The macOS/Linux steps now say they are for macOS and Linux and link to it; the old one-line Windows note is + gone. +- `docs/getting-started.md` → **Before you start** → **Using the commands in PowerShell** (between the + `powershell-translation` markers), linked from the README and the runbook. Scanning the docs found two constructs + the draft lacked: `"$(curl -s …)/32"` inside `cidr create` (the row now shows that exact form; `"$(…)"` works the + same in PowerShell, only `curl` must be `curl.exe`) and `\` continuations in `docs/custom-clusters.md`, which the + AC5 scan now covers too. The `&&` row's note was dropped: the README table explains it. +- Troubleshooting has a Windows bullet (`capellactl is not recognized`, `running scripts is disabled`), and the API + key bullet gives the PowerShell IP check. +- AC4 was a real defect: `clean_api_key` stripped the CRLF but kept a UTF-8 BOM (`\ufeff` is not whitespace to + `str.strip`), so a token piped from Windows PowerShell with a UTF-8 `$OutputEncoding` would be rejected. It now + removes it and notes `removed a byte-order mark`. +- `scripts/readme-windows-steps.py` builds the CI script from the README table. It skips step 1 (winget), clones the + checked-out commit instead of GitHub's `main` (`git clone "" capellactl`), and runs step 6 with + `-Scope Process … -Force`. It first sets the process policy to `Restricted`, as on a new laptop, so `Activate.ps1` + runs only if the README's policy step works. Each command is checked (`$?` and `$LASTEXITCODE`), because Windows + PowerShell does not stop when a program fails. The file is written as UTF-8 with a BOM, which Windows PowerShell + 5.1 needs to read non-ASCII text. +- `windows-install` CI job (after `test`, on `windows-latest`, `shell: powershell`). It is not one of the six + required checks in the `main` ruleset (CAP-050); add it there if a red run should block merging. +- `help ci` has the Windows runner gate; `downloads/README.md` (generated) has the user-PATH install for the `.exe`. +- Not run here: the Windows job itself (no Windows machine); the unit tests run on every OS in the `test` matrix. + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_windows_docs_ac1_readme_install_table` | +| AC2 | `test_windows_docs_ac2_bash_only_syntax_is_caught`, `test_windows_docs_ac2_no_bash_only_syntax` | +| AC3 | `test_windows_docs_ac3_steps_extracted_from_readme`, `test_windows_docs_ac3_ci_runs_the_readme_in_windows_powershell`; `windows-install` CI job | +| AC4 | `test_windows_docs_ac4_api_key_stdin_crlf_bom`, `test_windows_docs_ac4_powershell_piped_key_stored_clean` | +| AC5 | `test_windows_docs_ac5_translation_table_complete` | +| AC6 | `test_windows_docs_ac6_exe_on_user_path` | diff --git a/docs/release-1.0.0/CAP-056-cluster-backup-schedule.md b/docs/release-1.0.0/CAP-056-cluster-backup-schedule.md new file mode 100644 index 0000000..aba499f --- /dev/null +++ b/docs/release-1.0.0/CAP-056-cluster-backup-schedule.md @@ -0,0 +1,194 @@ +# CAP-056 — Cluster backup schedule (cloud snapshots) as code + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E10 Backup & restore | P1 | 5 | CAP-011, CAP-020, CAP-038 | Implemented | + +## User story +As an **operator**, I want to set how often a cluster is backed up and how long each backup is kept, with one +command or in capella.yaml, so that every cluster I deploy is protected without opening the Capella UI. + +## Context & discovery +- Requested by a customer (2026-10-29): backup and restore from capellactl, with a schedule for **cluster** backups + and, separately, for **bucket** backups (CAP-058), and list commands that look like every other list. +- Capella has two backup kinds. **Cloud snapshot backups** copy the whole cluster (every bucket, index and + setting) at the cloud-disk level; this story and CAP-057 cover them as `cluster backup …`. **Bucket backups** + are per bucket, full + incremental; CAP-058 and CAP-059 cover them as `bucket backup …`. +- 00-architecture.md §14 listed backup schedules under "Not in 1.0.0"; this story moves them into 1.0.0. + +| Operation | Endpoint | Success | Notes | +|---|---|---|---| +| Get schedule | `GET …/clusters/{clusterId}/cloudsnapshotbackupschedule` | 200 | `interval`, `retention` (hours), `startTime` (ISO 8601), `copyToRegions` | +| Upsert schedule | `PUT …/clusters/{clusterId}/cloudsnapshotbackupschedule` | 204 | Same body; creates or replaces | +| Delete schedule | `DELETE …/clusters/{clusterId}/cloudsnapshotbackupschedule` | 204 | | +| Schedule options | `GET …/clusters/{clusterId}/cloudsnapshotbackupschedule/options` | 200 | `intervals` (hours), `minimumRetention`, `maximumRetention` (hours) the cluster's support plan permits. **New in the spec after the 2026-10-06 snapshot** | +| Copy regions | `GET …/clusters/{clusterId}/cloudsnapshotbackups/regions` | 200 | Regions a copy can go to (shared with CAP-057) | + +The permitted intervals and retention change with the support plan ("read this endpoint before creating or +updating a backup schedule"), so they are read from Capella, never hard-coded. Free-tier clusters have no backups +(`FREE_TIER_MISSING`). + +## Design + +### Commands +``` +capellactl cluster backup schedule show NAME +capellactl cluster backup schedule options NAME +capellactl cluster backup schedule set NAME --every HOURS --retain DURATION [--start HH:MM] [--copy-to REGION …] +capellactl cluster backup schedule delete NAME +``` +Shared flags as for every cluster command: `--project`, `--no-apply`, global `--auto-approve`, `-o json`. + +Examples: +``` +capellactl cluster backup schedule options nova-prod-01 +capellactl cluster backup schedule set nova-prod-01 --every 24 --retain 30d +capellactl cluster backup schedule set nova-prod-01 --every 6 --retain 7d --start 02:00 --copy-to us-west-2 +capellactl cluster backup schedule show nova-prod-01 +``` + +- `cluster backup` is a new sub-group of `cluster`, like `cluster schedule` and `cluster group`; `schedule` is its + sub-group. `set` and `delete` follow the CAP-038 flow: change the entry → validate → plan → confirm → write → apply. +- `--retain` takes hours or days (`168`, `168h`, `7d`) and is sent as hours. `--start` is the time of day in UTC for + the first backup, sent as the next such instant in ISO 8601. `--copy-to` may repeat; at most two regions (the + same limit as on-demand backups). +- **show** prints the schedule as stored in Capella: `every 24 h · kept 30 days · first at 02:00 UTC · copies: + us-west-2`, then the next backup time; `no backup schedule` when none. +- **options** prints the permitted intervals and retention range for the cluster's support plan, and the copy regions. + +### capella.yaml +```yaml +clusters: + - name: nova-prod-01 + backupSchedule: # cloud snapshot backups of the whole cluster + interval: 24 # hours, one of the plan's intervals + retention: 720 # hours + startTime: "02:00" # UTC + copyToRegions: [us-west-2] +``` +Field names follow the API (`x-api` in the schema). `plan`, `apply`, `drift`, `sync` and `destroy` treat it like +the hibernation schedule (CAP-020): a missing block in the file with a schedule in Capella is reported by `drift` as +unmanaged and removed only by `apply --prune`. + +### Plan output +``` +~ cluster nova-prod-01 backup schedule + + every 24 h · kept 30 days · first at 02:00 UTC · copies: us-west-2 +``` + +### Where the code goes +- `cli/cluster_backup.py` (new): the `backup` sub-group with `schedule show|options|set|delete`. +- `resources/backup_schedule.py` (new): reconciler, child of the cluster in the dependency order (§4.2). +- `api/endpoints.py`: the four operations; `docs/api/management-api-v4.0-responses.json` refreshed and the fake + Capella extended until `test_fake_parity.py` passes. + +## Acceptance criteria & tests + +### AC1 — Set a schedule +**Given** `cluster backup schedule set nova-dev-01 --every 24 --retain 30d`, **then** the options endpoint is read, +one `PUT …/cloudsnapshotbackupschedule` is sent with `interval: 24, retention: 720`, capella.yaml records +`backupSchedule`, and a later `plan` reports no changes. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac1_set_body` | `tests/scenario/test_cluster_backup_schedule.py` | PUT body, file | +| `test_cluster_backup_ac1_no_drift` | same | | + +### AC2 — Idempotent +**Given** AC1 completed, **when** the same command or `apply` runs again, **then** `no changes`, exit 0, zero +`POST`/`PUT`/`DELETE` calls. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac2_rerun_zero_writes` | `tests/scenario/test_cluster_backup_schedule.py` | | + +### AC3 — Values the plan does not permit are refused before any write +**Given** an interval not in `intervals`, or a retention outside `minimumRetention`–`maximumRetention`, or more +than two `--copy-to` regions, or a region not in the regions list, **then** exit 3 with the permitted values +(`--every must be one of 1, 2, 4, 6, 8, 12, 24 for this cluster's support plan`) and zero writes. **Given** a +free-tier cluster, **then** exit 4 `free-tier clusters have no backups`. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac3_refused` | `tests/scenario/test_cluster_backup_schedule.py` | Parametrised | +| `test_cluster_backup_ac3_free_tier` | same | | + +### AC4 — Show and options +**Given** a schedule exists, **then** `show` prints it with the next backup time and `-o json` returns Capella's +body; **given** none, `no backup schedule`, exit 0. `options` prints the permitted intervals, retention range and +copy regions. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac4_show` | `tests/scenario/test_cluster_backup_schedule.py` | Text, JSON, none | +| `test_cluster_backup_ac4_options` | same | | + +### AC5 — Delete +**Given** a schedule, **then** `delete` shows `- backup schedule`, asks once, sends one `DELETE` and removes +`backupSchedule` from capella.yaml. **Given** none, **then** `no backup schedule`, exit 0, zero writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac5_delete` | `tests/scenario/test_cluster_backup_schedule.py` | | +| `test_cluster_backup_ac5_delete_missing_noop` | same | | + +### AC6 — In the environment commands +**Given** `backupSchedule` in capella.yaml, **then** `plan`/`apply` create or update it after the cluster is +healthy; `sync` writes it from Capella; `drift` reports a changed or unmanaged schedule; `apply --prune` deletes an +unmanaged one. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac6_apply_creates` | `tests/scenario/test_cluster_backup_schedule.py` | | +| `test_cluster_backup_ac6_sync_round_trip` | same | | +| `test_cluster_backup_ac6_drift_and_prune` | same | | + +### AC7 — Discoverable +**Given** `cluster backup schedule --help`, **then** every verb has an example. `help backup` explains cluster +versus bucket backups, `capellactl help config clusters.backupSchedule` describes the block, and `docs/runbook.md` +has "Set a cluster backup schedule". + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac7_help` | `tests/scenario/test_cluster_backup_schedule.py` | | + +## Out of scope +- Operational Insights cluster snapshots (`…/operationalInsightsClusters/{id}/cloudSnapshotBackupSchedule`): not an + operational-database cluster. + +## Open questions +- Does Capella create a default snapshot schedule for a new cluster? If so, `sync` after `cluster create` records it + and `plan` must not report it as drift. + +## Implementation notes (as built) +- `capellactl/cli/cluster_backup.py`: the `cluster backup` sub-group and its `schedule` group (`show`, `options`, + `set`, `delete`). `set` also takes `--no-copies` (stop keeping copies) and changes only the values given on an + existing schedule. It reads the options (and the copy regions, when `--copy-to` is given) before anything is + written, so a value the support plan does not permit exits 3 with the permitted values (AC3). +- `capellactl/resources/backup_schedule.py`: `ClusterBackupScheduleReconciler` (kind `backup-schedule`), a child of + the cluster after the hibernation schedule. A plan or apply with a value outside the options is BLOCKED (exit 4) + with the same message. +- **Read only when declared.** Most backup endpoints need an Organization Owner or Project Owner key (spec), so the + reconciler is prunable but does not report unmanaged schedules on `plan` (unlike the hibernation schedule): + `plan` and `apply` read it only when the file has `backupSchedule`; `drift` lists an undeclared one and + `apply --prune` deletes it. A 403 or 404 on the read counts as "no schedule". This keeps plans made with a Project + Manager key working, and also answers the open question: a schedule Capella may create by default is not drift. +- The API returns **204** (no body) when there is no schedule; `get_snapshot_schedule` returns None for it. +- `startTime` is stored as the UTC time of day (`"02:00"`), sent as the next such instant, and compared by time of + day, so re-runs stay idempotent. +- `sync` reads the schedule of running, provisioned clusters. For a turned-off cluster, or when the key may not read + backups (403), it keeps the file's `backupSchedule` (as CAP-054 does for buckets) and warns once. +- `GET …/cloudsnapshotbackupschedule/options` was added to the offline spec snapshot (refreshed 2026-10-29) and to + the fake; `docs/api/management-api-v4.0-responses.json` refreshed; `help backup` is a new topic. + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_cluster_backup_ac1_set_body`, `test_cluster_backup_ac1_no_drift` (tests/scenario/test_cluster_backup_schedule.py) | +| AC2 | `test_cluster_backup_ac2_rerun_zero_writes` | +| AC3 | `test_cluster_backup_ac3_refused`, `test_cluster_backup_ac3_free_tier` | +| AC4 | `test_cluster_backup_ac4_show`, `test_cluster_backup_ac4_options` | +| AC5 | `test_cluster_backup_ac5_delete`, `test_cluster_backup_ac5_delete_missing_noop` | +| AC6 | `test_cluster_backup_ac6_apply_creates`, `test_cluster_backup_ac6_sync_round_trip`, `test_cluster_backup_ac6_sync_keeps_schedule_the_key_cannot_read`, `test_cluster_backup_ac6_drift_and_prune` | +| AC7 | `test_cluster_backup_ac7_help` | diff --git a/docs/release-1.0.0/CAP-057-cluster-backup-and-restore.md b/docs/release-1.0.0/CAP-057-cluster-backup-and-restore.md new file mode 100644 index 0000000..e1626a7 --- /dev/null +++ b/docs/release-1.0.0/CAP-057-cluster-backup-and-restore.md @@ -0,0 +1,169 @@ +# CAP-057 — Cluster backups: list, back up now, keep longer, delete and restore + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E10 Backup & restore | P1 | 5 | CAP-007, CAP-056 | Implemented | + +## User story +As an **operator**, I want to list a cluster's backups, take one before a risky change, keep an important one +longer, and restore the cluster from a backup, each with one command, so that I can recover a cluster without the +Capella UI. + +## Context & discovery +- Second half of the customer request in CAP-056: the cloud snapshot backups themselves. Bucket backups are + CAP-059. + +| Operation | Endpoint | Success | Notes | +|---|---|---|---| +| List | `GET …/clusters/{clusterId}/cloudsnapshotbackups` | 200 | `id`, `createdAt`, `expiration`, `progress.status`, `type`, `databaseSize`, `crossRegionCopies[]`, `retention` | +| List (project) | `GET …/projects/{projectId}/cloudsnapshotbackups` | 200 | Every cluster of the project | +| Create | `POST …/clusters/{clusterId}/cloudsnapshotbackups` | 202 | Optional `retention` (hours), `regionsToCopy` (at most two) | +| Edit retention | `PUT …/cloudsnapshotbackups/{backupId}` | 204 | `retention` (hours) | +| Delete | `DELETE …/cloudsnapshotbackups/{backupId}` | 204 | | +| Restore | `POST …/cloudsnapshotbackups/{backupId}/restore` | 202 | Optional `crossRegionRestorePreference` | +| List restores | `GET …/cloudsnapshotbackups/restores` | 200 | `status`, `createdAt`, `restoreEnd`, `snapshot` | +| Copy regions | `GET …/cloudsnapshotbackups/regions` | 200 | Shared with CAP-056 | + +A restore replaces the cluster's data with the snapshot, so it is guarded like `cluster delete`. + +## Design + +### Commands +``` +capellactl cluster backup list [NAME] [--all] +capellactl cluster backup create NAME [--retain DURATION] [--copy-to REGION …] +capellactl cluster backup retain NAME BACKUP_ID --retain DURATION +capellactl cluster backup delete NAME BACKUP_ID +capellactl cluster backup restore NAME BACKUP_ID [--from-region REGION …] +capellactl cluster backup restores NAME +capellactl cluster backup regions NAME +``` +Shared flags: `--project`, global `--auto-approve`, `--no-wait`, `--timeout`, `-o json`. + +Examples: +``` +capellactl cluster backup list nova-prod-01 +capellactl cluster backup create nova-prod-01 +capellactl cluster backup retain nova-prod-01 3f1c… --retain 90d +capellactl cluster backup restore nova-prod-01 3f1c… +``` + +- **list** uses the list layout of every list (S.NO first, ID last): + `S.NO CREATED TYPE STATUS SIZE EXPIRES COPIES ID`, newest first. Without `NAME` (or with `--all`) it uses + the project-level endpoint and adds a `CLUSTER` column after S.NO. +- **create** waits until the backup's `progress.status` is complete (CAP-007 waiter) unless `--no-wait`, then prints + its ID. It is an action, like `cluster power`: each run takes a new backup and nothing is written to capella.yaml. +- **retain** changes how long one backup is kept. +- **delete** asks for the backup's creation time to be typed (or `--auto-approve`). +- **restore** prints what will be replaced (`all data of nova-prod-01 is replaced with the backup of + 2026-10-28 02:00 UTC`), asks for the cluster name to be typed (or `--auto-approve`), sends the restore and waits + until the restore's status is complete and the cluster is `healthy`. +- **restores** lists past restores in the same layout: `S.NO STARTED FINISHED SNAPSHOT STATUS ID`. + +### Where the code goes +- `cli/cluster_backup.py` (from CAP-056): the verbs above. +- `api/endpoints.py`, fake Capella, refreshed responses file. + +## Acceptance criteria & tests + +### AC1 — List +**Given** a cluster with three backups, **then** `cluster backup list NAME` prints three rows, newest first, with +S.NO first and ID last; `-o json` returns Capella's items. **Given** no `NAME`, **then** the project-level endpoint +is used and the `CLUSTER` column is shown. **Given** no backups, `no backups`, exit 0. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac1_list` | `tests/scenario/test_cluster_backup.py` | Text, JSON, empty | +| `test_cluster_backup_ac1_list_project` | same | | + +### AC2 — Back up now +**Given** `cluster backup create nova-dev-01 --retain 7d`, **then** one `POST` with `retention: 168` is sent, the +tool waits until the backup is complete and prints its ID; with `--no-wait` it returns after the `202`. More than two +`--copy-to` regions, or a region not offered, exits 3 with zero writes. A free-tier cluster exits 4. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac2_create_waits` | `tests/scenario/test_cluster_backup.py` | | +| `test_cluster_backup_ac2_create_refused` | same | Parametrised | + +### AC3 — Keep a backup longer +**Given** `retain … --retain 90d`, **then** one `PUT` with `retention: 2160`. **Given** the backup already has that +retention, **then** `no changes`, zero writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac3_retain` | `tests/scenario/test_cluster_backup.py` | | +| `test_cluster_backup_ac3_retain_rerun_noop` | same | | + +### AC4 — Delete +**Given** a backup, **then** `delete` asks for the typed confirmation and sends one `DELETE`. **Given** an unknown +ID, **then** `backup … not found`, exit 0 with `--auto-approve` and zero writes, so scripts can re-run. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac4_delete` | `tests/scenario/test_cluster_backup.py` | | +| `test_cluster_backup_ac4_delete_missing_noop` | same | | + +### AC5 — Restore +**Given** `restore nova-dev-01 ID`, **then** the plan says all data is replaced, a wrong typed name sends nothing +(exit 5 as for `cluster delete`), the right one sends one `POST …/restore`, and the tool waits until the restore is +complete and the cluster is healthy. **Given** a cluster that is `turnedOff`, **then** exit 4 `turned off; power it +on before restoring` with zero writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac5_restore_confirm` | `tests/scenario/test_cluster_backup.py` | | +| `test_cluster_backup_ac5_restore_waits` | same | | +| `test_cluster_backup_ac5_restore_turned_off` | same | | + +### AC6 — Restores and regions +**Given** past restores, **then** `restores` lists them in the list layout; `regions` lists the copy regions. + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac6_restores_and_regions` | `tests/scenario/test_cluster_backup.py` | | + +### AC7 — Discoverable +**Given** `cluster backup --help`, **then** every verb has an example, and `docs/runbook.md` has "Take a cluster +backup before a change" and "Restore a cluster from a backup". + +| Test | Location | Verifies | +|---|---|---| +| `test_cluster_backup_ac7_help` | `tests/scenario/test_cluster_backup.py` | | + +## Out of scope +- **Clone a backup into a new cluster** (`POST …/projects/{p}/cloudsnapshotbackups/{backupId}/clone`): creates a + cluster outside capella.yaml, so it needs the CAP-038 record-then-apply design. Proposed as `cluster backup clone` + for 1.1. + +## Open questions +- What are `progress.status` and the restore `status` values? The spec gives them as free strings; the fake and the + waiter use the values seen in the sandbox, recorded in the responses file. + +## Implementation notes (as built) +- `capellactl/cli/cluster_backup.py`: `list`, `create`, `retain`, `delete`, `restore`, `restores`, `regions`. +- **`list` without NAME** uses the project-level endpoint, which returns one summary per cluster (its newest and + oldest backup), not every backup. The table is `S.NO CLUSTER REGION NEWEST OLDEST CLUSTER ID`; there is no + `--all` flag (`--project` chooses the project). +- **Confirmations**: `delete` asks for the cluster name (like every typed confirmation), not the backup's creation + time. A wrong name prints `Cancelled; nothing was …` and exits 0, as `cluster delete` does (the story's "exit 5" + was wrong: 5 is the timeout code). +- `create` and `restore` wait with the new `wait_for_status` (engine/waiter.py, the cluster waiter's poll intervals): + `create` until the backup's `progress.status` is done; `restore` until the restore is done and then until the + cluster is healthy. `--no-wait` returns after the 202. +- The spec gives `progress.status` and the restore `status` as free text; capellactl treats `complete`, `completed`, + `succeeded`, `success`, `done` and `ready` as done and `failed`, `error`, `cancelled` and `expired` as failed + (`cli/backup_common.py`). **Still open:** confirm the values on the sandbox's first run (CAP-032). +- Cloning a backup into a new cluster stays out of scope (`cluster backup clone`, proposed for 1.1). + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_cluster_backup_ac1_list`, `test_cluster_backup_ac1_list_project` (tests/scenario/test_cluster_backup.py) | +| AC2 | `test_cluster_backup_ac2_create_waits`, `test_cluster_backup_ac2_create_refused` | +| AC3 | `test_cluster_backup_ac3_retain`, `test_cluster_backup_ac3_retain_rerun_noop` | +| AC4 | `test_cluster_backup_ac4_delete`, `test_cluster_backup_ac4_delete_missing_noop` | +| AC5 | `test_cluster_backup_ac5_restore_confirm`, `test_cluster_backup_ac5_restore_waits`, `test_cluster_backup_ac5_restore_turned_off` | +| AC6 | `test_cluster_backup_ac6_restores_and_regions` | +| AC7 | `test_cluster_backup_ac7_help` | diff --git a/docs/release-1.0.0/CAP-058-bucket-backup-schedule.md b/docs/release-1.0.0/CAP-058-bucket-backup-schedule.md new file mode 100644 index 0000000..0d58bad --- /dev/null +++ b/docs/release-1.0.0/CAP-058-bucket-backup-schedule.md @@ -0,0 +1,177 @@ +# CAP-058 — Bucket backup schedule as code, and backup cycles + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E10 Backup & restore | P1 | 5 | CAP-023, CAP-056 | Implemented | + +## User story +As an **operator**, I want to set a weekly full + incremental backup schedule per bucket, with one command or in +capella.yaml, and see the backup cycles it has produced, so that each bucket is backed up on its own terms. + +## Context & discovery +- The bucket half of the customer request in CAP-056. A bucket backup is per bucket: one **full** backup a week + starts a **cycle**, followed by **incrementals** every few hours. + +| Operation | Endpoint | Success | Notes | +|---|---|---|---| +| Get schedule | `GET …/buckets/{bucketId}/backup/schedules` | 200 | `type: weekly`, `weeklySchedule` | +| Create schedule | `POST …/buckets/{bucketId}/backup/schedules` | 202 | | +| Update schedule | `PUT …/buckets/{bucketId}/backup/schedules` | 202 | | +| Delete schedule | `DELETE …/buckets/{bucketId}/backup/schedules` | 202 | | +| List cycles | `GET …/buckets/{bucketId}/backup/cycles` | 200 | | +| Backups of a cycle | `GET …/buckets/{bucketId}/backup/cycles/{cycleId}` | 200 | | + +`weeklySchedule` (all required), enums from the spec: + +| Field | Values | Flag | +|---|---|---| +| `dayOfWeek` | `sunday` … `saturday` | `--day` | +| `startAt` | hour 0–23 | `--start-at` | +| `incrementalEvery` | 1, 2, 4, 6, 8, 12, 24 (hours) | `--incremental-every` | +| `retentionTime` | `30days`, `60days`, `90days`, `180days`, `1year`, `2years`, `3years`, `4years`, `5years` | `--retention` | +| `costOptimizedRetention` | boolean | `--cost-optimized/--no-cost-optimized` | + +`bucketId` is the Base64 of the bucket name, as the bucket commands already compute. + +## Design + +### Commands +``` +capellactl bucket backup schedule show CLUSTER BUCKET +capellactl bucket backup schedule set CLUSTER BUCKET [--day D] [--start-at H] [--incremental-every H] + [--retention R] [--cost-optimized/--no-cost-optimized] +capellactl bucket backup schedule delete CLUSTER BUCKET +capellactl bucket backup cycle list CLUSTER BUCKET +capellactl bucket backup cycle get CLUSTER BUCKET CYCLE_ID +``` +Arguments as the other bucket commands (`bucket create CLUSTER NAME`); shared flags `--project`, `--no-apply`, +global `--auto-approve`, `-o json`. + +Examples (defaults first): +``` +capellactl bucket backup schedule set nova-prod-01 orders +capellactl bucket backup schedule set nova-prod-01 orders --day sunday --start-at 2 --incremental-every 12 --retention 30days +capellactl bucket backup cycle list nova-prod-01 orders +``` + +- `set` without options uses `sunday`, `0`, `24`, `30days`, `--no-cost-optimized`; interactive terminals are asked + each value with those defaults. An existing schedule is kept for values not given. It sends `POST` when no schedule + exists and `PUT` otherwise. +- **show**: `weekly full on sunday at 00:00 UTC · incremental every 24 h · kept 30 days`, `no backup schedule` when + none. +- **cycle list** (list layout, S.NO first, ID last): `S.NO STARTED BACKUPS STATUS ID`. **cycle get** lists the + backups of one cycle: `S.NO DATE METHOD SOURCE STATUS SIZE ITEMS EXPIRES ID`. + +### capella.yaml +```yaml +buckets: + - name: orders + backupSchedule: # bucket backups: weekly full + incrementals + type: weekly + weeklySchedule: + dayOfWeek: sunday + startAt: 0 + incrementalEvery: 24 + retentionTime: 30days + costOptimizedRetention: false +``` +Reconciled as a child of the bucket, like the cluster schedule in CAP-056 (plan, apply, sync, drift, prune). + +### Where the code goes +- `cli/bucket_backup.py` (new): the `bucket backup` sub-group, registered in `bucket_cmds.py`. +- `resources/bucket_backup_schedule.py` (new); schema, `help config`, `make docs`. + +## Acceptance criteria & tests + +### AC1 — Set a schedule +**Given** `bucket backup schedule set nova-dev-01 orders`, **then** one `POST …/backup/schedules` is sent with the +defaults, capella.yaml records `backupSchedule` under the bucket, and a later `plan` reports no changes. **Given** a +schedule exists and `--incremental-every 12`, **then** one `PUT` with only that value changed. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac1_set_create` | `tests/scenario/test_bucket_backup_schedule.py` | POST body, file | +| `test_bucket_backup_ac1_set_update` | same | PUT body | + +### AC2 — Idempotent +**Given** AC1 completed, **when** the command or `apply` runs again, **then** `no changes`, exit 0, zero writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac2_rerun_zero_writes` | `tests/scenario/test_bucket_backup_schedule.py` | | + +### AC3 — Invalid values refused offline +**Given** a value outside the enums above (for example `--incremental-every 3` or `--retention 45days`), **then** +`validate` and the command exit 3 listing the allowed values, with zero API calls. **Given** a bucket on a free-tier +cluster, **then** exit 4. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac3_invalid_values` | `tests/scenario/test_bucket_backup_schedule.py` | Parametrised | +| `test_bucket_backup_ac3_free_tier` | same | | + +### AC4 — Show and delete +**Given** a schedule, **then** `show` prints it and `-o json` returns Capella's body; `delete` asks once, sends one +`DELETE` and removes the block from capella.yaml. **Given** none, both print `no backup schedule`, exit 0, zero +writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac4_show` | `tests/scenario/test_bucket_backup_schedule.py` | | +| `test_bucket_backup_ac4_delete_and_missing` | same | | + +### AC5 — Cycles +**Given** two cycles, **then** `cycle list` prints them in the list layout; `cycle get` lists the cycle's backups +with method (full/incremental), source (scheduled/manual) and status. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac5_cycles` | `tests/scenario/test_bucket_backup_schedule.py` | | + +### AC6 — In the environment commands +**Given** `backupSchedule` under a bucket, **then** `apply` creates it after the bucket; `sync` writes it from +Capella; `drift` reports a changed or unmanaged schedule; `apply --prune` deletes an unmanaged one; `destroy` needs no +extra step (the bucket's deletion removes it). + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac6_apply_sync_drift` | `tests/scenario/test_bucket_backup_schedule.py` | | + +### AC7 — Discoverable +**Given** `bucket backup --help`, **then** every verb has an example; `help backup` (CAP-056) covers bucket backups; +`docs/runbook.md` has "Set a bucket backup schedule". + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac7_help` | `tests/scenario/test_bucket_backup_schedule.py` | | + +## Open questions +- Do `POST`/`PUT` of a schedule finish synchronously in practice despite the `202`? If not, the command waits until + `GET` returns the new values. + +## Implementation notes (as built) +- `capellactl/cli/bucket_backup.py`: the `bucket backup` sub-group with `schedule show|set|delete` and + `cycle list|get`, registered on the `bucket` group. +- **capella.yaml form:** the schedule is written flat under the bucket (`dayOfWeek`, `startAt`, `incrementalEvery`, + `retentionTime`, `costOptimizedRetention`), not as `type: weekly` + `weeklySchedule:`; `weekly` is the only type the + API has, so it is implied. Missing fields take the defaults (Sunday, 0, 24, 30days, false); `set` and `sync` write + all five. The schedule goes before the bucket's `scopes`. +- `BucketBackupScheduleReconciler` (kind `bucket-backup-schedule`, a child of the bucket): `POST` when there is no + schedule, `PUT` otherwise; read only when declared, as CAP-056 (a 403 or 404 counts as none). `drift` lists an + undeclared schedule and `apply --prune` deletes it; `sync` writes it, or keeps the file's when it cannot be read. +- AC3: the enums are in `config/schema.json`, so an invalid value exits 3 from validation with zero API calls. +- Interactive terminals are asked each value when a new schedule is set without options. +- Open question (POST/PUT answer 202): the fake applies them at once; if Capella does not, the next `plan` shows the + difference and `apply` repeats the write. To be confirmed on the sandbox. + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_bucket_backup_ac1_set_create`, `test_bucket_backup_ac1_set_update`, `test_bucket_backup_ac1_set_interactive` (tests/scenario/test_bucket_backup_schedule.py) | +| AC2 | `test_bucket_backup_ac2_rerun_zero_writes` | +| AC3 | `test_bucket_backup_ac3_invalid_values`, `test_bucket_backup_ac3_free_tier` | +| AC4 | `test_bucket_backup_ac4_show`, `test_bucket_backup_ac4_delete_and_missing` | +| AC5 | `test_bucket_backup_ac5_cycles` | +| AC6 | `test_bucket_backup_ac6_apply_sync_drift` | +| AC7 | `test_bucket_backup_ac7_help` | diff --git a/docs/release-1.0.0/CAP-059-bucket-backup-and-restore.md b/docs/release-1.0.0/CAP-059-bucket-backup-and-restore.md new file mode 100644 index 0000000..e2102e0 --- /dev/null +++ b/docs/release-1.0.0/CAP-059-bucket-backup-and-restore.md @@ -0,0 +1,170 @@ +# CAP-059 — Bucket backups: list, back up now, delete, restore and export + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E10 Backup & restore | P1 | 8 | CAP-007, CAP-058 | Implemented | + +## User story +As an **operator**, I want to list a bucket's backups, back a bucket up now, restore it (to the same or another +cluster, all or part of the data) and download a backup, each with one command, so that I can recover or move one +bucket's data without the Capella UI. + +## Context & discovery + +| Operation | Endpoint | Success | Notes | +|---|---|---|---| +| Create | `POST …/buckets/{bucketId}/backups` | 202 | On-demand backup | +| List | `GET …/clusters/{clusterId}/backups` | 200 | Latest backup of each bucket's cycles in the cluster: `status` (`pending`, `ready`, `failed`), `method` (`full`, `incremental`), `source` (`manual`, `scheduled`), `stats.sizeInMb`, `stats.items`, `restoreBefore` | +| Get | `GET …/clusters/{clusterId}/backups/{backupId}` | 200 | | +| Delete | `DELETE …/clusters/{clusterId}/backups/{backupId}` | 202 | **Deletes the whole cycle** the backup belongs to | +| Restore | `POST …/clusters/{clusterId}/backups/{backupId}/restore` | 202 | `targetClusterID`, `sourceClusterID`, `backupID`, `services` (`data`, `query`) required; optional `forceUpdates`, `autoRemoveCollections`, `filterKeys`, `filterValues`, `includeData`, `excludeData`, `mapData`, `replaceTTL` (`none`, `all`, `expired`), `replaceTTLWith` | +| Export | `POST …/buckets/{bucketId}/backups/{backupId}/export` | 202 | Packages a backup cycle as a zip. **New in the spec after the 2026-10-06 snapshot.** 422 unless the backup is `ready`, at most 5 TB and its cycle complete; 409 when the cycle already has an export | +| Get export | `GET …/buckets/{bucketId}/backups/{backupId}/exports/{exportId}` | 200 | `status`; the download URL when complete. **New** | + +## Design + +### Commands +``` +capellactl bucket backup list CLUSTER [BUCKET] +capellactl bucket backup get CLUSTER BACKUP_ID +capellactl bucket backup create CLUSTER BUCKET +capellactl bucket backup delete CLUSTER BACKUP_ID +capellactl bucket backup restore CLUSTER BACKUP_ID [--to-cluster NAME] [--services data,query] + [--include-data B.S.C,…] [--exclude-data B.S.C,…] [--map-data SRC=DST,…] + [--filter-keys REGEX] [--filter-values REGEX] + [--replace-ttl none|all|expired] [--replace-ttl-with TIME] + [--force-updates] [--auto-remove-collections] +capellactl bucket backup export CLUSTER BACKUP_ID [--no-wait] +``` +Shared flags: `--project`, global `--auto-approve`, `--no-wait`, `--timeout`, `-o json`. + +Examples (safe defaults first): +``` +capellactl bucket backup list nova-prod-01 +capellactl bucket backup create nova-prod-01 orders +capellactl bucket backup restore nova-prod-01 7a2e… --replace-ttl none +capellactl bucket backup restore nova-prod-01 7a2e… --to-cluster nova-dr-01 --include-data orders.sales +capellactl bucket backup export nova-prod-01 7a2e… +``` + +- **list** (list layout, S.NO first, ID last): `S.NO BUCKET DATE METHOD SOURCE STATUS SIZE ITEMS EXPIRES + ID`, newest first; `BUCKET` filters by bucket name. +- **create** waits until the backup is `ready` (or `failed`, exit 1) unless `--no-wait`. It is an action and writes + nothing to capella.yaml. +- **delete** says that the whole cycle goes (`deletes cycle 2026-10-25 with 7 backups of orders`), asks for the + bucket name to be typed (or `--auto-approve`). +- **restore** sends `sourceClusterID` = the backup's cluster and `targetClusterID` = `--to-cluster` or the same + cluster, `services` default `data`. The plan lists what is restored and what is overwritten; `--force-updates` + is shown as `overwrites newer data`. It asks for the target cluster's name to be typed, then waits until done. +- **export** starts the export, waits until it is complete and prints the download URL (a secret-like link: printed + only to the terminal, never in logs, CAP-008). If the cycle already has an export (409), it prints that export's + status and URL instead, exit 0, so re-runs are safe. + +### Where the code goes +- `cli/bucket_backup.py` (from CAP-058). +- `api/endpoints.py`, fake Capella, refreshed responses file. The two export operations are in the spec snapshot + since it was refreshed on 2026-10-29 with these stories. + +## Acceptance criteria & tests + +### AC1 — List and get +**Given** backups of two buckets, **then** `list CLUSTER` prints both in the list layout and `list CLUSTER orders` +only orders'; `get` prints one with its stats; `-o json` returns Capella's items; none → `no backups`, exit 0. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac1_list` | `tests/scenario/test_bucket_backup.py` | Text, filter, JSON, empty | +| `test_bucket_backup_ac1_get` | same | | + +### AC2 — Back up now +**Given** `create nova-dev-01 orders`, **then** one `POST …/buckets/{base64(orders)}/backups`, the tool waits until +`ready` and prints the ID; a `failed` backup exits 1 with Capella's status. An unknown bucket exits 1 before any +write. A free-tier cluster exits 4. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac2_create` | `tests/scenario/test_bucket_backup.py` | | +| `test_bucket_backup_ac2_create_failed_and_refused` | same | | + +### AC3 — Delete a cycle +**Given** a backup, **then** `delete` names the cycle and its number of backups, asks for the typed bucket name and +sends one `DELETE`. **Given** an unknown ID, **then** `backup … not found`, exit 0 with `--auto-approve`, zero writes. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac3_delete_cycle` | `tests/scenario/test_bucket_backup.py` | | +| `test_bucket_backup_ac3_delete_missing_noop` | same | | + +### AC4 — Restore to the same cluster +**Given** `restore nova-dev-01 ID`, **then** one `POST …/restore` with `sourceClusterID` = `targetClusterID`, +`backupID`, `services: ["data"]` and no optional fields; the tool waits until it is done. A wrong typed name sends +nothing (exit 5). + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac4_restore_same_cluster` | `tests/scenario/test_bucket_backup.py` | Body, wait | +| `test_bucket_backup_ac4_restore_confirm` | same | | + +### AC5 — Restore elsewhere or in part +**Given** `--to-cluster nova-dr-01 --include-data orders.sales --map-data orders=orders_copy --replace-ttl all +--replace-ttl-with 2027-01-01T00:00:00Z`, **then** the body carries the target cluster's ID and those fields as the +API names them. **Given** an unknown target cluster, `--replace-ttl-with` without `--replace-ttl all|expired`, or +`--services` outside `data,query`, **then** exit 3 with zero writes. **Given** a target that is `turnedOff`, exit 4. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac5_restore_options_body` | `tests/scenario/test_bucket_backup.py` | | +| `test_bucket_backup_ac5_restore_refused` | same | Parametrised | + +### AC6 — Export +**Given** a `ready` backup of a complete cycle, **then** `export` sends one `POST …/export`, polls the export until +complete and prints the download URL only to the terminal (redaction canary, CAP-008). **Given** 409, **then** the +existing export is shown, exit 0, no further write. **Given** 422, **then** exit 4 with Capella's reason. + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac6_export` | `tests/scenario/test_bucket_backup.py` | | +| `test_bucket_backup_ac6_export_existing_and_refused` | same | | + +### AC7 — Discoverable +**Given** `bucket backup --help`, **then** every verb has an example; `docs/runbook.md` has "Restore a bucket from a +backup" and "Download a bucket backup". + +| Test | Location | Verifies | +|---|---|---| +| `test_bucket_backup_ac7_help` | `tests/scenario/test_bucket_backup.py` | | + +## Open questions +- `includeData`/`excludeData`/`mapData` are free strings in the spec; the format (`bucket.scope.collection`, comma + separated, `src=dst`) is taken from `cbbackupmgr` and must be confirmed against the sandbox before AC5 is built. + +## Implementation notes (as built) +- `capellactl/cli/bucket_backup.py`: `list`, `get`, `create`, `delete`, `restore`, `export`. +- `list CLUSTER` shows what Capella lists, the latest backup of each bucket; `list CLUSTER BUCKET` walks the bucket's + cycles and shows every backup. +- **`create`**: the API answers 202 without an ID, so the command waits until the bucket's latest backup is a new one + that is `ready` (exit 1 when it is `failed`), and prints its ID. +- **`restore` does not wait** (deviation from AC4): the Management API has no restore status for bucket backups, so + the command says the restore started and that Capella reports its progress in the cluster's activity log. +- `restore` confirms with the target cluster's name; a wrong name prints `Cancelled` and exits 0 (the story's + "exit 5" was wrong: 5 is the timeout code). An unknown `--to-cluster` exits 1 (`cluster … not found`), as every + not-found cluster does, not 3. `includeData`, `excludeData`, `mapData`, `filterKeys` and `filterValues` are passed + as given (the open question on their format stays open for the sandbox run). +- `delete` confirms with the bucket name and says how many backups the cycle has. +- **`export`**: on 409 Capella does not return the existing export's ID, so the command says the cycle already has an + export and points to the new `--export-id ID` option, which shows an export's status and a fresh download link. + 422 exits 4 with Capella's reason. The link is printed to the terminal only: capellactl never logs response bodies. +- The two export operations were added to the spec snapshot (refreshed 2026-10-29), the endpoint inventory and the + fake; the fake's backup responses match the spec (`test_fake_parity.py`). + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_bucket_backup_ac1_list`, `test_bucket_backup_ac1_get` (tests/scenario/test_bucket_backup.py) | +| AC2 | `test_bucket_backup_ac2_create`, `test_bucket_backup_ac2_create_failed_and_refused` | +| AC3 | `test_bucket_backup_ac3_delete_cycle`, `test_bucket_backup_ac3_delete_missing_noop` | +| AC4 | `test_bucket_backup_ac4_restore_same_cluster`, `test_bucket_backup_ac4_restore_confirm` | +| AC5 | `test_bucket_backup_ac5_restore_options_body`, `test_bucket_backup_ac5_restore_refused`, `test_bucket_backup_ac5_restore_target_turned_off` | +| AC6 | `test_bucket_backup_ac6_export`, `test_bucket_backup_ac6_export_existing_and_refused` | +| AC7 | `test_bucket_backup_ac7_help` | diff --git a/docs/release-1.0.0/CAP-060-choose-disk-type.md b/docs/release-1.0.0/CAP-060-choose-disk-type.md new file mode 100644 index 0000000..f9eb00c --- /dev/null +++ b/docs/release-1.0.0/CAP-060-choose-disk-type.md @@ -0,0 +1,172 @@ +# CAP-060 — Choose the disk type, and see what each type means + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E3 Clusters | P1 | 5 | CAP-017, CAP-041, CAP-051, CAP-052 | Implemented | + +## User story +As an **operator deploying a cluster**, I want to choose the disk type for each service group the same way I choose +vCPU and RAM, and see for each type its size range, speed (IOPS) and what it is for, so that I can pick storage that +fits the workload without reading the cloud provider's documentation. + +## Context & discovery +- Requested by a customer (2026-10-29): `cluster create` lets them pick the vCPU/RAM combination from a list + (CAP-051 AC9), but the disk is silently the provider default (`gp3 50 GB 3000 IOPS`, `pd-ssd 50 GB`, `P6`). + `--disk-type` exists but is not offered, not explained, and not accepted by `cluster update`. +- No new endpoint: the disk is `serviceGroups[].node.disk` in `POST`/`PUT …/clusters` (`DiskAWS`, `DiskAzure`, + `DiskGCP`). The spec gives only the type enums and minimums and says "see documentation" for sizes and IOPS, so — + as for node sizes in `config/compute.yaml` — the meaning of each type comes from Capella's documentation (read + 2026-10-29) into a new `config/disks.yaml`: + +| Provider | Type | What it is | Size per node | Speed (IOPS) | +|---|---|---|---|---| +| AWS | `gp3` | General-purpose SSD (EBS); default | 50 GB – 16 TB | 3,000 – 16,000, set independently of size | +| AWS | `io2` | Provisioned-IOPS SSD for latency-sensitive, write-heavy workloads; costs more | 50 GB – 16 TB | 3,000 – 64,000 | +| GCP | `pd-ssd` | SSD persistent disk; the only type | 50 GB and up | Follows size: 30 read + 30 write per GB (50 GB → 1,500/1,500); grow the disk to go faster | +| Azure | `P6` … `P60` | Premium SSD v1; size and speed fixed by the type | P6 64 · P10 128 · P15 256 · P20 512 · P30 1,024 · P40 2,048 · P50 4,096 · P60 8,192 GiB | P6 240 · P10 500 · P15 1,100 · P20 2,300 · P30 5,000 · P40/P50 7,500 · P60 16,000 | +| Azure | `Ultra` | Ultra Disk for intensive, transaction-heavy workloads; lowest latency; only in regions that support it | 64, 128, 256, 512 GiB, then 1–15 TiB in 1 TiB steps | Settable, capped by size: 64 → 19,200 · 128 → 38,400 · 256 → 76,800 · 512+ → 80,000 | + +- **Recommended IOPS defaults** (Capella docs, "IOPS Defaults"): for `gp3`/`io2`, 3,000 for 50–99 GB, 4,370 for + 100–199, then +1,370 per 100 GB up to 16,000 at 1,000 GB+; Ultra 3,000 at 64 GiB … 16,000 at 1,024 GiB+. Capella + takes "a value higher than the default but not lower" (100–199 GB may go down to 3,000). `make_disk` sends 3,000 + whatever the size today, so a larger disk can be rejected; this story fixes that. +- Disk changes behave differently per provider (docs, "Storage Auto-Expansion"): on AWS, size or IOPS can change + once every 6 hours in place, otherwise nodes are replaced and rebalanced; on Azure, a change replaces and + rebalances nodes. Size cannot be reduced (schema). Auto-expansion is always on for AWS and GCP. + +## Design + +### Commands +``` +capellactl cluster disk-types [--provider aws|gcp|azure] +capellactl cluster create NAME … [--disk-type T] [--disk-size GB] [--disk-iops N] (existing flags) +capellactl cluster update NAME [--group SERVICES] --disk-type T [--disk-size GB] [--disk-iops N] (new: --disk-type) +capellactl cluster group add NAME --services S [--disk-type T] … (existing flag) +``` +Examples: +``` +capellactl cluster disk-types --provider aws +capellactl cluster create nova-prod-01 --provider aws --disk-type gp3 --disk-size 200 +capellactl cluster update nova-prod-01 --group data --disk-type io2 --disk-iops 20000 +``` + +- **disk-types** lists the provider's types in the list layout (S.NO first): + `S.NO PROVIDER TYPE SIZE (GB) IOPS FOR`, for example + `1 aws gp3 50–16000 3000–16000 general purpose (default)`. `-o json` returns `config/disks.yaml` for the + provider. `cluster options` gains a `disk types` line per provider pointing to `cluster disk-types`. +- **Interactive create and `group add`**: after the node size, a disk picker shows the same table for the chosen + provider, numbered, default first, then asks only what the type allows: size (with its range; skipped for Azure + premium) and IOPS (default from the table for that size; skipped for GCP and Azure premium). The flags skip their + questions. +- **IOPS default follows the size** for `gp3`, `io2` and Ultra (the table above) when `--disk-iops` is not given. +- **`cluster update --disk-type`** changes the type of every group, or of one with `--group`. The plan shows the + meaning and the cost of the change: + ``` + ~ cluster nova-prod-01 scale up + ~ serviceGroups[data].node.disk.type gp3 → io2 (provisioned IOPS, 3000–64000) + nodes are replaced and rebalanced + ``` +- **Offline checks** (`validate`, every command before a write) use `config/disks.yaml`: AWS size 50–16,000, IOPS in + the type's range and not below the default for the size; Azure Ultra size from the spec enum and IOPS ≤ the cap; + Azure premium refuses size and IOPS (existing rule); GCP refuses IOPS. Messages name the allowed values and point + to `cluster disk-types`. + +### Where the code goes +- `config/disks.yaml` (new, documented source and date like `compute.yaml`), loaded in `config/presets.py`. +- `cli/cluster_ops.py`: `cluster_disk_types`, `_ask_disk()` used by `_ask_groups`, the one-group custom flow and + `cluster group add`; `make_disk` takes the size-based IOPS default; `cluster update --disk-type`. +- `config/crossref.py`: the range checks. + +## Acceptance criteria & tests + +### AC1 — List disk types with their meaning +**Given** `cluster disk-types`, **then** every type of every provider is listed with size range, IOPS and use, S.NO +first; `--provider azure` lists only Azure's ten types; `-o json` returns the same data. `cluster options` mentions +`cluster disk-types`. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac1_list` | `tests/scenario/test_cluster_disk.py` | Text, filter, JSON | +| `test_disk_types_ac1_options_points_to_list` | same | | + +### AC2 — Choose interactively +**Given** an interactive `cluster create` on AWS with custom size, **then** after the node size the disk picker +shows gp3 and io2 with their meaning; choosing io2 and 200 GB proposes 5,740 IOPS; the `POST` body has +`{"type": "io2", "storage": 200, "iops": 5740}`. On Azure, choosing `P15` asks nothing more; on GCP, only the size is +asked and the expected IOPS is shown. `cluster group add` asks the same, defaulting to the data group's disk. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac2_create_picker` | `tests/scenario/test_cluster_disk.py` | Per provider | +| `test_disk_types_ac2_group_add_picker` | same | | + +### AC3 — IOPS default follows size +**Given** `--disk-type gp3 --disk-size 500` and no `--disk-iops`, **then** `iops: 9850`; **given** 50 GB, 3,000; +**given** Ultra 256 GiB, 6,000. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac3_iops_default` | `tests/unit/cli/test_make_disk.py` | Parametrised over the docs table | + +### AC4 — Out-of-range values refused offline +**Given** gp3 with 20,000 IOPS, io2 below the size's default, AWS 20,000 GB, Ultra 100 GiB, Ultra IOPS over the cap, +`--disk-iops` on GCP, or size on Azure premium, **then** exit 3 naming the allowed values and `cluster disk-types`, +with zero API calls. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac4_refused` | `tests/unit/config/test_crossref_disk.py` | Parametrised | + +### AC5 — Change the disk type of a running cluster +**Given** `cluster update nova-dev-01 --group data --disk-type io2`, **then** the plan shows the type change with its +meaning and the replacement note, one `PUT` changes only that group's disk, capella.yaml records it, and a re-run is +`no changes` with zero writes. **Given** a type of another provider, **then** exit 3. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac5_update_type` | `tests/scenario/test_cluster_disk.py` | Plan, PUT body, file | +| `test_disk_types_ac5_rerun_noop_and_wrong_provider` | same | | + +### AC6 — Discoverable +**Given** `cluster create --help` and `cluster update --help`, **then** the disk options point to +`cluster disk-types`; `help scaling` has a "Disk types" section. The guide section (`docs/custom-clusters.md` §10) +is made live and tested by CAP-061. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_types_ac6_help` | `tests/scenario/test_cluster_disk.py` | | + +## Out of scope +- Prices per disk type: Capella's API has no price list; the table says which types cost more. +- Checking Ultra availability per region: the API does not list it; Capella rejects it with its own message. + +## Implementation notes (as built) +- `capellactl/config/disks.yaml` (Capella documentation, read 2026-10-29; packaged in the wheel and the executable) + and its helpers in `config/presets.py`: `load_disk_types`, `default_iops`, `iops_range`. Google Cloud has no + documented maximum size, so only the minimum (50 GB) is checked. +- `cluster disk-types [--provider P]` (list layout, `-o json`); `cluster options` names each provider's types and + points to it (`diskTypes` in JSON). +- The disk picker (`_ask_disk`) is asked after the node size in the interactive custom create (one group and each + group of several) and in `cluster group add` (default: the data group's disk). Azure premium types ask nothing + more; Google Cloud asks the size and shows the IOPS it gives. +- `make_disk` now gives gp3, io2 and Ultra disks Capella's recommended IOPS for their size when none is given (it + sent 3000 for every size), and an Ultra disk the smallest Ultra size (64 GB, not 50). CAP-051 AC5's expected + bodies were updated (100 GB → 4370 IOPS, 200 GB → 5740). +- `cluster update --disk-type` changes **the group chosen with `--group` (default: the data group)**, like the other + update options, not every group as the design said. The size is kept when the new type allows it; IOPS become the + recommendation unless `--disk-iops` is given. The lines under the plan say what the type is for and that Capella + may replace and rebalance the nodes. +- **AC4 deviation:** sizes and IOPS outside a type's range are errors (exit 3), but IOPS **below the recommendation** + are a warning, not an error: clusters brought in by `sync` may have older, lower values that Capella still runs. + `--disk-iops` on Google Cloud is refused by the commands rather than by validation, for the same reason. + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_disk_types_ac1_list`, `test_disk_types_ac1_options_points_to_list` (tests/scenario/test_cluster_disk.py) | +| AC2 | `test_disk_types_ac2_create_picker`, `test_disk_types_ac2_group_add_picker` | +| AC3 | `test_disk_types_ac3_iops_default`, `test_disk_types_ac3_defaults_per_provider` (tests/unit/cli/test_make_disk.py) | +| AC4 | `test_disk_types_ac4_refused`, `test_disk_types_ac4_accepted`, `test_disk_types_ac4_below_recommended_warns` (tests/unit/config/test_crossref_disk.py) | +| AC5 | `test_disk_types_ac5_update_type`, `test_disk_types_ac5_rerun_noop_and_wrong_provider` | +| AC6 | `test_disk_types_ac6_help` | diff --git a/docs/release-1.0.0/CAP-061-backup-and-disk-guides.md b/docs/release-1.0.0/CAP-061-backup-and-disk-guides.md new file mode 100644 index 0000000..6ca52ed --- /dev/null +++ b/docs/release-1.0.0/CAP-061-backup-and-disk-guides.md @@ -0,0 +1,107 @@ +# CAP-061 — Guides: backup and restore, and choosing a disk type, as steps CI runs + +| Epic | Priority | Points | Depends on | Status | +|---|---|---|---|---| +| E10 Backup & restore | P1 | 3 | CAP-056, CAP-057, CAP-058, CAP-059, CAP-060 | Implemented | + +## User story +As a **customer operator**, I want one guide that walks me through backup and restore, and a section of the custom +cluster guide on disk types, with commands I can copy and the output I should see, so that I can try the new +features without reading the stories or the API reference. + +## Context & discovery +- Requested 2026-10-29 together with CAP-056–CAP-060: "update the commands for user to try by adding into the + appropriate guide book". +- Where the commands go, and why: + - **Backup and restore → a new guide, `docs/backup-restore.md`.** It spans cluster and bucket commands, schedules, + restores and capella.yaml, which is too much for a section of `getting-started.md` (already long, and run end to + end by CAP-033 AC5). It follows the shape of `docs/custom-clusters.md`: concepts in one table, numbered steps, + output, troubleshooting. The README Documentation table links it; `getting-started.md` points to it from + "Manage a provisioned cluster". + - **Disk types → `docs/custom-clusters.md` §10.** That guide already covers choosing node sizes per service group; + the disk is chosen in the same place (`--group … :DISK`, `cluster group add`, the interactive picker). + - **Day-2 tasks → `docs/runbook.md`**, as each backup story's AC7 says (set a schedule, back up before a change, + restore a cluster, restore a bucket, download a backup, change a disk type). +- The guide text was written with the stories (2026-10-29) and is in the repository already, marked *planned*, with + commands in ```` ```text ```` blocks. Only ```` ```bash ```` blocks are run or parsed by CI + (`tests/unit/docs/test_doc_commands.py`, `tests/scenario/test_custom_clusters_guide.py`), so the planned commands + do not break CI before they exist. This story makes them real. + +## Design +- When CAP-056–CAP-060 are Implemented: change the guides' ```` ```text ```` command blocks to ```` ```bash ```` (output + blocks stay plain), remove the *planned* notes in `docs/backup-restore.md`, `docs/custom-clusters.md` §10 and the + README row, and correct example output to what the commands print. +- `tests/scenario/test_backup_restore_guide.py` (new) runs every command of `docs/backup-restore.md`, in order, + against the fake Capella, like `test_custom_clusters_guide.py`; `test_custom_clusters_guide.py` gains the §10 + commands and asserts the disks the fake received. +- `help backup` (CAP-056) and `help scaling` (CAP-060) end with a pointer to the guide. + +## Acceptance criteria & tests + +### AC1 — The backup guide runs +**Given** the fake Capella with project `sandbox`, cluster `prod-01` and bucket `orders`, **then** every +`capellactl` line in `docs/backup-restore.md` runs in order with `--auto-approve` and exits 0 (or the exit code the +guide shows on purpose), and the fake holds the cluster schedule, the bucket schedule, one on-demand backup of each +kind and one restore of each kind. + +| Test | Location | Verifies | +|---|---|---| +| `test_backup_guide_ac1_steps_run` | `tests/scenario/test_backup_restore_guide.py` | | + +### AC2 — The disk section runs +**Given** `docs/custom-clusters.md` §10, **then** its commands run in `test_custom_clusters_guide.py` and the fake's +`prod-05` has `gp3 200 GB 5740 IOPS` on data, `io2` on search, and `io2 20000 IOPS` on data after the update. + +| Test | Location | Verifies | +|---|---|---| +| `test_disk_guide_ac2_steps_run` | `tests/scenario/test_custom_clusters_guide.py` | | + +### AC3 — No planned leftovers +**Given** the docs, **then** no guide, README row or help page says *planned* or *not available yet* for a command that +exists, and every command block of the two guides is ```` ```bash ````, so `test_docs_ac2_commands_parse` checks them. + +| Test | Location | Verifies | +|---|---|---| +| `test_backup_guide_ac3_no_planned_notes` | `tests/unit/docs/test_backup_guide.py` | | + +### AC4 — Easy to find +**Given** the README Documentation table, `getting-started.md` "Manage a provisioned cluster", `help backup` and +`help scaling`, **then** each links to the guide or section; `docs/runbook.md` has the six day-2 tasks above and its +commands are parsed in CI. + +| Test | Location | Verifies | +|---|---|---| +| `test_backup_guide_ac4_linked` | `tests/unit/docs/test_backup_guide.py` | | + +### AC5 — Works in Windows PowerShell +**Given** the new command lines, **then** none uses a bash-only construct without a row in the getting-started +PowerShell translation table (CAP-055 AC5 scan extended to `backup-restore.md`). + +| Test | Location | Verifies | +|---|---|---| +| `test_windows_docs_ac5_translation_table_complete` | `tests/unit/docs/test_windows_docs.py` | Extended file list | + +## Out of scope +- A video or slide walkthrough. + +## Implementation notes (as built) +- `docs/backup-restore.md`: nine steps, from the options to a download, with `bash` command blocks. IDs the reader + copies from a list are written `CLUSTER_BACKUP_ID` and `BUCKET_BACKUP_ID`; the test puts in the fake's newest. +- `docs/custom-clusters.md` §10 "Choose the disk type" is live; its commands create `prod-05` with gp3, add an io2 + search group and change the data group to io2. +- `docs/runbook.md`: Part 1 has "Change a disk type", "Set a cluster backup schedule", "Set a bucket backup + schedule", "Take a cluster backup before a change", "Restore a cluster from a backup", "Restore a bucket from a + backup" and "Download a bucket backup"; Part 2 has a "Backups" section, `cluster disk-types` and + `cluster update --disk-type`, and a corrected Azure disk note. +- README Documentation table, `getting-started.md` (step 11), `help backup` and `help scaling` link the guides. +- The CAP-055 PowerShell scan reads `backup-restore.md` too. + +### Tests by AC + +| AC | Tests | +|---|---| +| AC1 | `test_backup_guide_ac1_steps_run` (tests/scenario/test_backup_restore_guide.py) | +| AC2 | `test_disk_guide_ac2_steps_run` (tests/scenario/test_custom_clusters_guide.py) | +| AC3 | `test_backup_guide_ac3_no_planned_notes` (tests/unit/docs/test_backup_guide.py) | +| AC4 | `test_backup_guide_ac4_linked` | +| AC5 | `test_backup_guide_ac5_powershell_scan_covers_it` | diff --git a/docs/release-1.0.0/README.md b/docs/release-1.0.0/README.md index 64cbc4e..bc8bbc0 100644 --- a/docs/release-1.0.0/README.md +++ b/docs/release-1.0.0/README.md @@ -8,19 +8,19 @@ Architecture, domain model, conventions, NFRs and the global Definition of Done: | Metric | Stories | Story points | |---|---|---| -| **Total** | **54** | **223** | -| ✅ Implemented | 53 | 218 | -| 🚧 In progress | 1 | 5 | +| **Total** | **61** | **257** | +| ✅ Implemented | 59 | 249 | +| 🚧 In progress | 2 | 8 | | ⬜ Not started | 0 | 0 | -| Not yet implemented (in progress + not started) | 1 | 5 | -| **Completion** | 98% | 98% | +| Not yet implemented (in progress + not started) | 2 | 8 | +| **Completion** | 97% | 97% | ### By priority | Priority | Meaning | Stories | Points | Implemented (stories / points) | |---|---|---|---|---| -| P0 | Must have — release blocker | 44 | 184 | 43 / 179 | -| P1 | Should have — ships in 1.0 unless explicitly descoped | 10 | 39 | 10 / 39 | +| P0 | Must have — release blocker | 45 | 187 | 43 / 179 | +| P1 | Should have — ships in 1.0 unless explicitly descoped | 16 | 70 | 16 / 70 | ### By epic @@ -28,17 +28,18 @@ Architecture, domain model, conventions, NFRs and the global Definition of Done: |---|---|---|---| | E1 Foundation | 11 | 46 | 11 / 46 | | E2 Projects | 5 | 20 | 5 / 20 | -| E3 Clusters | 9 | 48 | 9 / 48 | +| E3 Clusters | 10 | 53 | 10 / 53 | | E4 Scaling | 5 | 21 | 5 / 21 | | E5 Hibernation | 3 | 10 | 3 / 10 | | E6 Network access | 1 | 3 | 1 / 3 | | E7 Data model | 4 | 14 | 4 / 14 | | E8 Database credentials | 4 | 12 | 4 / 12 | -| E9 Lifecycle & release | 12 | 49 | 11 / 44 | +| E9 Lifecycle & release | 13 | 52 | 11 / 44 | +| E10 Backup & restore | 5 | 26 | 5 / 26 | ## Stories by priority -### P0 — Must have — release blocker (44 stories, 184 points) +### P0 — Must have — release blocker (45 stories, 187 points) | # | ID | Story | Epic | Points | Status | |---|---|---|---|---|---| @@ -86,8 +87,9 @@ Architecture, domain model, conventions, NFRs and the global Definition of Done: | 42 | ~~[CAP-048](CAP-048-track-resources-by-id.md)~~ | ~~Track projects and clusters by id so renames in the Capella UI are not mistaken for new resources~~ | ~~E9 Lifecycle & release~~ | ~~5~~ | ✅ Implemented | | 43 | ~~[CAP-050](CAP-050-protect-main-branch.md)~~ | ~~Protect `main`: changes only through a reviewed pull request~~ | ~~E9 Lifecycle & release~~ | ~~3~~ | ✅ Implemented | | 44 | ~~[CAP-051](CAP-051-multi-dimensional-cluster-create.md)~~ | ~~Create multi-dimensional (MDS) clusters from the CLI~~ | ~~E3 Clusters~~ | ~~13~~ | ✅ Implemented | +| 45 | [CAP-055](CAP-055-windows-install-steps.md) | Install and run capellactl on Windows: step-by-step PowerShell table in the README | E9 Lifecycle & release | 3 | 🚧 In progress | -### P1 — Should have — ships in 1.0 unless explicitly descoped (10 stories, 39 points) +### P1 — Should have — ships in 1.0 unless explicitly descoped (16 stories, 70 points) | # | ID | Story | Epic | Points | Status | |---|---|---|---|---|---| @@ -101,6 +103,12 @@ Architecture, domain model, conventions, NFRs and the global Definition of Done: | 8 | ~~[CAP-052](CAP-052-service-group-commands.md)~~ | ~~Service group commands: list, add and remove groups on an existing cluster~~ | ~~E4 Scaling~~ | ~~5~~ | ✅ Implemented | | 9 | ~~[CAP-053](CAP-053-advanced-cluster-create-options.md)~~ | ~~Advanced cluster create options: server version, single node, zone, CMEK, private DNS, disk auto-expansion~~ | ~~E3 Clusters~~ | ~~5~~ | ✅ Implemented | | 10 | ~~[CAP-054](CAP-054-one-sync-command.md)~~ | ~~One command to bring capella.yaml in line with Capella: `sync` covers the whole organization~~ | ~~E9 Lifecycle & release~~ | ~~5~~ | ✅ Implemented | +| 11 | ~~[CAP-056](CAP-056-cluster-backup-schedule.md)~~ | ~~Cluster backup schedule (cloud snapshots) as code~~ | ~~E10 Backup & restore~~ | ~~5~~ | ✅ Implemented | +| 12 | ~~[CAP-057](CAP-057-cluster-backup-and-restore.md)~~ | ~~Cluster backups: list, back up now, keep longer, delete and restore~~ | ~~E10 Backup & restore~~ | ~~5~~ | ✅ Implemented | +| 13 | ~~[CAP-058](CAP-058-bucket-backup-schedule.md)~~ | ~~Bucket backup schedule as code, and backup cycles~~ | ~~E10 Backup & restore~~ | ~~5~~ | ✅ Implemented | +| 14 | ~~[CAP-059](CAP-059-bucket-backup-and-restore.md)~~ | ~~Bucket backups: list, back up now, delete, restore and export~~ | ~~E10 Backup & restore~~ | ~~8~~ | ✅ Implemented | +| 15 | ~~[CAP-060](CAP-060-choose-disk-type.md)~~ | ~~Choose the disk type, and see what each type means~~ | ~~E3 Clusters~~ | ~~5~~ | ✅ Implemented | +| 16 | ~~[CAP-061](CAP-061-backup-and-disk-guides.md)~~ | ~~Guides: backup and restore, and choosing a disk type, as steps CI runs~~ | ~~E10 Backup & restore~~ | ~~3~~ | ✅ Implemented | ## Delivery phases @@ -112,8 +120,9 @@ Stories are delivered in phases; each phase ends with something an operator can | Phase 1 — Project & cluster | ~~CAP-009~~, ~~CAP-039~~, ~~CAP-040~~, ~~CAP-010~~, ~~CAP-011~~, ~~CAP-035~~, ~~CAP-012~~, ~~CAP-038~~, ~~CAP-041~~, ~~CAP-049~~, ~~CAP-013~~, ~~CAP-014~~, ~~CAP-022~~ | 53 | 13/13 (53 pts) | ✅ Done | Answer the init wizard, then create, edit, protect and delete a project and cluster with its IP allowlist, idempotently | | Phase 2 — Scale & hibernate | ~~CAP-015~~, ~~CAP-016~~, ~~CAP-017~~, ~~CAP-018~~, ~~CAP-019~~, ~~CAP-020~~, ~~CAP-021~~ | 26 | 7/7 (26 pts) | ✅ Done | Scale clusters out, in and up safely; power them off and on; run a weekly hibernation schedule | | Phase 3 — Data model & access | ~~CAP-023~~, ~~CAP-024~~, ~~CAP-025~~, ~~CAP-026~~, ~~CAP-027~~, ~~CAP-046~~, ~~CAP-028~~, ~~CAP-029~~ | 26 | 8/8 (26 pts) | ✅ Done | Buckets, scopes, collections and least-privilege database credentials, ready for applications | -| Phase 4 — Adopt, harden & ship | ~~CAP-030~~, ~~CAP-036~~, ~~CAP-047~~, ~~CAP-048~~, ~~CAP-031~~, ~~CAP-042~~, CAP-032, ~~CAP-044~~, ~~CAP-043~~, ~~CAP-045~~, ~~CAP-033~~, ~~CAP-050~~, ~~CAP-054~~ | 52 | 12/13 (47 pts) | 🚧 Current | Import existing clusters with a no-change round trip, one-command build and teardown, drift reporting, nightly integration gate and customer docs | -| Phase 5 — Custom cluster layouts | ~~CAP-051~~, ~~CAP-052~~, ~~CAP-053~~ | 23 | 3/3 (23 pts) | ✅ Done | Create a cluster with any size, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone | +| Phase 4 — Adopt, harden & ship | ~~CAP-030~~, ~~CAP-036~~, ~~CAP-047~~, ~~CAP-048~~, ~~CAP-031~~, ~~CAP-042~~, CAP-032, ~~CAP-044~~, ~~CAP-043~~, ~~CAP-045~~, ~~CAP-033~~, ~~CAP-050~~, ~~CAP-054~~, CAP-055 | 55 | 12/14 (47 pts) | 🚧 Current | Import existing clusters with a no-change round trip, one-command build and teardown, drift reporting, nightly integration gate and customer docs | +| Phase 5 — Custom cluster layouts | ~~CAP-051~~, ~~CAP-052~~, ~~CAP-053~~, ~~CAP-060~~ | 28 | 4/4 (28 pts) | ✅ Done | Create a cluster with any size, disk type, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone | +| Phase 6 — Backup & restore | ~~CAP-056~~, ~~CAP-057~~, ~~CAP-058~~, ~~CAP-059~~, ~~CAP-061~~ | 26 | 5/5 (26 pts) | ✅ Done | Schedule cluster and bucket backups as code, list them, back up now and restore a cluster or a bucket, from commands alone | ## Stories by phase @@ -185,7 +194,7 @@ _Buckets, scopes, collections and least-privilege database credentials, ready fo | 7 | [CAP-028](CAP-028-password-rotation.md) | Password rotation | P0 | E8 Database credentials | 3 | CAP-027, CAP-008 | ✅ Implemented | | 8 | [CAP-029](CAP-029-delete-database-credential.md) | Delete database credential | P0 | E8 Database credentials | 1 | CAP-027 | ✅ Implemented | -### Phase 4 — Adopt, harden & ship — 🚧 Current (12/13 stories, 47/52 points) +### Phase 4 — Adopt, harden & ship — 🚧 Current (12/14 stories, 47/55 points) _Import existing clusters with a no-change round trip, one-command build and teardown, drift reporting, nightly integration gate and customer docs._ @@ -204,16 +213,30 @@ _Import existing clusters with a no-change round trip, one-command build and tea | 11 | [CAP-033](CAP-033-docs-examples-runbook.md) | Examples, runbook & customer documentation | P0 | E9 Lifecycle & release | 3 | CAP-030 | ✅ Implemented | | 12 | [CAP-050](CAP-050-protect-main-branch.md) | Protect `main`: changes only through a reviewed pull request | P0 | E9 Lifecycle & release | 3 | CAP-001, CAP-043 | ✅ Implemented | | 13 | [CAP-054](CAP-054-one-sync-command.md) | One command to bring capella.yaml in line with Capella: `sync` covers the whole organization | P1 | E9 Lifecycle & release | 5 | CAP-036, CAP-038, CAP-040 | ✅ Implemented | +| 14 | [CAP-055](CAP-055-windows-install-steps.md) | Install and run capellactl on Windows: step-by-step PowerShell table in the README | P0 | E9 Lifecycle & release | 3 | CAP-033, CAP-045 | 🚧 In progress | -### Phase 5 — Custom cluster layouts — ✅ Done (3/3 stories, 23/23 points) +### Phase 5 — Custom cluster layouts — ✅ Done (4/4 stories, 28/28 points) -_Create a cluster with any size, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone._ +_Create a cluster with any size, disk type, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone._ | # | ID | Story | Priority | Epic | Points | Depends on | Status | |---|---|---|---|---|---|---|---| | 1 | [CAP-051](CAP-051-multi-dimensional-cluster-create.md) | Create multi-dimensional (MDS) clusters from the CLI | P0 | E3 Clusters | 13 | CAP-011, CAP-015, CAP-038, CAP-049 | ✅ Implemented | | 2 | [CAP-052](CAP-052-service-group-commands.md) | Service group commands: list, add and remove groups on an existing cluster | P1 | E4 Scaling | 5 | CAP-015, CAP-016, CAP-038, CAP-051 | ✅ Implemented | | 3 | [CAP-053](CAP-053-advanced-cluster-create-options.md) | Advanced cluster create options: server version, single node, zone, CMEK, private DNS, disk auto-expansion | P1 | E3 Clusters | 5 | CAP-011, CAP-038, CAP-051 | ✅ Implemented | +| 4 | [CAP-060](CAP-060-choose-disk-type.md) | Choose the disk type, and see what each type means | P1 | E3 Clusters | 5 | CAP-017, CAP-041, CAP-051, CAP-052 | ✅ Implemented | + +### Phase 6 — Backup & restore — ✅ Done (5/5 stories, 26/26 points) + +_Schedule cluster and bucket backups as code, list them, back up now and restore a cluster or a bucket, from commands alone._ + +| # | ID | Story | Priority | Epic | Points | Depends on | Status | +|---|---|---|---|---|---|---|---| +| 1 | [CAP-056](CAP-056-cluster-backup-schedule.md) | Cluster backup schedule (cloud snapshots) as code | P1 | E10 Backup & restore | 5 | CAP-011, CAP-020, CAP-038 | ✅ Implemented | +| 2 | [CAP-057](CAP-057-cluster-backup-and-restore.md) | Cluster backups: list, back up now, keep longer, delete and restore | P1 | E10 Backup & restore | 5 | CAP-007, CAP-056 | ✅ Implemented | +| 3 | [CAP-058](CAP-058-bucket-backup-schedule.md) | Bucket backup schedule as code, and backup cycles | P1 | E10 Backup & restore | 5 | CAP-023, CAP-056 | ✅ Implemented | +| 4 | [CAP-059](CAP-059-bucket-backup-and-restore.md) | Bucket backups: list, back up now, delete, restore and export | P1 | E10 Backup & restore | 8 | CAP-007, CAP-058 | ✅ Implemented | +| 5 | [CAP-061](CAP-061-backup-and-disk-guides.md) | Guides: backup and restore, and choosing a disk type, as steps CI runs | P1 | E10 Backup & restore | 3 | CAP-056, CAP-057, CAP-058, CAP-059, CAP-060 | ✅ Implemented | ## Release checklist @@ -224,5 +247,7 @@ _Create a cluster with any size, multi-dimensional service layout and create opt - [ ] Every open question in the story files is answered or explicitly deferred to 1.1 - [ ] Import round trip (CAP-036 AC2) shows no changes against at least one real customer-like environment in the sandbox - [ ] Time-to-first-environment check done by an engineer new to the tool against a real organization: under 60 minutes (release check; CAP-033 AC5 runs the same guide path against the fake in CI) +- [ ] The same time-to-first-environment check done once on a Windows laptop in Windows PowerShell, following the README's Windows install table (CAP-055) - [ ] Source archive, wheel and SHA256SUMS built with `make dist` and attached to the GitHub Release (CAP-043); container image published; standalone executables for macOS, Linux and Windows in `downloads/` and listed in the README Download section (CAP-045); README, runbook and examples validated (CAP-033) +- [ ] A cluster backup and a bucket backup restored in the sandbox organization, and the restored data checked (CAP-057, CAP-059) - [ ] `main` and `v*` tag rulesets active and `make rulesets-check` green before the repository is made public (CAP-050) diff --git a/docs/release-2.0.0/API-COVERAGE-APP-SERVICES.md b/docs/release-2.0.0/API-COVERAGE-APP-SERVICES.md index 819b108..9062fe3 100644 --- a/docs/release-2.0.0/API-COVERAGE-APP-SERVICES.md +++ b/docs/release-2.0.0/API-COVERAGE-APP-SERVICES.md @@ -1,10 +1,10 @@ # Management API coverage — App Services (release 2.0.0) -> Generated by `scripts/update-api-coverage.py` from the Capella Management API v4.0 spec ([source](https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml), snapshot of 2026-10-06). Every App Services operation in the spec appears exactly once. Operational-database coverage for 1.0.0 is in [release-1.0.0/API-COVERAGE.md](../release-1.0.0/API-COVERAGE.md). Don't edit by hand. +> Generated by `scripts/update-api-coverage.py` from the Capella Management API v4.0 spec ([source](https://docs.couchbase.com/cloud/management-api-reference/_attachments/openapi.external.generated.yaml), snapshot of 2026-10-29). Every App Services operation in the spec appears exactly once. Operational-database coverage for 1.0.0 is in [release-1.0.0/API-COVERAGE.md](../release-1.0.0/API-COVERAGE.md). Don't edit by hand. ## Scope -The spec has **285 operations**. This document tracks the **73 App Services and App Endpoints operations**, including the 4 free-tier App Service operations that the spec tags *Free Tier*. Release 1.0.0 covers the operational database. The AI operations (AI Data Plane, AI Workflows, Models) are not planned for now. +The spec has **343 operations**. This document tracks the **73 App Services and App Endpoints operations**, including the 4 free-tier App Service operations that the spec tags *Free Tier*. Release 1.0.0 covers the operational database. The AI operations (AI Data Plane, AI Workflows, Models) are not planned for now. No stories are written for 2.0.0 yet. Every row is ⏭️ **Not started**, and every command name is a proposal that follows the 1.0.0 conventions: noun-verb, declarative through `plan`/`apply`, and idempotent. diff --git a/docs/runbook.md b/docs/runbook.md index 88983b0..e98d018 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -10,7 +10,8 @@ How to run Capella with capellactl after the first environment is up (new to cap Every command that changes something shows the plan, asks once (`[y/N]`), records the change in `capella.yaml` (keeping the previous version as `capella.yaml.bak`) and applies it. Add `--auto-approve` (or `-y`, before the command) to skip the question, `--quiet` (`-q`) for short output, or `--no-apply` (after the command) to only record -the change and deploy it later with `capellactl apply`. In a pipeline, see `capellactl help ci`. +the change and deploy it later with `capellactl apply`. In a pipeline, see `capellactl help ci`. On Windows, see +[Using the commands in PowerShell](getting-started.md#using-the-commands-in-powershell). Current status of every command: [API-COVERAGE.md](release-1.0.0/API-COVERAGE.md). Every field of `capella.yaml`: [reference.md](reference.md). @@ -263,6 +264,135 @@ Add 203.0.113.10/32 to capella.yaml and allow it on nova-dev-01? [y/N]: y `0.0.0.0/0` (any address) is refused unless you add `--allow-any-ip`. +## Change a disk type + +Each service group has its own disk; `capellactl cluster disk-types` says what each type is for, how large it can be +and how fast it is. Without `--disk-iops`, capellactl uses Capella's recommended IOPS for the disk size. + +```bash +capellactl cluster disk-types --provider aws +capellactl cluster update nova-dev-01 --group data,index,query --disk-type io2 +``` + +```text +S.NO PROVIDER TYPE SIZE (GB) IOPS FOR +1 aws gp3 50–16000 3000–16000 general-purpose SSD (default) +2 aws io2 50–16000 3000–64000 provisioned-IOPS SSD for latency-sensitive, write-heavy workloads; costs more + + ~ cluster nova-dev-01 scale up + serviceGroups[data,index,query].node.disk.type: "gp3" → "io2" + +Plan: 0 to add, 1 to change, 0 to delete, 0 blocked. + disk type io2: provisioned-IOPS SSD for latency-sensitive, write-heavy workloads; costs more (IOPS 3000–64000) + AWS changes a disk in place at most once every 6 hours; otherwise Capella replaces and rebalances the nodes +Record these changes in capella.yaml and apply them to nova-dev-01? [y/N]: y +``` + +## Set a cluster backup schedule + +Cloud snapshot backups of the whole cluster. The intervals and retention allowed depend on the support plan. + +```bash +capellactl cluster backup schedule options nova-dev-01 +capellactl cluster backup schedule set nova-dev-01 --every 24 --retain 30d --start 02:00 +``` + +```text +Cluster backup schedule options for nova-dev-01 (support plan: developer pro) + every (hours) 1, 2, 4, 6, 8, 12, 24 + keep 1 day to 90 days + copy regions us-east-2, us-west-2, eu-west-1 (up to two) + + + backup-schedule nova-dev-01 every 24 h · kept 30 days · first at 02:00 UTC + +Plan: 1 to add, 0 to change, 0 to delete, 0 blocked. +Record this backup schedule in capella.yaml and apply it to nova-dev-01? [y/N]: y +``` + +## Set a bucket backup schedule + +A weekly full backup of the bucket, then incrementals. Without options: Sunday 00:00 UTC, every 24 hours, 30 days. + +```bash +capellactl bucket backup schedule set nova-dev-01 orders --start-at 2 --incremental-every 12 +``` + +```text + + bucket-backup-schedule nova-dev-01/orders weekly full on sunday at 02:00 UTC · incremental every 12 h · kept 30 days + +Plan: 1 to add, 0 to change, 0 to delete, 0 blocked. +Record this backup schedule in capella.yaml and apply it to bucket orders? [y/N]: y +``` + +## Take a cluster backup before a change + +```bash +capellactl cluster backup create nova-dev-01 +capellactl cluster backup list nova-dev-01 +``` + +```text +→ backing up cluster nova-dev-01 … + backup of nova-dev-01: pending (0s) + backup of nova-dev-01: complete (6m10s) +backup 3f1c… of nova-dev-01 is complete + +S.NO CREATED TYPE STATUS SIZE EXPIRES COPIES ID +1 2026-10-29 10:12 onDemand complete 42 GB 2026-11-28 10:12 - 3f1c… +``` + +`capellactl bucket backup create nova-dev-01 orders` backs up one bucket the same way. + +## Restore a cluster from a backup + +All data of the cluster is replaced with the backup; you type the cluster name to confirm. + +```bash +capellactl cluster backup restore nova-dev-01 3f1c… +``` + +```text + ± cluster nova-dev-01 all data is replaced with the backup of 2026-10-29 10:12 UTC +Type the cluster name to confirm the restore: nova-dev-01 +→ restoring cluster nova-dev-01 … + restore of nova-dev-01: complete (12m30s) + cluster nova-dev-01: healthy (13m02s) +restored cluster nova-dev-01 from the backup of 2026-10-29 10:12 UTC +``` + +## Restore a bucket from a backup + +Documents that are newer in the target are kept unless `--force-updates`. `--to-cluster` restores into another +cluster of the project; `--include-data orders.sales` restores one collection. + +```bash +capellactl bucket backup list nova-dev-01 orders +capellactl bucket backup restore nova-dev-01 7a2e… --replace-ttl none +``` + +```text + ~ restore orders from the backup of 2026-10-29 12:00 UTC into nova-dev-01 + services: data · newer documents in nova-dev-01 are kept +Type the target cluster name to confirm the restore: nova-dev-01 +restore of orders into nova-dev-01 started; Capella reports its progress in the cluster's activity log (the Management API has no restore status for bucket backups) +``` + +## Download a bucket backup + +```bash +capellactl bucket backup export nova-dev-01 7a2e… +``` + +```text +export 9d78… of orders started + export of orders: complete (4m20s) +export 9d78… is complete: 3.1 GB, available until 2026-10-29 22:00 UTC +download (valid for one hour; treat it like a password): + https://… +``` + +A new link: `capellactl bucket backup export nova-dev-01 7a2e… --export-id 9d78…`. + ## Recover from deploymentFailed A cluster that failed to deploy blocks every plan and apply that includes it (exit 4): @@ -399,6 +529,7 @@ capellactl project delete $PROJECT --cascade # deletes its clusters fir ```bash capellactl cluster options # what you can deploy (free tier, sizes, regions) +capellactl cluster disk-types --provider azure # disk types: size range, IOPS, what each is for capellactl cluster list capellactl cluster get $CLUSTER # create: each line is an alternative way to deploy $CLUSTER (a name can exist once per project) @@ -414,6 +545,7 @@ capellactl cluster clone # asks: source project capellactl cluster update $CLUSTER --support-plan enterprise capellactl cluster update $CLUSTER --description "Nova dev" capellactl cluster update $CLUSTER --cpu 8 --ram 32 # bigger nodes; less RAM runs the memory check; multi-node clusters +capellactl cluster update $CLUSTER --disk-type P15 # another disk type for the data group (--group SERVICES) capellactl cluster scale $SCALE_CLUSTER --nodes 4 # scale out; multi-node clusters (a --size dev cluster stays 1 node) capellactl cluster scale $SCALE_CLUSTER --nodes 3 # scale in: memory and replica checks first capellactl cluster scale $SCALE_CLUSTER --nodes 2 --group search @@ -453,7 +585,33 @@ capellactl cluster schedule delete $CLUSTER # removes it fr Windows are `HH:MM-HH:MM` with minutes `00` or `30` (`24:00` = end of day); a day is `on`, `off` or a window; a week cannot be off every day. -Azure disks: the disk type sets the size (`P6` default, `P10`, `P15`, `P20`, `P30` … `P60`, `Ultra`); choose it with `--disk-type` at create. `--disk-size` and `--disk-iops` are for AWS/GCP disks. On another provider, use `--provider aws --region us-east-1` or `--provider gcp --region us-east1`. +Azure disks: the disk type sets the size and speed (`P6` default, `P10`, `P15`, `P20`, `P30` … `P60`); `Ultra` takes `--disk-size` and `--disk-iops`. AWS: `gp3` or `io2` with `--disk-size` and `--disk-iops`; Google Cloud: `pd-ssd`, whose speed follows its size. All of them: `capellactl cluster disk-types`. + +## Backups + +Cluster backups (cloud snapshots of the whole cluster) and bucket backups: `capellactl help backup`, step by step in +[backup-restore.md](backup-restore.md). + +```bash +capellactl cluster backup schedule options $CLUSTER # intervals and retention the support plan allows +capellactl cluster backup schedule set $CLUSTER --every 24 --retain 30d --start 02:00 +capellactl cluster backup schedule show $CLUSTER +capellactl cluster backup schedule delete $CLUSTER # existing backups are kept until they expire +capellactl cluster backup list $CLUSTER # without a name: every cluster of the project +capellactl cluster backup create $CLUSTER # waits until complete (--no-wait returns at once) +capellactl cluster backup retain $CLUSTER $BACKUP_ID --retain 90d +capellactl cluster backup restore $CLUSTER $BACKUP_ID # replaces all data; type the name to confirm +capellactl cluster backup restores $CLUSTER +capellactl cluster backup delete $CLUSTER $BACKUP_ID +capellactl bucket backup schedule set $CLUSTER orders # Sunday 00:00 UTC, every 24 h, 30 days +capellactl bucket backup schedule show $CLUSTER orders +capellactl bucket backup cycle list $CLUSTER orders +capellactl bucket backup list $CLUSTER orders +capellactl bucket backup create $CLUSTER orders +capellactl bucket backup restore $CLUSTER $BACKUP_ID --replace-ttl none +capellactl bucket backup export $CLUSTER $BACKUP_ID # prints a download link valid for one hour +capellactl bucket backup delete $CLUSTER $BACKUP_ID # deletes the backup's whole cycle +``` ## Allowed IP ranges diff --git a/downloads/README.md b/downloads/README.md index 72398ab..999fa92 100644 --- a/downloads/README.md +++ b/downloads/README.md @@ -41,5 +41,15 @@ The executables are not code-signed, so check the SHA-256 first (above), then al when SmartScreen warns. Company policy (AppLocker, WDAC) may need IT to allow the file's hash. - **Linux**: nothing more than `chmod +x`. +To type `capellactl` from any folder on Windows, install it for your user (no administrator rights needed), then +open a new PowerShell window: + +```powershell +mkdir $env:LOCALAPPDATA\Programs\capellactl -Force +Move-Item .\capellactl-*-windows-x86_64.exe $env:LOCALAPPDATA\Programs\capellactl\capellactl.exe +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +[Environment]::SetEnvironmentVariable('Path', "$userPath;$env:LOCALAPPDATA\Programs\capellactl", 'User') +``` + Each executable carries `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.md` and `BINARY_NOTICES.md`; the same files are attached to the release next to it. diff --git a/pyproject.toml b/pyproject.toml index 83ae7ec..6f5e6bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ packages = ["capellactl"] "capellactl/config/schema.json" = "capellactl/config/schema.json" "capellactl/config/presets.yaml" = "capellactl/config/presets.yaml" "capellactl/config/compute.yaml" = "capellactl/config/compute.yaml" +"capellactl/config/disks.yaml" = "capellactl/config/disks.yaml" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/readme-windows-steps.py b/scripts/readme-windows-steps.py new file mode 100644 index 0000000..a165488 --- /dev/null +++ b/scripts/readme-windows-steps.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Turn the README's Windows install table into a PowerShell script for CI (CAP-055 AC3). + + python scripts/readme-windows-steps.py --source DIR --workdir DIR --out steps.ps1 [--python PATH] + +The commands come from the table between and , so a +change to the README changes what CI runs. CI differs from a customer's laptop in three places only: +- step 1 (`winget install`) is skipped: the runner already has Git and Python; +- `git clone` clones the checked-out commit (`--source`) instead of GitHub's main branch; +- `Set-ExecutionPolicy -Scope CurrentUser` becomes `-Scope Process … -Force`: no prompt, and the runner's settings + are left alone. The script first sets the process policy to Restricted, as on a new Windows laptop, so + `Activate.ps1` only runs if the README's policy step works. +`--python PATH` replaces `py -3.12`, for runners where the py launcher does not list setup-python's interpreter. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +README = ROOT / "README.md" +START, END = "", "" +CLONE_URL = "https://github.com/couchbaselabs/capellactl.git" + + +def steps(readme: str) -> list[tuple[str, list[str]]]: + """Each table row as (step label, its commands in order).""" + if START not in readme or END not in readme: + raise ValueError(f"README has no {START} … {END} table") + out = [] + for line in readme[readme.index(START) + len(START):readme.index(END)].splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3 or not re.match(r"\d+\.", cells[0]): + continue + out.append((cells[0], re.findall(r"`([^`]+)`", cells[1]))) + return out + + +def ci_commands(table: list[tuple[str, list[str]]], source: str, python: str | None = None) -> list[str]: + cmds = [] + for _, commands in table: + if commands and all(c.startswith("winget ") for c in commands): + continue + for c in commands: + c = c.replace(CLONE_URL, f'"{source}" capellactl') + c = c.replace("-Scope CurrentUser RemoteSigned", "-Scope Process RemoteSigned -Force") + if python: + c = c.replace("py -3.12", f'& "{python}"') + cmds.append(c) + return cmds + + +def ci_script(commands: list[str], workdir: str) -> str: + lines = ["Set-ExecutionPolicy -Scope Process Restricted -Force # as on a new Windows laptop", + f'Set-Location (New-Item -ItemType Directory -Force "{workdir}")'] + for c in commands: + quoted = c.replace("'", "''") + lines += [f"Write-Host '> {quoted}'", "$global:LASTEXITCODE = 0", c, + f"if (-not $? -or $LASTEXITCODE) {{ Write-Host 'README step failed: {quoted}'; exit 1 }}"] + return "\n".join(lines) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--source", required=True, help="checkout to clone instead of GitHub") + ap.add_argument("--workdir", required=True, help="empty folder to run the steps in") + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--python", help="interpreter to use instead of `py -3.12`") + a = ap.parse_args() + commands = ci_commands(steps(README.read_text(encoding="utf-8")), a.source, a.python) + a.out.write_text(ci_script(commands, a.workdir), encoding="utf-8-sig") # BOM: Windows PowerShell reads UTF-8 + print(f"{a.out}: {len(commands)} README commands") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update-api-coverage.py b/scripts/update-api-coverage.py index 9a89b6b..fff8959 100644 --- a/scripts/update-api-coverage.py +++ b/scripts/update-api-coverage.py @@ -64,15 +64,16 @@ def cmd(name: str, stories: str = "", left: str = "") -> None: cmd("cluster options", "CAP-041") cmd("cluster list", "CAP-038") cmd("cluster get", "CAP-038, CAP-041") -cmd("cluster create", "CAP-011, CAP-038, CAP-041, CAP-051, CAP-053", +cmd("cluster create", "CAP-011, CAP-038, CAP-041, CAP-051, CAP-053, CAP-060", "Multi-dimensional service groups (`--group SPEC`, CAP-051); `--server-version`, `--single-node`, `--zone`, " - "`--cmek-id`, `--private-dns`, `--disk-auto-expansion` (CAP-053).") -cmd("cluster update", "CAP-012, CAP-017, CAP-053", "Lowering RAM runs the CAP-016 memory guard (CAP-017 AC6); " - "`--disk-auto-expansion` on Azure (CAP-053).") + "`--cmek-id`, `--private-dns`, `--disk-auto-expansion` (CAP-053); disk type picker and size-based IOPS (CAP-060).") +cmd("cluster update", "CAP-012, CAP-017, CAP-053, CAP-060", "Lowering RAM runs the CAP-016 memory guard (CAP-017 AC6); " + "`--disk-auto-expansion` on Azure (CAP-053); `--disk-type` (CAP-060).") cmd("cluster scale", "CAP-015, CAP-016, CAP-018", "Scale out and in (capacity checks); `--step ±1` within `--min/--max`, `--all-groups`.") cmd("cluster group list", "CAP-052", "Service groups with nodes, compute and disk.") -cmd("cluster group add", "CAP-052", "Adds a service group; size defaults to the data group.") +cmd("cluster group add", "CAP-052, CAP-060", "Adds a service group; size defaults to the data group; disk type picker " + "(CAP-060).") cmd("cluster group remove", "CAP-052", "Removes a service group with the CAP-016 scale-in guards.") cmd("cluster clone", "CAP-049", "Copies size, services, buckets, scopes and collections to another region; " "`--with-cidrs`, `--with-credentials` (new passwords).") @@ -102,6 +103,16 @@ def cmd(name: str, stories: str = "", left: str = "") -> None: cmd("credential create", "CAP-027, CAP-046") cmd("credential update", "CAP-027, CAP-028, CAP-046") cmd("credential delete", "CAP-029") +for n in ("show", "options", "set", "delete"): + cmd(f"cluster backup schedule {n}", "CAP-056", "Cloud snapshot schedule; `backupSchedule` in capella.yaml.") +for n in ("list", "create", "retain", "delete", "restore", "restores", "regions"): + cmd(f"cluster backup {n}", "CAP-057", "Cloud snapshot backups of the whole cluster.") +for n in ("show", "set", "delete"): + cmd(f"bucket backup schedule {n}", "CAP-058", "Weekly full + incremental; `backupSchedule` under the bucket.") +for n in ("list", "get"): + cmd(f"bucket backup cycle {n}", "CAP-058") +for n in ("list", "get", "create", "delete", "restore", "export"): + cmd(f"bucket backup {n}", "CAP-059", "Bucket backups; `delete` removes the whole cycle.") PROPOSED = """ org update @@ -113,10 +124,7 @@ def cmd(name: str, stories: str = "", left: str = "") -> None: db-role privileges|db-role list|db-role get|db-role create|db-role update|db-role delete bucket flush sample-bucket load|sample-bucket list|sample-bucket get|sample-bucket delete -backup create|backup list|backup get|backup delete|backup restore -backup schedule show|backup schedule set|backup schedule delete|backup cycle list|backup cycle get -snapshot create|snapshot list|snapshot restores|snapshot regions|snapshot update|snapshot delete|snapshot restore|snapshot clone -snapshot schedule show|snapshot schedule set|snapshot schedule delete +cluster backup clone peer create|peer list|peer get|peer delete|peer azure-command private-endpoint show|private-endpoint enable|private-endpoint update|private-endpoint disable|private-endpoint list|private-endpoint command|private-endpoint accept|private-endpoint reject alert create|alert list|alert channels|alert get|alert update|alert delete|alert test @@ -126,6 +134,11 @@ def cmd(name: str, stories: str = "", left: str = "") -> None: data-api show|data-api update|data-api endpoint-command|data-api endpoint list|data-api endpoint accept|data-api endpoint reject billing get|billing prepaid|billing payg|billing download eventing list|eventing get|eventing create|eventing update|eventing delete|eventing code get|eventing code set|eventing state|eventing logs +insights list|insights get|insights create|insights update|insights delete|insights power on|insights power off +insights schedule show|insights schedule set|insights schedule delete|insights cidr list|insights cidr get|insights cidr create|insights cidr delete +insights backup list|insights backup create|insights backup retain|insights backup delete|insights backup restore|insights backup restores +insights backup schedule show|insights backup schedule set|insights backup schedule delete +insights private-endpoint show|insights private-endpoint enable|insights private-endpoint disable|insights private-endpoint command|insights private-endpoint list|insights private-endpoint accept|insights private-endpoint reject """ for _line in PROPOSED.strip().splitlines(): for _n in _line.split("|"): @@ -149,6 +162,8 @@ def cmd(name: str, stories: str = "", left: str = "") -> None: ("destroy", "CAP-030", "Deletes every declared resource in reverse order; capella.yaml is kept."), ("drift", "CAP-031", "Read-only: changed, missing and unmanaged resources; `--detailed-exitcode`."), ("help / completion", "CAP-034", "No API calls."), + ("cluster disk-types", "CAP-060", "No API calls: disk types, sizes and IOPS from `config/disks.yaml` (Capella's " + "documentation; the spec has only the type names)."), ] # ======================================================================== operation map @@ -297,32 +312,37 @@ def op(method: str, path: str, commands: str, note: str = "", left: str = "", st op("POST", RP + "/{replicationId}/activationStatus", "replication resume") op("GET", RP + "/jobs/{jobId}", "replication job") # Backups & restore (bucket) -op("POST", BKT + "/backups", "backup create") -op("GET", CLU + "/backups", "backup list") -op("GET", CLU + "/backups/{backupId}", "backup get") -op("DELETE", CLU + "/backups/{backupId}", "backup delete") -op("POST", CLU + "/backups/{backupId}/restore", "backup restore") +op("POST", BKT + "/backups", "bucket backup create") +op("GET", CLU + "/backups", "bucket backup list") +op("GET", CLU + "/backups/{backupId}", "bucket backup get") +op("DELETE", CLU + "/backups/{backupId}", "bucket backup delete", "Deletes the whole backup cycle.") +op("POST", CLU + "/backups/{backupId}/restore", "bucket backup restore", "Same or another cluster; partial restores.") +op("POST", BKT + "/backups/{backupId}/export", "bucket backup export", "Packages a backup cycle as a zip.") +op("GET", BKT + "/backups/{backupId}/exports/{exportId}", "bucket backup export", "Polled for the download URL.") BS = BKT + "/backup" -op("GET", BS + "/schedules", "backup schedule show") -op("POST", BS + "/schedules", "backup schedule set") -op("PUT", BS + "/schedules", "backup schedule set") -op("DELETE", BS + "/schedules", "backup schedule delete") -op("GET", BS + "/cycles", "backup cycle list") -op("GET", BS + "/cycles/{cycleId}", "backup cycle get") -# Cloud snapshot backups +op("GET", BS + "/schedules", "bucket backup schedule show", "Also read by plan, sync and drift.") +op("POST", BS + "/schedules", "bucket backup schedule set") +op("PUT", BS + "/schedules", "bucket backup schedule set") +op("DELETE", BS + "/schedules", "bucket backup schedule delete") +op("GET", BS + "/cycles", "bucket backup cycle list") +op("GET", BS + "/cycles/{cycleId}", "bucket backup cycle get") +# Cloud snapshot backups (cluster backups) CS = CLU + "/cloudsnapshotbackups" -op("POST", CS, "snapshot create") -op("GET", CS, "snapshot list") -op("GET", PRJ + "/cloudsnapshotbackups", "snapshot list", "Project-wide listing (`--project`).") -op("GET", CS + "/restores", "snapshot restores") -op("GET", CS + "/regions", "snapshot regions") -op("PUT", CS + "/{backupId}", "snapshot update", "Changes retention.") -op("DELETE", CS + "/{backupId}", "snapshot delete") -op("POST", CS + "/{backupId}/restore", "snapshot restore") -op("POST", PRJ + "/cloudsnapshotbackups/{backupId}/clone", "snapshot clone") -op("GET", CLU + "/cloudsnapshotbackupschedule", "snapshot schedule show") -op("PUT", CLU + "/cloudsnapshotbackupschedule", "snapshot schedule set") -op("DELETE", CLU + "/cloudsnapshotbackupschedule", "snapshot schedule delete") +op("POST", CS, "cluster backup create") +op("GET", CS, "cluster backup list") +op("GET", PRJ + "/cloudsnapshotbackups", "cluster backup list", "Project-wide listing (no cluster name).") +op("GET", CS + "/restores", "cluster backup restores") +op("GET", CS + "/regions", "cluster backup regions / cluster backup schedule options", "Regions a copy can go to.") +op("PUT", CS + "/{backupId}", "cluster backup retain", "Changes retention.") +op("DELETE", CS + "/{backupId}", "cluster backup delete") +op("POST", CS + "/{backupId}/restore", "cluster backup restore") +op("POST", PRJ + "/cloudsnapshotbackups/{backupId}/clone", "cluster backup clone", "Creates a cluster; out of " + "scope of CAP-057.") +op("GET", CLU + "/cloudsnapshotbackupschedule", "cluster backup schedule show", "Also read by plan, sync and drift.") +op("PUT", CLU + "/cloudsnapshotbackupschedule", "cluster backup schedule set") +op("DELETE", CLU + "/cloudsnapshotbackupschedule", "cluster backup schedule delete") +op("GET", CLU + "/cloudsnapshotbackupschedule/options", "cluster backup schedule options / cluster backup schedule set", + "Permitted intervals and retention for the support plan; read before every set.") # Network peers NP = CLU + "/networkPeers" op("POST", NP, "peer create") @@ -403,6 +423,44 @@ def op(method: str, path: str, commands: str, note: str = "", left: str = "", st op("POST", ORG + "/billing/download", "billing download") op("POST", CLU + "/billing/download", "billing download", "Itemized for one cluster (`--cluster`).") +# Operational Insights clusters (a separate analytics cluster type, not the operational database) +OI = PRJ + "/operationalInsightsClusters" +OIC = OI + "/{analyticsClusterId}" +op("POST", OI, "insights create") +op("GET", OI, "insights list") +op("GET", ORG + "/operationalInsightsClusters", "insights list", "Organization level.") +op("GET", OIC, "insights get") +op("PUT", OIC, "insights update") +op("DELETE", OIC, "insights delete") +op("POST", OIC + "/activationState", "insights power on") +op("DELETE", OIC + "/activationState", "insights power off") +op("GET", OIC + "/onOffSchedule", "insights schedule show") +op("POST", OIC + "/onOffSchedule", "insights schedule set") +op("PUT", OIC + "/onOffSchedule", "insights schedule set") +op("DELETE", OIC + "/onOffSchedule", "insights schedule delete") +op("GET", OIC + "/allowedcidrs", "insights cidr list") +op("POST", OIC + "/allowedcidrs", "insights cidr create") +op("GET", OIC + "/allowedcidrs/{allowedCidrId}", "insights cidr get") +op("DELETE", OIC + "/allowedcidrs/{allowedCidrId}", "insights cidr delete") +OB = OIC + "/cloudSnapshotBackups" +op("POST", OB, "insights backup create") +op("GET", OB, "insights backup list") +op("PUT", OB + "/{backupId}", "insights backup retain") +op("DELETE", OB + "/{backupId}", "insights backup delete") +op("POST", OB + "/{backupId}/restore", "insights backup restore") +op("GET", OB + "/restores", "insights backup restores") +op("GET", OIC + "/cloudSnapshotBackupSchedule", "insights backup schedule show") +op("PUT", OIC + "/cloudSnapshotBackupSchedule", "insights backup schedule set") +op("DELETE", OIC + "/cloudSnapshotBackupSchedule", "insights backup schedule delete") +OP = OIC + "/privateEndpointService" +op("GET", OP, "insights private-endpoint show") +op("POST", OP, "insights private-endpoint enable") +op("DELETE", OP, "insights private-endpoint disable") +op("POST", OP + "/endpointCommand", "insights private-endpoint command") +op("GET", OP + "/endpoints", "insights private-endpoint list") +op("POST", OP + "/endpoints/{endpointId}/associate", "insights private-endpoint accept") +op("POST", OP + "/endpoints/{endpointId}/unassociate", "insights private-endpoint reject") + AREAS = [ # (spec tag, label, in 1.0.0 scope) ("Organizations", "Organizations", True), ("Projects", "Projects", True), ("Clusters", "Clusters", True), ("Free Tier", "Free tier (clusters & buckets)", True), ("On/Off Schedule", "On/Off schedule (hibernation)", True), @@ -411,20 +469,24 @@ def op(method: str, path: str, commands: str, note: str = "", left: str = "", st ("Database Credentials", "Database credentials", True), ("Database Roles", "Database roles (custom)", False), ("Sample Bucket", "Sample buckets", False), ("Query Indexes", "Query indexes", False), ("Eventing Functions", "Eventing functions", False), ("Data API", "Data API", False), - ("Replications", "Replications (XDCR)", False), ("Backups & Restore (Bucket)", "Backups & restore (bucket)", False), - ("Backup Schedule (Bucket)", "Backup schedule (bucket)", False), - ("Cloud Snapshot Backups & Restore", "Cloud snapshot backups & restore", False), - ("Cloud Snapshot Backups Schedule", "Cloud snapshot backup schedule", False), + ("Replications", "Replications (XDCR)", False), ("Backups & Restore (Bucket)", "Backups & restore (bucket)", True), + ("Backup Schedule (Bucket)", "Backup schedule (bucket)", True), + ("Cloud Snapshot Backups & Restore", "Cloud snapshot backups & restore (cluster)", True), + ("Cloud Snapshot Backups Schedule", "Cloud snapshot backup schedule (cluster)", True), ("Network Peers", "Network peers", False), ("Private Endpoint Service", "Private endpoint service", False), ("Certificates", "Certificates", False), ("Audit Logs", "Audit logs", False), ("CMEK", "CMEK (customer-managed keys)", False), ("Alert Integration", "Alert integrations", False), ("Events", "Events", False), ("Users", "Organization users", False), ("Api Keys", "API keys", False), ("Billing", "Billing", False), + ("Operational Insights Clusters", "Operational Insights clusters", False), + ("Operational Insights On/Off Schedule", "Operational Insights on/off schedule", False), + ("Allowed CIDRs (Operational Insights Cluster)", "Allowed CIDRs (Operational Insights)", False), + ("Operational Insights Cloud Snapshot Backups & Restore", "Operational Insights snapshot backups & restore", False), + ("Operational Insights Cloud Snapshot Backups Schedule", "Operational Insights snapshot backup schedule", False), + ("Operational Insights Private Endpoint Service", "Operational Insights private endpoints", False), ] GAPS = [ - "**Scope note out of date.** [00-architecture.md §14](00-architecture.md) still lists *free-tier clusters* under " - "\"Not in 1.0.0\", but CAP-041 is Implemented. Update that line.", "**`GET …/allowedcidrs/{id}`** is wired in `capellactl/api/endpoints.py` but never called. Keep it for symmetry " "or drop it from `INVENTORY`.", ] @@ -531,7 +593,7 @@ def is_app_services(tag: str, path: str) -> bool: def is_ai(tag: str) -> bool: - return tag.startswith("AI ") or "Model" in tag + return tag.startswith(("AI ", "MCP ")) or "Model" in tag def anchor(tag: str) -> str: @@ -660,7 +722,7 @@ def count(rows: list[dict[str, Any]]) -> list[int]: f"covers the **{len(ops)} operational-database operations**. Two groups are left out:\n") w(f"- **{excl['as']} App Services / App Endpoints operations**, including the 4 free-tier App Service operations. " "They are tracked in [release 2.0.0: App Services API coverage](../release-2.0.0/API-COVERAGE-APP-SERVICES.md).") - w(f"- **{excl['ai']} AI operations** (AI Data Plane, AI Workflows, Models). Not planned for now.\n") + w(f"- **{excl['ai']} AI operations** (AI Data Plane, AI Workflows, Models, AI Agent Memory, MCP servers and tools). Not planned for now.\n") w("**Status legend**\n") w("| Status | Meaning |\n|---|---|") w(f"| {DONE} | capellactl calls this operation, from a command, from `plan`/`apply`, or internally, and every story " diff --git a/scripts/update-downloads.py b/scripts/update-downloads.py index 01f0174..4104b66 100644 --- a/scripts/update-downloads.py +++ b/scripts/update-downloads.py @@ -150,6 +150,16 @@ def table(manifest: dict[str, Any] | None) -> str: when SmartScreen warns. Company policy (AppLocker, WDAC) may need IT to allow the file's hash. - **Linux**: nothing more than `chmod +x`. +To type `capellactl` from any folder on Windows, install it for your user (no administrator rights needed), then +open a new PowerShell window: + +```powershell +mkdir $env:LOCALAPPDATA\\Programs\\capellactl -Force +Move-Item .\\capellactl-*-windows-x86_64.exe $env:LOCALAPPDATA\\Programs\\capellactl\\capellactl.exe +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +[Environment]::SetEnvironmentVariable('Path', "$userPath;$env:LOCALAPPDATA\\Programs\\capellactl", 'User') +``` + Each executable carries `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.md` and `BINARY_NOTICES.md`; the same files are attached to the release next to it.""" diff --git a/scripts/update-story-index.py b/scripts/update-story-index.py index 09bed81..ec75bb6 100755 --- a/scripts/update-story-index.py +++ b/scripts/update-story-index.py @@ -56,9 +56,11 @@ def priority_info(release: str) -> OrderedDict: ("Phase 3 — Data model & access", "Buckets, scopes, collections and least-privilege database credentials, ready for applications", ["CAP-023", "CAP-024", "CAP-025", "CAP-026", "CAP-027", "CAP-046", "CAP-028", "CAP-029"]), ("Phase 4 — Adopt, harden & ship", "Import existing clusters with a no-change round trip, one-command build and teardown, drift reporting, nightly integration gate and customer docs", - ["CAP-030", "CAP-036", "CAP-047", "CAP-048", "CAP-031", "CAP-042", "CAP-032", "CAP-044", "CAP-043", "CAP-045", "CAP-033", "CAP-050", "CAP-054"]), - ("Phase 5 — Custom cluster layouts", "Create a cluster with any size, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone", - ["CAP-051", "CAP-052", "CAP-053"]), + ["CAP-030", "CAP-036", "CAP-047", "CAP-048", "CAP-031", "CAP-042", "CAP-032", "CAP-044", "CAP-043", "CAP-045", "CAP-033", "CAP-050", "CAP-054", "CAP-055"]), + ("Phase 5 — Custom cluster layouts", "Create a cluster with any size, disk type, multi-dimensional service layout and create option, and add or remove service groups later, from commands alone", + ["CAP-051", "CAP-052", "CAP-053", "CAP-060"]), + ("Phase 6 — Backup & restore", "Schedule cluster and bucket backups as code, list them, back up now and restore a cluster or a bucket, from commands alone", + ["CAP-056", "CAP-057", "CAP-058", "CAP-059", "CAP-061"]), ], "checklist": [ "All P0 stories are Implemented (and P1 stories are Implemented or explicitly descoped with sign-off)", @@ -68,7 +70,9 @@ def priority_info(release: str) -> OrderedDict: "Every open question in the story files is answered or explicitly deferred to 1.1", "Import round trip (CAP-036 AC2) shows no changes against at least one real customer-like environment in the sandbox", "Time-to-first-environment check done by an engineer new to the tool against a real organization: under 60 minutes (release check; CAP-033 AC5 runs the same guide path against the fake in CI)", + "The same time-to-first-environment check done once on a Windows laptop in Windows PowerShell, following the README's Windows install table (CAP-055)", "Source archive, wheel and SHA256SUMS built with `make dist` and attached to the GitHub Release (CAP-043); container image published; standalone executables for macOS, Linux and Windows in `downloads/` and listed in the README Download section (CAP-045); README, runbook and examples validated (CAP-033)", + "A cluster backup and a bucket backup restored in the sandbox organization, and the restored data checked (CAP-057, CAP-059)", "`main` and `v*` tag rulesets active and `make rulesets-check` green before the repository is made public (CAP-050)", ], }, diff --git a/tests/binary/test_smoke.py b/tests/binary/test_smoke.py index 438522a..8bd4b58 100644 --- a/tests/binary/test_smoke.py +++ b/tests/binary/test_smoke.py @@ -154,7 +154,8 @@ def test_binary_ac7_bundle_allowlist(): leaked = [n for n in names for f in FORBIDDEN if f in n and not n.startswith("capellactl/help/")] assert leaked == [], leaked ours = {n for n in names if n.startswith("capellactl/")} - assert ours == help_pages | {"capellactl/config/schema.json", "capellactl/config/presets.yaml"}, ours + assert ours == help_pages | {"capellactl/config/schema.json", "capellactl/config/presets.yaml", + "capellactl/config/compute.yaml", "capellactl/config/disks.yaml"}, ours for n in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md", "BINARY_NOTICES.md"): # and next to it assert (Path(BINARY).parent / n).is_file(), n diff --git a/tests/fakes/capella.py b/tests/fakes/capella.py index e245f8e..cbb2a1e 100644 --- a/tests/fakes/capella.py +++ b/tests/fakes/capella.py @@ -4,6 +4,9 @@ here. Behaviour mirrors the OpenAPI spec: 202 + currentState progression for clusters, 201 creates, 204 updates, ETag/If-Match/412, 409 code 1010 for duplicates, 404 after delete, base64 bucket IDs, generated credential passwords returned only on create, onOffSchedule 404 when none, /stats memory. +Backups (CAP-056–CAP-059): cloud snapshot schedule (204 when none) checked against the plan's options, snapshots and +restores that complete after `step_seconds`, bucket backup schedules (404 when none), backups grouped in cycles, +on-demand bucket backups (202 without a body), restores and exports. """ from __future__ import annotations @@ -107,6 +110,17 @@ def __init__(self, org_id: str = "org-1", clock: FakeClock | None = None, base_u self.scopes: dict[tuple[str, str], dict[str, dict[str, dict[str, Any]]]] = {} self.users: dict[str, dict[str, Any]] = {} self.schedules: dict[str, dict[str, Any]] = {} + # Backups (CAP-056–CAP-059) + self.snapshot_schedules: dict[str, dict[str, Any]] = {} + self.snapshot_options = {"intervals": [1, 2, 4, 6, 8, 12, 24], "minimumRetention": 24, "maximumRetention": 2160} + self.snapshot_regions = ["us-east-2", "us-west-2", "eu-west-1"] + self.snapshots: dict[str, dict[str, Any]] = {} + self.snapshot_restores: dict[str, dict[str, Any]] = {} + self.bucket_backup_schedules: dict[tuple[str, str], dict[str, Any]] = {} + self.backups: dict[str, dict[str, Any]] = {} + self.exports: dict[str, dict[str, Any]] = {} + self.bucket_restores: list[dict[str, Any]] = [] + self.backup_fails_next = False # the next on-demand bucket backup ends in status failed self.versions: dict[str, int] = {} self.cluster_total_memory_mb = 20480 # Capella's answer to a second POST of the same CIDR is unconfirmed (CAP-022 open question): 409 by default. @@ -193,6 +207,48 @@ def seed_scope(self, cluster_id: str, bucket: str, scope: str, collections: dict cols[name] = {"name": name, "maxTTL": ttl} self.versions[f"collection:{cluster_id}/{bucket_id(bucket)}/{scope}/{name}"] = 1 + def seed_snapshot(self, cluster_id: str, created_at: str = "2026-10-28T02:00:00Z", retention: int = 720, + type_: str = "scheduled", status: str = "complete", **fields: Any) -> str: + """A cloud snapshot backup of a cluster (done unless `status` says otherwise).""" + sid = fields.pop("id", None) or self._new_id("snap") + project = self.clusters[cluster_id]["project"] + self.snapshots[sid] = {"id": sid, "clusterId": cluster_id, "projectId": project, "tenantId": self.org_id, + "createdAt": created_at, "expiration": "2026-11-27T02:00:00Z", "retention": retention, + "type": type_, "progress": {"status": status, "time": created_at}, + "crossRegionCopies": [], "cmek": [], "server": {"version": "8.0.1"}, + "databaseSize": 42 * 1024 ** 3, "_done_at": -1.0, **fields} + return str(sid) + + def seed_bucket_backup(self, cluster_id: str, bucket: str, method: str = "full", source: str = "scheduled", + cycle: str | None = None, date: str = "2026-10-27T02:00:00Z", status: str = "ready", + **fields: Any) -> str: + """A backup of a bucket, in `cycle` (a new cycle when not given).""" + i = fields.pop("id", None) or self._new_id("bk") + cyc = cycle or self._new_id("cycle") + self.backups[i] = {"id": i, "clusterID": cluster_id, "tenantID": self.org_id, + "projectID": self.clusters[cluster_id]["project"], "cycleID": cyc, "date": date, + "restoreBefore": "2026-11-26T02:00:00Z", "status": status, "method": method, + "bucketName": bucket, "bucketID": bucket_id(bucket), "source": source, "provider": "aws", + "stats": {"sizeInMb": 3100.0, "items": 912000, "mutations": 912000, "tombstones": 0, + "gsi": 2, "fts": 0, "cbas": 0, "event": 0}, + "elapsedTimeInSeconds": 120, + "scheduleInfo": {"backupType": "weekly", "backupTime": date, "increment": 24, + "retention": "30days"}, + "_done_at": -1.0, **fields} + return str(i) + + def _snapshot_view(self, s: dict[str, Any]) -> dict[str, Any]: + view = {k: copy.deepcopy(v) for k, v in s.items() if not k.startswith("_")} + if s["_done_at"] >= 0 and self.clock.monotonic() >= s["_done_at"] and view["progress"]["status"] == "pending": + view["progress"]["status"] = "complete" + s["progress"]["status"] = "complete" + return view + + def _backup_view(self, b: dict[str, Any]) -> dict[str, Any]: + if b["_done_at"] >= 0 and self.clock.monotonic() >= b["_done_at"] and b["status"] == "pending": + b["status"] = b.pop("_final", "ready") + return {k: copy.deepcopy(v) for k, v in b.items() if not k.startswith("_")} + # ------------------------------------------------------------ state def set_transition(self, cluster_id: str, path: list[str], step_seconds: float | None = None) -> None: step = self.step_seconds if step_seconds is None else step_seconds @@ -843,12 +899,329 @@ def u_put(o: str, p: str, c: str, i: str, body: Any, headers: dict[str, str], ** def u_delete(o: str, p: str, c: str, i: str, **k: Any) -> Resp: return _ok(204) if self.users.pop(i, None) else _err(404, "credential not found", 4048) + self._build_backup_routes(CLU, BKT, PRJ) r("GET", CLU + "/users", u_list) r("POST", CLU + "/users", u_create) r("GET", CLU + "/users/{i}", u_get) r("PUT", CLU + "/users/{i}", u_put) r("DELETE", CLU + "/users/{i}", u_delete) + def _build_backup_routes(self, CLU: str, BKT: str, PRJ: str) -> None: # noqa: C901, N803 - one table + r = self._route + + def snapshot_cluster(o: str, p: str, c: str) -> Resp | None: + cl, e = self._cluster(o, p, c) + if e is not None: + return e + if str((cl["body"].get("support") or {}).get("plan")) == "free": # type: ignore[index] + return _err(422, "Backups are not available for free tier clusters.", 4220) + return None + + # Cloud snapshot backup schedule (GET: 204 when there is none) + def ss_get(o: str, p: str, c: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + body = self.snapshot_schedules.get(c) + return _ok(200, body) if body is not None else _ok(204) + + def ss_put(o: str, p: str, c: str, body: Any, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + body = body or {} + opts = self.snapshot_options + if body.get("interval") not in opts["intervals"]: + return _err(422, f"interval must be one of {opts['intervals']}", 4220) + if not opts["minimumRetention"] <= int(body.get("retention") or 0) <= opts["maximumRetention"]: + return _err(422, "retention is outside the range the service plan permits", 4220) + if any(x not in self.snapshot_regions for x in body.get("copyToRegions") or []): + return _err(422, "copyToRegions has a region that is not available", 4220) + self.snapshot_schedules[c] = {k2: copy.deepcopy(v) for k2, v in body.items() + if k2 in ("interval", "retention", "startTime", "copyToRegions")} + return _ok(204) + + def ss_delete(o: str, p: str, c: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + self.snapshot_schedules.pop(c, None) + return _ok(204) + + def ss_options(o: str, p: str, c: str, **k: Any) -> Resp: + return snapshot_cluster(o, p, c) or _ok(200, copy.deepcopy(self.snapshot_options)) + + r("GET", CLU + "/cloudsnapshotbackupschedule/options", ss_options) + r("GET", CLU + "/cloudsnapshotbackupschedule", ss_get) + r("PUT", CLU + "/cloudsnapshotbackupschedule", ss_put) + r("DELETE", CLU + "/cloudsnapshotbackupschedule", ss_delete) + + # Cloud snapshot backups (registered before …/cloudsnapshotbackups/{backupId}) + def cs_regions(o: str, p: str, c: str, **k: Any) -> Resp: + return snapshot_cluster(o, p, c) or _ok(200, list(self.snapshot_regions)) + + def cs_list(o: str, p: str, c: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + items = [self._snapshot_view(s) for s in self.snapshots.values() if s["clusterId"] == c] + return self._page(sorted(items, key=lambda s: s["createdAt"], reverse=True), k["query"]) + + def cs_project(o: str, p: str, **k: Any) -> Resp: + if p not in self.projects: + return _err(404, f"project {p} not found", 4042) + rows = [] + for cid, cl in self.clusters.items(): + snaps = sorted((self._snapshot_view(s) for s in self.snapshots.values() if s["clusterId"] == cid), + key=lambda s: s["createdAt"]) + if cl["project"] != p or not snaps: + continue + cp = cl["body"].get("cloudProvider") or {} + rows.append({"clusterId": cid, "clusterName": cl["body"]["name"], "currentStatus": self.cluster_state(cid), + "cloudProvider": f"hosted{str(cp.get('type', 'aws')).upper()}", "region": cp.get("region"), + "creationDateTime": "2026-01-01T00:00:00Z", "createdBy": "fake-api-key", + "mostRecentSnapshot": snaps[-1], "oldestSnapshot": snaps[0]}) + return self._page(rows, k["query"]) + + def cs_create(o: str, p: str, c: str, body: Any, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + body = body or {} + regions = body.get("regionsToCopy") or [] + if len(regions) > 2 or any(x not in self.snapshot_regions for x in regions): + return _err(422, "regionsToCopy: at most two of the available regions", 4220) + sid = self.seed_snapshot(c, created_at=f"2026-10-29T{len(self.snapshots) % 24:02d}:00:00Z", + retention=int(body.get("retention") or 720), type_="onDemand", status="pending", + crossRegionCopies=[{"regionCode": x, "status": "pending"} for x in regions]) + self.snapshots[sid]["_done_at"] = self.clock.monotonic() + self.step_seconds + return _ok(202, {"backupId": sid}) + + def cs_restores(o: str, p: str, c: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + items = [] + for rs in self.snapshot_restores.values(): + if rs["clusterId"] != c: + continue + view = {k2: v for k2, v in rs.items() if not k2.startswith("_")} + if self.clock.monotonic() >= rs["_done_at"]: + view.update(status="complete", restoreEnd="2026-10-29T03:00:00Z") + items.append(view) + return self._page(items, k["query"]) + + def cs_snapshot(c: str, b: str) -> dict[str, Any] | None: + s = self.snapshots.get(b) + return s if s is not None and s["clusterId"] == c else None + + def cs_put(o: str, p: str, c: str, b: str, body: Any, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + s = cs_snapshot(c, b) + if s is None: + return _err(404, "backup not found", 4040) + s["retention"] = int((body or {}).get("retention") or s["retention"]) + return _ok(204) + + def cs_delete(o: str, p: str, c: str, b: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + if cs_snapshot(c, b) is None: + return _err(404, "backup not found", 4040) + del self.snapshots[b] + return _ok(202, {}) + + def cs_restore(o: str, p: str, c: str, b: str, body: Any, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + s = cs_snapshot(c, b) + if s is None: + return _err(404, "backup not found", 4040) + if self.cluster_state(c) in ("turnedOff", "turningOff"): + return _err(422, "The cluster is turned off.", 4220) + rid = self._new_id("restore") + self.snapshot_restores[rid] = {"id": rid, "clusterId": c, "projectId": p, "tenantId": self.org_id, + "createdAt": "2026-10-29T02:30:00Z", "restoreTo": c, + "snapshot": s["createdAt"], "status": "pending", + "_done_at": self.clock.monotonic() + self.step_seconds, + "_body": copy.deepcopy(body)} + self.set_transition(c, ["rebalancing", "healthy"]) + return _ok(202, {"restoreId": rid}) + + r("GET", CLU + "/cloudsnapshotbackups/regions", cs_regions) + r("GET", CLU + "/cloudsnapshotbackups/restores", cs_restores) + r("GET", CLU + "/cloudsnapshotbackups", cs_list) + r("POST", CLU + "/cloudsnapshotbackups", cs_create) + r("PUT", CLU + "/cloudsnapshotbackups/{b}", cs_put) + r("DELETE", CLU + "/cloudsnapshotbackups/{b}", cs_delete) + r("POST", CLU + "/cloudsnapshotbackups/{b}/restore", cs_restore) + r("GET", PRJ + "/cloudsnapshotbackups", cs_project) + + # Bucket backup schedules (GET and PUT: 404 when there is none; POST: 409 when there is one) + def bucket_ok(o: str, p: str, c: str, b: str) -> Resp | None: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + return None if (c, b) in self.buckets else _err(404, "bucket not found", 4045) + + def schedule_problem(body: Any) -> Resp | None: + w = (body or {}).get("weeklySchedule") or {} + rules = {"dayOfWeek": ("sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"), + "startAt": tuple(range(24)), "incrementalEvery": (1, 2, 4, 6, 8, 12, 24), + "retentionTime": ("30days", "60days", "90days", "180days", "1year", "2years", "3years", "4years", + "5years"), "costOptimizedRetention": (True, False)} + if (body or {}).get("type") != "weekly": + return _err(422, "type must be weekly", 4220) + for f, allowed in rules.items(): + if w.get(f) not in allowed: + return _err(422, f"weeklySchedule.{f} is invalid", 4220) + return None + + def bs_get(o: str, p: str, c: str, b: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + body = self.bucket_backup_schedules.get((c, b)) + if body is None: + return _err(404, "no backup schedule for this bucket", 4040) + return _ok(200, {"type": "weekly", "clusterID": c, "bucketId": b, **copy.deepcopy(body)}) + + def bs_post(o: str, p: str, c: str, b: str, body: Any, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b) or schedule_problem(body)) is not None: + return e + if (c, b) in self.bucket_backup_schedules: + return _err(409, "There is a conflict with the current state of a resource.", 1010) + self.bucket_backup_schedules[(c, b)] = {"weeklySchedule": copy.deepcopy(body["weeklySchedule"])} + return _ok(202) + + def bs_put(o: str, p: str, c: str, b: str, body: Any, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b) or schedule_problem(body)) is not None: + return e + if (c, b) not in self.bucket_backup_schedules: + return _err(404, "no backup schedule for this bucket", 4040) + self.bucket_backup_schedules[(c, b)] = {"weeklySchedule": copy.deepcopy(body["weeklySchedule"])} + return _ok(204) + + def bs_delete(o: str, p: str, c: str, b: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + if self.bucket_backup_schedules.pop((c, b), None) is None: + return _err(404, "no backup schedule for this bucket", 4040) + return _ok(202) + + def cycles(o: str, p: str, c: str, b: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + first: dict[str, str] = {} + for bk in sorted(self.backups.values(), key=lambda x: x["date"]): + if bk["clusterID"] == c and bk["bucketID"] == b: + first.setdefault(bk["cycleID"], bk["date"]) + return _ok(200, {"data": [{"cycleID": cy, "createdAt": at} for cy, at in first.items()]}) + + def cycle_get(o: str, p: str, c: str, b: str, y: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + items = [self._backup_view(bk) for bk in self.backups.values() + if bk["clusterID"] == c and bk["bucketID"] == b and bk["cycleID"] == y] + if not items: + return _err(404, "cycle not found", 4040) + return _ok(200, {"data": [{k2: v for k2, v in i.items() if k2 != "bucketDownloadsCount"} for i in items]}) + + r("GET", BKT + "/backup/schedules", bs_get) + r("POST", BKT + "/backup/schedules", bs_post) + r("PUT", BKT + "/backup/schedules", bs_put) + r("DELETE", BKT + "/backup/schedules", bs_delete) + r("GET", BKT + "/backup/cycles", cycles) + r("GET", BKT + "/backup/cycles/{y}", cycle_get) + + # Bucket backups + def bk_create(o: str, p: str, c: str, b: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + name = self.buckets[(c, b)]["name"] + i = self.seed_bucket_backup(c, name, method="full", source="manual", status="pending", + date=f"2026-10-29T{len(self.backups) % 24:02d}:00:00Z") + self.backups[i]["_done_at"] = self.clock.monotonic() + self.step_seconds + if self.backup_fails_next: + self.backups[i]["_final"] = "failed" + self.backup_fails_next = False + return _ok(202) + + def bk_list(o: str, p: str, c: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + latest: dict[str, dict[str, Any]] = {} + for bk in sorted(self.backups.values(), key=lambda x: x["date"]): + if bk["clusterID"] == c: + latest[bk["bucketID"]] = bk # Capella lists the latest backup of each bucket + return _ok(200, {"data": [{**self._backup_view(bk), "bucketDownloadsCount": 0} for bk in latest.values()]}) + + def bk_get(o: str, p: str, c: str, i: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + bk = self.backups.get(i) + if bk is None or bk["clusterID"] != c: + return _err(404, "backup not found", 4040) + return _ok(200, self._backup_view(bk)) + + def bk_delete(o: str, p: str, c: str, i: str, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + bk = self.backups.get(i) + if bk is None or bk["clusterID"] != c: + return _err(404, "backup not found", 4040) + for j in [j for j, x in self.backups.items() if x["cycleID"] == bk["cycleID"]]: + del self.backups[j] # the whole cycle goes + return _ok(202) + + def bk_restore(o: str, p: str, c: str, i: str, body: Any, **k: Any) -> Resp: + if (e := snapshot_cluster(o, p, c)) is not None: + return e + body = body or {} + for f in ("targetClusterID", "sourceClusterID", "backupID", "services"): + if f not in body: + return _err(422, f"{f} is required", 4220) + if i not in self.backups: + return _err(404, "backup not found", 4040) + target = self.clusters.get(str(body["targetClusterID"])) + if target is None: + return _err(404, "target cluster not found", 4041) + if self.cluster_state(str(body["targetClusterID"])) in ("turnedOff", "turningOff"): + return _err(422, "The target cluster is turned off.", 4220) + self.bucket_restores.append(copy.deepcopy(body)) + return _ok(202) + + def ex_create(o: str, p: str, c: str, b: str, i: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + bk = self.backups.get(i) + if bk is None: + return _err(404, "backup not found", 4040) + if self._backup_view(bk)["status"] != "ready": + return _err(422, "The backup must be in a ready state.", 4220) + if any(x["cycleId"] == bk["cycleID"] and x["status"] != "failed" for x in self.exports.values()): + return _err(409, "The backup cycle already has an export.", 1010) + x = self._new_id("export") + self.exports[x] = {"id": x, "tenantId": self.org_id, "projectId": p, "clusterId": c, + "cycleId": bk["cycleID"], "bucketName": bk["bucketName"], "status": "pending", + "createdAt": "2026-10-29T04:00:00Z", "_backup": i, + "_done_at": self.clock.monotonic() + self.step_seconds} + return _ok(202, {"exportId": x, "createdAt": "2026-10-29T04:00:00Z"}) + + def ex_get(o: str, p: str, c: str, b: str, i: str, x: str, **k: Any) -> Resp: + if (e := bucket_ok(o, p, c, b)) is not None: + return e + ex = self.exports.get(x) + if ex is None or ex["_backup"] != i: + return _err(404, "export not found", 4040) + view = {k2: v for k2, v in ex.items() if not k2.startswith("_")} + if self.clock.monotonic() >= ex["_done_at"]: + view.update(status="complete", sizeInBytes=3 * 1024 ** 3, sha256Checksum="ab" * 32, + expiration="2026-10-29T16:00:00Z", + backupDownloadURL=f"https://downloads.example.com/{x}.zip?X-Amz-Signature=s3cr3t") + return _ok(200, view) + + r("POST", BKT + "/backups", bk_create) + r("POST", BKT + "/backups/{i}/export", ex_create) + r("GET", BKT + "/backups/{i}/exports/{x}", ex_get) + r("GET", CLU + "/backups", bk_list) + r("GET", CLU + "/backups/{i}", bk_get) + r("DELETE", CLU + "/backups/{i}", bk_delete) + r("POST", CLU + "/backups/{i}/restore", bk_restore) + @staticmethod def _azure_disk_check(provider: Any, groups: Any) -> Resp | None: """Capella: only Ultra disks take a size or IOPS on Azure.""" diff --git a/tests/scenario/test_backup_restore_guide.py b/tests/scenario/test_backup_restore_guide.py new file mode 100644 index 0000000..56a1919 --- /dev/null +++ b/tests/scenario/test_backup_restore_guide.py @@ -0,0 +1,48 @@ +"""CAP-061 AC1: every command in docs/backup-restore.md runs, in order, against the fake Capella.""" +from __future__ import annotations + +import re +import shlex +from pathlib import Path + +import pytest + +from tests.fakes.capella import bucket_id + +GUIDE = Path(__file__).resolve().parents[2] / "docs" / "backup-restore.md" + + +def _commands() -> list[str]: + out = [] + for block in re.findall(r"```bash\n(.*?)```", GUIDE.read_text(encoding="utf-8"), flags=re.S): + for line in block.replace("\\\n", " ").splitlines(): + line = re.sub(r"\s+#.*$", "", line.strip()) + if line.startswith("capellactl "): + out.append(line.removeprefix("capellactl ")) + return out + + +@pytest.mark.story("CAP-061", "AC1") +def test_backup_guide_ac1_steps_run(fake, run): + pid = fake.seed_project("sandbox") + cid = fake.seed_cluster(pid, "prod-01") + copy = fake.seed_cluster(pid, "prod-01-copy") + fake.seed_bucket(cid, "orders") + fake.seed_bucket(copy, "orders") + fake.seed_snapshot(cid, created_at="2026-10-28T02:00:00Z") + assert run("sync").exit_code == 0 + commands = _commands() + assert len(commands) >= 15 + for cmd in commands: + # The reader copies IDs from the lists; the newest of each kind here. + snapshot = max(fake.snapshots.values(), key=lambda s: s["createdAt"])["id"] + backup = max(fake.backups.values(), key=lambda b: b["date"])["id"] if fake.backups else "none" + cmd = cmd.replace("CLUSTER_BACKUP_ID", snapshot).replace("BUCKET_BACKUP_ID", backup) + r = run("--auto-approve", *shlex.split(cmd)) + assert r.exit_code == 0, f"{cmd}\n{r.output}" + assert fake.snapshot_schedules[cid]["interval"] == 24 + assert fake.bucket_backup_schedules[(cid, bucket_id("orders"))]["weeklySchedule"]["incrementalEvery"] == 12 + assert any(s["type"] == "onDemand" for s in fake.snapshots.values()) + assert any(b["source"] == "manual" for b in fake.backups.values()) + assert [r["targetClusterID"] for r in fake.bucket_restores] == [cid, copy] + assert len(fake.snapshot_restores) == 1 and fake.exports diff --git a/tests/scenario/test_bucket_backup.py b/tests/scenario/test_bucket_backup.py new file mode 100644 index 0000000..27b9925 --- /dev/null +++ b/tests/scenario/test_bucket_backup.py @@ -0,0 +1,188 @@ +"""CAP-059: bucket backups: list, back up now, delete, restore and export.""" +from __future__ import annotations + +import json + +import pytest + +from capellactl.cli import console +from tests.conftest import seed_healthy +from tests.fakes.capella import bucket_id + + +@pytest.fixture +def world(fake, run): + pid, cid = seed_healthy(fake) + dr = fake.seed_cluster(pid, "nova-dr-01") + fake.seed_bucket(cid, "orders") + fake.seed_bucket(cid, "users") + full = fake.seed_bucket_backup(cid, "orders", cycle="cy-1", date="2026-10-25T02:00:00Z") + inc = fake.seed_bucket_backup(cid, "orders", method="incremental", cycle="cy-1", date="2026-10-26T02:00:00Z") + users = fake.seed_bucket_backup(cid, "users", source="manual", cycle="cy-u", date="2026-10-24T02:00:00Z") + assert run("import").exit_code == 0 + fake.reset_calls() + return {"cid": cid, "dr": dr, "full": full, "inc": inc, "users": users} + + +@pytest.mark.story("CAP-059", "AC1") +def test_bucket_backup_ac1_list(fake, run, world): + r = run("bucket", "backup", "list", "nova-dev-01") + assert r.exit_code == 0, r.output + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "BUCKET", "DATE", "METHOD", "SOURCE", "STATUS", "SIZE", "ITEMS", "EXPIRES", "ID"] + assert [line.split()[1] for line in lines[1:]] == ["orders", "users"] # the latest of each bucket, newest first + assert lines[1].split()[-1] == world["inc"] + r = run("bucket", "backup", "list", "nova-dev-01", "orders") + assert [line.split()[-1] for line in r.output.splitlines()[1:]] == [world["inc"], world["full"]] + data = json.loads(run("-o", "json", "bucket", "backup", "list", "nova-dev-01").output) + assert {b["bucketName"] for b in data} == {"orders", "users"} + fake.backups.clear() + assert "no backups" in run("bucket", "backup", "list", "nova-dev-01").output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC1") +def test_bucket_backup_ac1_get(fake, run, world): + r = run("bucket", "backup", "get", "nova-dev-01", world["full"]) + assert r.exit_code == 0, r.output + assert "orders" in r.output and "full" in r.output and "cycle cy-1" in r.output and "indexes 2" in r.output + assert json.loads(run("-o", "json", "bucket", "backup", "get", "nova-dev-01", world["full"]).output)["id"] == \ + world["full"] + r = run("bucket", "backup", "get", "nova-dev-01", "nope") + assert r.exit_code == 1 and "backup nope not found" in r.output + + +@pytest.mark.story("CAP-059", "AC2") +def test_bucket_backup_ac2_create(fake, run, world): + r = run("--auto-approve", "bucket", "backup", "create", "nova-dev-01", "orders") + assert r.exit_code == 0, r.output + posts = fake.calls("POST", "*/buckets/*/backups") + assert len(posts) == 1 and posts[0].path.endswith(f"/buckets/{bucket_id('orders')}/backups") + new = next(i for i, b in fake.backups.items() if b["source"] == "manual" and b["bucketName"] == "orders") + assert "backup of orders: ready" in r.output and f"backup {new} of orders is ready" in r.output + r = run("--auto-approve", "--no-wait", "bucket", "backup", "create", "nova-dev-01", "users") + assert r.exit_code == 0 and "started (--no-wait)" in r.output + + +@pytest.mark.story("CAP-059", "AC2") +def test_bucket_backup_ac2_create_failed_and_refused(fake, run, world): + fake.backup_fails_next = True + r = run("--auto-approve", "bucket", "backup", "create", "nova-dev-01", "orders") + assert r.exit_code == 1 and "backup of orders ended failed" in r.output + fake.reset_calls() + r = run("--auto-approve", "bucket", "backup", "create", "nova-dev-01", "nope") + assert r.exit_code == 1 and "bucket nope not found in cluster nova-dev-01" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC3") +def test_bucket_backup_ac3_delete_cycle(fake, run, world, monkeypatch): + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("bucket", "backup", "delete", "nova-dev-01", world["inc"], input="users\n") + assert "deletes cycle 2026-10-25 02:00 with 2 backups of orders" in r.output + assert "Cancelled" in r.output and fake.writes() == [] + r = run("bucket", "backup", "delete", "nova-dev-01", world["inc"], input="orders\n") + assert r.exit_code == 0, r.output + assert len(fake.calls("DELETE", "*/backups/*")) == 1 + assert world["full"] not in fake.backups and world["inc"] not in fake.backups and world["users"] in fake.backups + + +@pytest.mark.story("CAP-059", "AC3") +def test_bucket_backup_ac3_delete_missing_noop(fake, run, world): + r = run("--auto-approve", "bucket", "backup", "delete", "nova-dev-01", "nope") + assert r.exit_code == 0 and "not found in cluster nova-dev-01, nothing to do" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC4") +def test_bucket_backup_ac4_restore_same_cluster(fake, run, world): + r = run("--auto-approve", "bucket", "backup", "restore", "nova-dev-01", world["full"]) + assert r.exit_code == 0, r.output + assert "restore orders from the backup of 2026-10-25 02:00 UTC into nova-dev-01" in r.output + assert "newer documents in nova-dev-01 are kept" in r.output + posts = fake.calls("POST", "*/backups/*/restore") + assert len(posts) == 1 and posts[0].body == {"targetClusterID": world["cid"], "sourceClusterID": world["cid"], + "backupID": world["full"], "services": ["data"]} + assert "restore of orders into nova-dev-01 started" in r.output + + +@pytest.mark.story("CAP-059", "AC4") +def test_bucket_backup_ac4_restore_confirm(fake, run, world, monkeypatch): + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("bucket", "backup", "restore", "nova-dev-01", world["full"], input="orders\n") + assert r.exit_code == 0 and "Cancelled; nothing was restored." in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC5") +def test_bucket_backup_ac5_restore_options_body(fake, run, world): + r = run("--auto-approve", "bucket", "backup", "restore", "nova-dev-01", world["full"], "--to-cluster", "nova-dr-01", + "--include-data", "orders.sales", "--map-data", "orders=orders_copy", "--replace-ttl", "all", + "--replace-ttl-with", "2027-01-01T00:00:00Z", "--services", "query,data", "--force-updates", + "--auto-remove-collections", "--filter-keys", "^order::") + assert r.exit_code == 0, r.output + assert "into nova-dr-01" in r.output and "overwrites newer documents (--force-updates)" in r.output + assert fake.calls("POST", "*/backups/*/restore")[0].body == { + "targetClusterID": world["dr"], "sourceClusterID": world["cid"], "backupID": world["full"], + "services": ["data", "query"], "includeData": "orders.sales", "mapData": "orders=orders_copy", + "filterKeys": "^order::", "replaceTTL": "all", "replaceTTLWith": "2027-01-01T00:00:00Z", "forceUpdates": True, + "autoRemoveCollections": True} + + +@pytest.mark.story("CAP-059", "AC5") +@pytest.mark.parametrize("extra,code,message", [ + (["--replace-ttl-with", "2027-01-01T00:00:00Z"], 3, "--replace-ttl-with needs --replace-ttl all"), + (["--replace-ttl", "none", "--replace-ttl-with", "2027-01-01T00:00:00Z"], 3, "--replace-ttl-with needs"), + (["--services", "data,index"], 3, "--services takes data, query or data,query"), + (["--to-cluster", "nope-01"], 1, "cluster nope-01 not found"), +]) +def test_bucket_backup_ac5_restore_refused(fake, run, world, extra, code, message): + r = run("--auto-approve", "bucket", "backup", "restore", "nova-dev-01", world["full"], *extra) + assert r.exit_code == code and message in r.output, r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC5") +def test_bucket_backup_ac5_restore_target_turned_off(fake, run, world): + fake.set_transition(world["dr"], ["turnedOff"]) + r = run("--auto-approve", "bucket", "backup", "restore", "nova-dev-01", world["full"], "--to-cluster", "nova-dr-01") + assert r.exit_code == 4 and "nova-dr-01 is turned off" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC6") +def test_bucket_backup_ac6_export(fake, run, world): + r = run("--auto-approve", "bucket", "backup", "export", "nova-dev-01", world["full"]) + assert r.exit_code == 0, r.output + assert len(fake.calls("POST", "*/backups/*/export")) == 1 + export_id = next(iter(fake.exports)) + assert f"export {export_id} of orders started" in r.output and "export of orders: complete" in r.output + assert "treat it like a password" in r.output and "X-Amz-Signature=s3cr3t" in r.output + fake.reset_calls() + r = run("bucket", "backup", "export", "nova-dev-01", world["full"], "--export-id", export_id) + assert r.exit_code == 0 and f"export {export_id} is complete" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-059", "AC6") +def test_bucket_backup_ac6_export_existing_and_refused(fake, run, world): + r = run("--log-level", "debug", "bucket", "backup", "export", "nova-dev-01", world["full"]) + assert r.exit_code == 0 and "/exports/" in r.output # the debug request lines are there … + assert r.output.count("X-Amz-Signature=s3cr3t") == 1 # … and the link only once: printed, never logged + fake.reset_calls() + r = run("bucket", "backup", "export", "nova-dev-01", world["inc"]) # same cycle + assert r.exit_code == 0 and "already has an export (Capella allows one per cycle)" in r.output + assert len(fake.writes()) == 1 # the refused POST only + fake.backups[world["users"]]["status"] = "pending" + r = run("bucket", "backup", "export", "nova-dev-01", world["users"]) + assert r.exit_code == 4 and "Capella cannot export this backup" in r.output and "ready state" in r.output + + +@pytest.mark.story("CAP-059", "AC7") +def test_bucket_backup_ac7_help(run): + r = run("bucket", "backup", "--help") + assert r.exit_code == 0 + for verb in ("list", "get", "create", "delete", "restore", "export", "schedule", "cycle"): + assert verb in r.output, verb + h = run("bucket", "backup", verb, "--help") + assert h.exit_code == 0 and "Examples:" in h.output, verb diff --git a/tests/scenario/test_bucket_backup_schedule.py b/tests/scenario/test_bucket_backup_schedule.py new file mode 100644 index 0000000..b7b842f --- /dev/null +++ b/tests/scenario/test_bucket_backup_schedule.py @@ -0,0 +1,182 @@ +"""CAP-058: bucket backup schedule as code, and backup cycles.""" +from __future__ import annotations + +import json + +import pytest + +from capellactl.cli import console +from tests.conftest import file_clusters, seed_healthy +from tests.fakes.capella import bucket_id + +DEFAULTS = {"dayOfWeek": "sunday", "startAt": 0, "incrementalEvery": 24, "retentionTime": "30days", + "costOptimizedRetention": False} +NO_CHANGES = "Plan: 0 to add, 0 to change, 0 to delete, 0 blocked." + + +@pytest.fixture +def imported(fake, run, tmp_path): + pid, cid = seed_healthy(fake) + fake.seed_bucket(cid, "orders") + assert run("import").exit_code == 0 + fake.reset_calls() + return tmp_path / "capella.yaml", cid + + +def _schedule(path): + return next(b for b in file_clusters(path)[0]["buckets"] if b["name"] == "orders").get("backupSchedule") + + +def _set(run, *extra): + return run("--auto-approve", "bucket", "backup", "schedule", "set", "nova-dev-01", "orders", *extra) + + +@pytest.mark.story("CAP-058", "AC1") +def test_bucket_backup_ac1_set_create(fake, run, imported): + path, cid = imported + r = _set(run) + assert r.exit_code == 0, r.output + assert ("+ bucket-backup-schedule nova-dev-01/orders weekly full on sunday at 00:00 UTC · incremental every 24 h · " + "kept 30 days") in r.output + posts = fake.calls("POST", "*/backup/schedules") + assert len(posts) == 1 and posts[0].body == {"type": "weekly", "weeklySchedule": DEFAULTS} + assert _schedule(path) == DEFAULTS + assert list(next(b for b in file_clusters(path)[0]["buckets"]))[-1] == "backupSchedule" + assert NO_CHANGES in run("plan").output + + +@pytest.mark.story("CAP-058", "AC1") +def test_bucket_backup_ac1_set_update(fake, run, imported): + path, cid = imported + assert _set(run).exit_code == 0 + fake.reset_calls() + r = _set(run, "--incremental-every", "12") + assert r.exit_code == 0, r.output + assert "incrementalEvery: 24 → 12" in r.output + puts = fake.calls("PUT", "*/backup/schedules") + assert len(puts) == 1 and puts[0].body["weeklySchedule"] == {**DEFAULTS, "incrementalEvery": 12} + assert fake.calls("POST", "*/backup/schedules") == [] + assert _schedule(path) == {**DEFAULTS, "incrementalEvery": 12} + + +@pytest.mark.story("CAP-058", "AC1") +def test_bucket_backup_ac1_set_interactive(fake, run, imported, monkeypatch): + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("bucket", "backup", "schedule", "set", "nova-dev-01", "orders", input="monday\n3\n12\n90days\nn\ny\n") + assert r.exit_code == 0, r.output + assert fake.calls("POST", "*/backup/schedules")[0].body["weeklySchedule"] == { + "dayOfWeek": "monday", "startAt": 3, "incrementalEvery": 12, "retentionTime": "90days", + "costOptimizedRetention": False} + + +@pytest.mark.story("CAP-058", "AC2") +def test_bucket_backup_ac2_rerun_zero_writes(fake, run, imported): + assert _set(run, "--day", "friday", "--start-at", "2").exit_code == 0 + fake.reset_calls() + r = _set(run, "--day", "friday", "--start-at", "2") + assert r.exit_code == 0 and "already matches" in r.output + assert "No changes" in run("--auto-approve", "apply").output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-058", "AC3") +@pytest.mark.parametrize("extra,message", [ + (["--incremental-every", "3"], "incrementalEvery: must be one of: 1, 2, 4, 6, 8, 12, 24"), + (["--retention", "45days"], "retentionTime: must be one of: 30days, 60days"), + (["--day", "someday"], "dayOfWeek: must be one of: sunday"), + (["--start-at", "24"], "startAt: must be one of: 0, 1, 2"), +]) +def test_bucket_backup_ac3_invalid_values(fake, run, imported, extra, message): + path, _ = imported + before = path.read_text(encoding="utf-8") + r = _set(run, *extra) + assert r.exit_code == 3 and message in r.output, r.output + assert fake.calls() == [] and path.read_text(encoding="utf-8") == before # refused offline + + +@pytest.mark.story("CAP-058", "AC3") +def test_bucket_backup_ac3_free_tier(fake, run): + pid = fake.seed_project("nova-dev") + fid = fake.seed_free_cluster(pid, "try-01") + fake.seed_bucket(fid, "free-b") + assert run("import").exit_code == 0 + r = run("--auto-approve", "bucket", "backup", "schedule", "set", "try-01", "free-b") + assert r.exit_code == 4 and "free-tier clusters have no backups" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-058", "AC4") +def test_bucket_backup_ac4_show(fake, run, imported): + _, cid = imported + r = run("bucket", "backup", "schedule", "show", "nova-dev-01", "orders") + assert r.exit_code == 0 and "has no backup schedule" in r.output + fake.bucket_backup_schedules[(cid, bucket_id("orders"))] = {"weeklySchedule": {**DEFAULTS, "startAt": 2, + "retentionTime": "1year"}} + r = run("bucket", "backup", "schedule", "show", "nova-dev-01", "orders") + assert "weekly full on sunday at 02:00 UTC · incremental every 24 h · kept 1 year" in r.output + data = json.loads(run("-o", "json", "bucket", "backup", "schedule", "show", "nova-dev-01", "orders").output) + assert data["type"] == "weekly" and data["weeklySchedule"]["startAt"] == 2 + + +@pytest.mark.story("CAP-058", "AC4") +def test_bucket_backup_ac4_delete_and_missing(fake, run, imported): + path, cid = imported + assert _set(run).exit_code == 0 + fake.reset_calls() + r = run("--auto-approve", "bucket", "backup", "schedule", "delete", "nova-dev-01", "orders") + assert r.exit_code == 0, r.output + assert len(fake.calls("DELETE", "*/backup/schedules")) == 1 and _schedule(path) is None + fake.reset_calls() + r = run("--auto-approve", "bucket", "backup", "schedule", "delete", "nova-dev-01", "orders") + assert r.exit_code == 0 and "has no backup schedule; nothing to do" in r.output and fake.writes() == [] + + +@pytest.mark.story("CAP-058", "AC5") +def test_bucket_backup_ac5_cycles(fake, run, imported): + _, cid = imported + fake.seed_bucket_backup(cid, "orders", cycle="cy-1", date="2026-10-18T00:00:00Z") + fake.seed_bucket_backup(cid, "orders", method="incremental", cycle="cy-1", date="2026-10-19T00:00:00Z") + fake.seed_bucket_backup(cid, "orders", cycle="cy-2", date="2026-10-25T00:00:00Z") + fake.seed_bucket_backup(cid, "orders", method="incremental", source="manual", cycle="cy-2", + date="2026-10-26T00:00:00Z", status="failed") + r = run("bucket", "backup", "cycle", "list", "nova-dev-01", "orders") + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "STARTED", "BACKUPS", "LATEST", "STATUS", "ID"] + assert lines[1].split() == ["1", "2026-10-25", "00:00", "2", "failed", "cy-2"] + assert lines[2].split() == ["2", "2026-10-18", "00:00", "2", "ready", "cy-1"] + r = run("bucket", "backup", "cycle", "get", "nova-dev-01", "orders", "cy-2") + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "DATE", "METHOD", "SOURCE", "STATUS", "SIZE", "ITEMS", "EXPIRES", "ID"] + assert lines[1].split()[3:6] == ["incremental", "manual", "failed"] + assert lines[2].split()[3:6] == ["full", "scheduled", "ready"] + r = run("bucket", "backup", "cycle", "get", "nova-dev-01", "orders", "nope") + assert r.exit_code == 1 and "cycle nope of orders not found" in r.output + + +@pytest.mark.story("CAP-058", "AC6") +def test_bucket_backup_ac6_apply_sync_drift(fake, run, imported): + path, cid = imported + key = (cid, bucket_id("orders")) + fake.bucket_backup_schedules[key] = {"weeklySchedule": {**DEFAULTS, "dayOfWeek": "monday"}} + r = run("plan") + assert NO_CHANGES in r.output and "bucket-backup-schedule" not in r.output # not declared: left alone + assert "bucket-backup-schedule nova-dev-01/orders weekly full on monday" in run("drift").output + assert run("sync").exit_code == 0 + assert _schedule(path) == {**DEFAULTS, "dayOfWeek": "monday"} + fake.reset_calls() + assert "No changes" in run("--auto-approve", "apply").output and fake.writes() == [] + fake.bucket_backup_schedules[key]["weeklySchedule"]["startAt"] = 5 # changed in the Capella UI + assert "startAt" in run("drift").output + r = run("--auto-approve", "apply") + assert r.exit_code == 0 and fake.bucket_backup_schedules[key]["weeklySchedule"]["startAt"] == 0 + del fake.bucket_backup_schedules[key] + assert run("--auto-approve", "apply").exit_code == 0 and key in fake.bucket_backup_schedules # recreated + + +@pytest.mark.story("CAP-058", "AC7") +def test_bucket_backup_ac7_help(run): + for args in (("schedule", "show"), ("schedule", "set"), ("schedule", "delete"), ("cycle", "list"), + ("cycle", "get")): + r = run("bucket", "backup", *args, "--help") + assert r.exit_code == 0 and "Examples:" in r.output and "capellactl bucket backup" in r.output, args + assert "Bucket backup" in run("help", "backup").output diff --git a/tests/scenario/test_cluster_backup.py b/tests/scenario/test_cluster_backup.py new file mode 100644 index 0000000..60dca7d --- /dev/null +++ b/tests/scenario/test_cluster_backup.py @@ -0,0 +1,171 @@ +"""CAP-057: cluster backups (cloud snapshots): list, back up now, keep longer, delete and restore.""" +from __future__ import annotations + +import json + +import pytest + +from capellactl.cli import console +from tests.conftest import seed_healthy + + +@pytest.fixture +def imported(fake, run, tmp_path): + pid, cid = seed_healthy(fake) + assert run("import").exit_code == 0 + fake.reset_calls() + return cid + + +@pytest.fixture +def three(fake, imported): + ids = [fake.seed_snapshot(imported, created_at=f"2026-10-2{d}T02:00:00Z") for d in (6, 8, 7)] + return imported, ids + + +@pytest.mark.story("CAP-057", "AC1") +def test_cluster_backup_ac1_list(fake, run, three): + cid, ids = three + r = run("cluster", "backup", "list", "nova-dev-01") + assert r.exit_code == 0, r.output + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "CREATED", "TYPE", "STATUS", "SIZE", "EXPIRES", "COPIES", "ID"] + assert [line.split()[-1] for line in lines[1:]] == [ids[1], ids[2], ids[0]] # newest first, ID last + assert lines[1].split()[:3] == ["1", "2026-10-28", "02:00"] and "42 GB" in lines[1] + data = json.loads(run("-o", "json", "cluster", "backup", "list", "nova-dev-01").output) + assert {s["id"] for s in data} == set(ids) + for i in ids: + del fake.snapshots[i] + assert "no backups" in run("cluster", "backup", "list", "nova-dev-01").output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-057", "AC1") +def test_cluster_backup_ac1_list_project(fake, run, three): + r = run("cluster", "backup", "list") + assert r.exit_code == 0, r.output + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "CLUSTER", "REGION", "NEWEST", "OLDEST", "CLUSTER", "ID"] + assert lines[1].split()[:6] == ["1", "nova-dev-01", "us-east-1", "2026-10-28", "02:00", "2026-10-26"] + assert fake.calls("GET", "*/projects/*/cloudsnapshotbackups") + + +@pytest.mark.story("CAP-057", "AC2") +def test_cluster_backup_ac2_create_waits(fake, run, imported): + r = run("--auto-approve", "cluster", "backup", "create", "nova-dev-01", "--retain", "7d", "--copy-to", "us-west-2") + assert r.exit_code == 0, r.output + posts = fake.calls("POST", "*/cloudsnapshotbackups") + assert len(posts) == 1 and posts[0].body == {"retention": 168, "regionsToCopy": ["us-west-2"]} + backup_id = next(iter(fake.snapshots)) + assert "backup of nova-dev-01: pending" in r.output and f"backup {backup_id} of nova-dev-01 is complete" in r.output + r = run("--auto-approve", "--no-wait", "cluster", "backup", "create", "nova-dev-01") + assert r.exit_code == 0 and "started (--no-wait)" in r.output and "complete" not in r.output + + +@pytest.mark.story("CAP-057", "AC2") +@pytest.mark.parametrize("extra,message", [ + (["--copy-to", "us-east-2", "--copy-to", "us-west-2", "--copy-to", "eu-west-1"], "at most two regions"), + (["--copy-to", "mars-1"], "copies cannot go to mars-1"), + (["--retain", "soon"], "--retain takes hours or days"), +]) +def test_cluster_backup_ac2_create_refused(fake, run, imported, extra, message): + r = run("--auto-approve", "cluster", "backup", "create", "nova-dev-01", *extra) + assert r.exit_code == 3 and message in r.output, r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-057", "AC3") +def test_cluster_backup_ac3_retain(fake, run, three): + cid, ids = three + r = run("--auto-approve", "cluster", "backup", "retain", "nova-dev-01", ids[0], "--retain", "90d") + assert r.exit_code == 0, r.output + puts = fake.calls("PUT", "*/cloudsnapshotbackups/*") + assert len(puts) == 1 and puts[0].body == {"retention": 2160} + assert "is now kept 90 days (was 30 days)" in r.output + r = run("cluster", "backup", "retain", "nova-dev-01", "nope", "--retain", "90d") + assert r.exit_code == 1 and "backup nope of nova-dev-01 not found" in r.output + + +@pytest.mark.story("CAP-057", "AC3") +def test_cluster_backup_ac3_retain_rerun_noop(fake, run, three): + _, ids = three + assert run("cluster", "backup", "retain", "nova-dev-01", ids[0], "--retain", "90d").exit_code == 0 + fake.reset_calls() + r = run("cluster", "backup", "retain", "nova-dev-01", ids[0], "--retain", "2160h") + assert r.exit_code == 0 and "No changes" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-057", "AC4") +def test_cluster_backup_ac4_delete(fake, run, three, monkeypatch): + _, ids = three + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("cluster", "backup", "delete", "nova-dev-01", ids[0], input="nova\n") + assert r.exit_code == 0 and "Cancelled; nothing was deleted." in r.output + assert fake.writes() == [] + r = run("cluster", "backup", "delete", "nova-dev-01", ids[0], input="nova-dev-01\n") + assert r.exit_code == 0, r.output + assert len(fake.calls("DELETE", "*/cloudsnapshotbackups/*")) == 1 and ids[0] not in fake.snapshots + + +@pytest.mark.story("CAP-057", "AC4") +def test_cluster_backup_ac4_delete_missing_noop(fake, run, three): + r = run("--auto-approve", "cluster", "backup", "delete", "nova-dev-01", "nope") + assert r.exit_code == 0 and "not found, nothing to do" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-057", "AC5") +def test_cluster_backup_ac5_restore_confirm(fake, run, three, monkeypatch): + _, ids = three + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("cluster", "backup", "restore", "nova-dev-01", ids[1], input="other\n") + assert r.exit_code == 0, r.output + assert "all data is replaced with the backup of 2026-10-28 02:00 UTC" in r.output + assert "Cancelled; nothing was restored." in r.output and fake.writes() == [] + monkeypatch.setattr(console, "is_interactive", lambda: False) + r = run("cluster", "backup", "restore", "nova-dev-01", ids[1]) + assert r.exit_code != 0 and fake.writes() == [] # no terminal and no --auto-approve: refused + + +@pytest.mark.story("CAP-057", "AC5") +def test_cluster_backup_ac5_restore_waits(fake, run, three): + cid, ids = three + r = run("--auto-approve", "cluster", "backup", "restore", "nova-dev-01", ids[1], "--from-region", "us-west-2") + assert r.exit_code == 0, r.output + posts = fake.calls("POST", "*/cloudsnapshotbackups/*/restore") + assert len(posts) == 1 and posts[0].body == {"crossRegionRestorePreference": ["us-west-2"]} + assert "restore of nova-dev-01: complete" in r.output and "cluster nova-dev-01: healthy" in r.output + assert "restored cluster nova-dev-01 from the backup of 2026-10-28 02:00 UTC" in r.output + + +@pytest.mark.story("CAP-057", "AC5") +def test_cluster_backup_ac5_restore_turned_off(fake, run, three): + cid, ids = three + fake.set_transition(cid, ["turnedOff"]) + r = run("--auto-approve", "cluster", "backup", "restore", "nova-dev-01", ids[1]) + assert r.exit_code == 4 and "turned off; power it on before restoring" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-057", "AC6") +def test_cluster_backup_ac6_restores_and_regions(fake, run, three): + _, ids = three + assert "no restores" in run("cluster", "backup", "restores", "nova-dev-01").output + assert run("--auto-approve", "cluster", "backup", "restore", "nova-dev-01", ids[0]).exit_code == 0 + r = run("cluster", "backup", "restores", "nova-dev-01") + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "STARTED", "FINISHED", "SNAPSHOT", "STATUS", "ID"] + assert "complete" in lines[1] and "2026-10-26 02:00" in lines[1] + r = run("cluster", "backup", "regions", "nova-dev-01") + assert r.output.splitlines()[0].split() == ["S.NO", "REGION"] and "us-west-2" in r.output + + +@pytest.mark.story("CAP-057", "AC7") +def test_cluster_backup_ac7_help(run): + r = run("cluster", "backup", "--help") + assert r.exit_code == 0 + for verb in ("list", "create", "retain", "delete", "restore", "restores", "regions", "schedule"): + assert verb in r.output, verb + h = run("cluster", "backup", verb, "--help") + assert h.exit_code == 0 and "Examples:" in h.output, verb diff --git a/tests/scenario/test_cluster_backup_schedule.py b/tests/scenario/test_cluster_backup_schedule.py new file mode 100644 index 0000000..7c8da87 --- /dev/null +++ b/tests/scenario/test_cluster_backup_schedule.py @@ -0,0 +1,201 @@ +"""CAP-056: cluster backup schedule (cloud snapshots) as code.""" +from __future__ import annotations + +import json + +import pytest + +from tests.conftest import file_clusters, seed_healthy + +NO_CHANGES = "Plan: 0 to add, 0 to change, 0 to delete, 0 blocked." +SCHEDULE = {"interval": 24, "retention": 720, "startTime": "2026-10-30T02:00:00Z", "copyToRegions": ["us-west-2"]} + + +@pytest.fixture +def imported(fake, run, tmp_path): + pid, cid = seed_healthy(fake) + assert run("import").exit_code == 0 + fake.reset_calls() + return tmp_path / "capella.yaml", cid + + +def _schedule(path): + return file_clusters(path)[0].get("backupSchedule") + + +@pytest.mark.story("CAP-056", "AC1") +def test_cluster_backup_ac1_set_body(fake, run, imported): + path, cid = imported + r = run("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", "--every", "24", "--retain", "30d") + assert r.exit_code == 0, r.output + assert "+ backup-schedule nova-dev-01 every 24 h · kept 30 days" in r.output + assert fake.calls("GET", "*/cloudsnapshotbackupschedule/options") # what the plan permits is read first + puts = fake.calls("PUT", "*/cloudsnapshotbackupschedule") + assert len(puts) == 1 and puts[0].body == {"interval": 24, "retention": 720, "copyToRegions": []} + assert _schedule(path) == {"interval": 24, "retention": 720} + assert fake.snapshot_schedules[cid]["retention"] == 720 + + +@pytest.mark.story("CAP-056", "AC1") +def test_cluster_backup_ac1_no_drift(fake, run, imported): + path, _ = imported + r = run("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", "--every", "6", "--retain", "168h", + "--start", "02:00", "--copy-to", "us-west-2") + assert r.exit_code == 0, r.output + body = fake.calls("PUT", "*/cloudsnapshotbackupschedule")[0].body + assert body["startTime"].endswith("T02:00:00Z") and body["copyToRegions"] == ["us-west-2"] + assert _schedule(path) == {"interval": 6, "retention": 168, "startTime": "02:00", "copyToRegions": ["us-west-2"]} + assert NO_CHANGES in run("plan").output + assert run("drift", "--detailed-exitcode").exit_code == 0 + + +@pytest.mark.story("CAP-056", "AC2") +def test_cluster_backup_ac2_rerun_zero_writes(fake, run, imported): + args = ("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", "--every", "24", "--retain", "30d", + "--start", "02:00") + assert run(*args).exit_code == 0 + fake.reset_calls() + r = run(*args) + assert r.exit_code == 0 and "already matches" in r.output + assert "No changes" in run("--auto-approve", "apply").output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC3") +@pytest.mark.parametrize("extra,message", [ + (["--every", "3", "--retain", "30d"], "interval 3 is not permitted: use one of 1, 2, 4, 6, 8, 12, 24 hours"), + (["--every", "24", "--retain", "12"], "retention 12 h is not permitted: keep backups 1 day to 90 days"), + (["--every", "24", "--retain", "120d"], "retention 120 days is not permitted"), + (["--every", "24", "--retain", "30d", "--copy-to", "us-east-2", "--copy-to", "us-west-2", "--copy-to", + "eu-west-1"], "copyToRegions takes at most two regions"), + (["--every", "24", "--retain", "30d", "--copy-to", "mars-1"], "copies cannot go to mars-1"), + (["--every", "24", "--retain", "1w"], "--retain takes hours or days"), + (["--retain", "30d"], "a new backup schedule needs --every HOURS and --retain DURATION"), +]) +def test_cluster_backup_ac3_refused(fake, run, imported, extra, message): + path, _ = imported + before = path.read_text(encoding="utf-8") + r = run("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", *extra) + assert r.exit_code == 3 and message in r.output, r.output + assert fake.writes() == [] and path.read_text(encoding="utf-8") == before + + +@pytest.mark.story("CAP-056", "AC3") +def test_cluster_backup_ac3_free_tier(fake, run, tmp_path): + pid = fake.seed_project("nova-dev") + fake.seed_free_cluster(pid, "try-01") + assert run("import").exit_code == 0 + r = run("--auto-approve", "cluster", "backup", "schedule", "set", "try-01", "--every", "24", "--retain", "30d") + assert r.exit_code == 4 and "free-tier clusters have no backups" in r.output + r = run("cluster", "backup", "list", "try-01") + assert r.exit_code == 4 and "free-tier clusters have no backups" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC4") +def test_cluster_backup_ac4_show(fake, run, imported): + _, cid = imported + r = run("cluster", "backup", "schedule", "show", "nova-dev-01") + assert r.exit_code == 0 and "has no backup schedule" in r.output + fake.snapshot_schedules[cid] = dict(SCHEDULE) + r = run("cluster", "backup", "schedule", "show", "nova-dev-01") + assert r.exit_code == 0, r.output + assert "every 24 h · kept 30 days · first at 02:00 UTC · copies: us-west-2" in r.output + assert "next backup: " in r.output + data = json.loads(run("-o", "json", "cluster", "backup", "schedule", "show", "nova-dev-01").output) + assert data["interval"] == 24 and data["copyToRegions"] == ["us-west-2"] and "nextBackup" in data + + +@pytest.mark.story("CAP-056", "AC4") +def test_cluster_backup_ac4_options(fake, run, imported): + r = run("cluster", "backup", "schedule", "options", "nova-dev-01") + assert r.exit_code == 0, r.output + assert "every (hours) 1, 2, 4, 6, 8, 12, 24" in r.output + assert "keep 1 day to 90 days" in r.output + assert "copy regions us-east-2, us-west-2, eu-west-1 (up to two)" in r.output + data = json.loads(run("-o", "json", "cluster", "backup", "schedule", "options", "nova-dev-01").output) + assert data["maximumRetention"] == 2160 and data["regions"][0] == "us-east-2" + assert fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC5") +def test_cluster_backup_ac5_delete(fake, run, imported): + path, cid = imported + assert run("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", "--every", "24", "--retain", + "30d").exit_code == 0 + fake.reset_calls() + r = run("--auto-approve", "cluster", "backup", "schedule", "delete", "nova-dev-01") + assert r.exit_code == 0, r.output + assert "- backup schedule nova-dev-01 every 24 h · kept 30 days" in r.output + assert len(fake.calls("DELETE", "*/cloudsnapshotbackupschedule")) == 1 + assert cid not in fake.snapshot_schedules and _schedule(path) is None + + +@pytest.mark.story("CAP-056", "AC5") +def test_cluster_backup_ac5_delete_missing_noop(fake, run, imported): + r = run("--auto-approve", "cluster", "backup", "schedule", "delete", "nova-dev-01") + assert r.exit_code == 0 and "has no backup schedule; nothing to do" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC6") +def test_cluster_backup_ac6_apply_creates(fake, run, imported): + path, cid = imported + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + at = next(i for i, line in enumerate(lines) if line.lstrip().startswith("serviceGroups:")) + indent = lines[at][: len(lines[at]) - len(lines[at].lstrip())] + lines.insert(at, f"{indent}backupSchedule: {{interval: 12, retention: 336}}\n") + path.write_text("".join(lines), encoding="utf-8") + r = run("--auto-approve", "apply") + assert r.exit_code == 0, r.output + assert fake.snapshot_schedules[cid] == {"interval": 12, "retention": 336, "copyToRegions": []} + fake.reset_calls() + assert "No changes" in run("--auto-approve", "apply").output and fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC6") +def test_cluster_backup_ac6_sync_round_trip(fake, run, imported): + path, cid = imported + fake.snapshot_schedules[cid] = dict(SCHEDULE) + r = run("sync") + assert r.exit_code == 0, r.output + assert _schedule(path) == {"interval": 24, "retention": 720, "startTime": "02:00", "copyToRegions": ["us-west-2"]} + fake.reset_calls() + assert "No changes" in run("--auto-approve", "apply").output and fake.writes() == [] + + +@pytest.mark.story("CAP-056", "AC6") +def test_cluster_backup_ac6_sync_keeps_schedule_the_key_cannot_read(fake, run, imported): + """A Project Manager key may not read backups: sync keeps the file's schedule and warns once.""" + path, cid = imported + assert run("--auto-approve", "cluster", "backup", "schedule", "set", "nova-dev-01", "--every", "24", "--retain", + "30d").exit_code == 0 + fake.fail_next("GET", "*/cloudsnapshotbackupschedule", 403, times=5) + r = run("sync") + assert r.exit_code == 0, r.output + assert "needs the Organization Owner or Project Owner role" in r.output + assert _schedule(path) == {"interval": 24, "retention": 720} + + +@pytest.mark.story("CAP-056", "AC6") +def test_cluster_backup_ac6_drift_and_prune(fake, run, imported): + _, cid = imported + fake.snapshot_schedules[cid] = dict(SCHEDULE) + r = run("plan") + assert NO_CHANGES in r.output and "backup-schedule" not in r.output # without the key, plan leaves it alone + r = run("drift") + assert "backup-schedule nova-dev-01 every 24 h · kept 30 days" in r.output, r.output + r = run("--auto-approve", "apply", "--prune") + assert r.exit_code == 0, r.output + assert "- backup-schedule nova-dev-01" in r.output and cid not in fake.snapshot_schedules + + +@pytest.mark.story("CAP-056", "AC7") +def test_cluster_backup_ac7_help(run): + for verb in ("show", "options", "set", "delete"): + r = run("cluster", "backup", "schedule", verb, "--help") + assert r.exit_code == 0 and "Examples:" in r.output and "capellactl cluster backup schedule" in r.output, verb + r = run("help", "backup") + assert "Cluster backup" in r.output and "Bucket backup" in r.output and "backupSchedule" in r.output + r = run("help", "config", "clusters.backupSchedule") + assert r.exit_code == 0 and "Cloud snapshot backups" in r.output diff --git a/tests/scenario/test_cluster_create_mds.py b/tests/scenario/test_cluster_create_mds.py index b4beb36..458931d 100644 --- a/tests/scenario/test_cluster_create_mds.py +++ b/tests/scenario/test_cluster_create_mds.py @@ -104,7 +104,7 @@ def test_cluster_mds_ac4_data_group_first(fake, run, imported): @pytest.mark.story("CAP-051", "AC5") @pytest.mark.parametrize("provider,region,extra,disks", [ ("aws", "us-east-1", ["--disk-size", "100"], - [{"type": "gp3", "storage": 100, "iops": 3000}, {"type": "gp3", "storage": 200, "iops": 3000}]), + [{"type": "gp3", "storage": 100, "iops": 4370}, {"type": "gp3", "storage": 200, "iops": 5740}]), # CAP-060 AC3 ("gcp", "us-east1", ["--disk-size", "100"], [{"type": "pd-ssd", "storage": 100}, {"type": "pd-ssd", "storage": 200}]), ("azure", "eastus", [], [{"type": "P6", "autoExpansion": True}, {"type": "P6"}]), ]) @@ -129,9 +129,9 @@ def test_cluster_mds_ac5_auto_expansion_azure_only(fake, run, imported): def test_cluster_mds_ac6_interactive_two_groups(fake, run, imported, monkeypatch): monkeypatch.setattr(console, "is_interactive", lambda: True) answers = ["provisioned", "aws", "custom", "multiple", # a single word is enough - "data", "3", "4", "", # group 1: data, 3 nodes, size 4 (8/32), disk default + "data", "3", "4", "", "", "", # group 1: data, 3 nodes, size 4 (8/32), disk type, size, IOPS default "y", - "index,query", "2", "2", "100", # group 2: 4/32 with 100 GB + "index,query", "2", "2", "", "100", "", # group 2: 4/32 with gp3 100 GB (CAP-060 picker) "n", "", "", "", "y"] r = run("cluster", "create", "nova-prod-01", input="\n".join(answers) + "\n") assert r.exit_code == 0, r.output @@ -147,8 +147,8 @@ def test_cluster_mds_ac6_interactive_two_groups(fake, run, imported, monkeypatch def test_cluster_mds_ac6_interactive_reasks_bad_answer(fake, run, imported, monkeypatch): monkeypatch.setattr(console, "is_interactive", lambda: True) answers = ["provisioned", "aws", "custom", "multiple groups", - "dat", "data", "2", "3", "4", "", "y", - "data", "cache", "search", "25", "2", "1", "", + "dat", "data", "2", "3", "4", "", "", "", "y", + "data", "cache", "search", "25", "2", "1", "", "", "", "n", "", "", "", "y"] r = run("cluster", "create", "nova-prod-01", input="\n".join(answers) + "\n") assert r.exit_code == 0, r.output @@ -161,7 +161,7 @@ def test_cluster_mds_ac6_interactive_reasks_bad_answer(fake, run, imported, monk @pytest.mark.story("CAP-051", "AC6") def test_cluster_mds_ac6_one_group_keeps_questions(fake, run, imported, monkeypatch): monkeypatch.setattr(console, "is_interactive", lambda: True) - answers = ["provisioned", "aws", "custom", "one group", "3", "8", "32", "", "", "", "y"] + answers = ["provisioned", "aws", "custom", "one group", "3", "8", "32", "", "", "", "", "", "", "y"] r = run("cluster", "create", "nova-qa-01", input="\n".join(answers) + "\n") assert r.exit_code == 0, r.output assert "vCPUs per node" in r.output and "Node size" not in r.output @@ -208,7 +208,7 @@ def test_cluster_mds_ac8_help_and_options(fake, run, imported): @pytest.mark.story("CAP-051", "AC9") def test_cluster_mds_ac9_interactive_offers_known_sizes(fake, run, imported, monkeypatch): monkeypatch.setattr(console, "is_interactive", lambda: True) - answers = ["provisioned", "aws", "custom", "multiple groups", "data", "3", "18", "6", "30", "", "n", + answers = ["provisioned", "aws", "custom", "multiple groups", "data", "3", "18", "6", "30", "", "", "", "n", "", "", "", "y"] r = run("cluster", "create", "nova-prod-01", input="\n".join(answers) + "\n") assert r.exit_code == 0, r.output @@ -255,9 +255,9 @@ def test_cluster_mds_ac10_deployment_failed_not_deleted(fake, run, imported): def test_cluster_mds_ac6_default_is_unused_services(fake, run, imported, monkeypatch): monkeypatch.setattr(console, "is_interactive", lambda: True) answers = ["provisioned", "aws", "custom", "multiple", - "data", "3", "4", "", "y", - "index,query", "2", "1", "", "y", - "", "2", "1", "", # Enter: one group with every service not used yet + "data", "3", "4", "", "", "", "y", + "index,query", "2", "1", "", "", "", "y", + "", "2", "1", "", "", "", # Enter: one group with every service not used yet "n", "", "", "", "y"] r = run("cluster", "create", "nova-prod-01", input="\n".join(answers) + "\n") assert r.exit_code == 0, r.output diff --git a/tests/scenario/test_cluster_disk.py b/tests/scenario/test_cluster_disk.py new file mode 100644 index 0000000..3a88a28 --- /dev/null +++ b/tests/scenario/test_cluster_disk.py @@ -0,0 +1,115 @@ +"""CAP-060: choose the disk type, and see what each type means.""" +from __future__ import annotations + +import json + +import pytest + +from capellactl.cli import console +from tests.conftest import file_clusters, seed_healthy + + +@pytest.fixture +def imported(fake, run, tmp_path): + _, cid = seed_healthy(fake) + assert run("import").exit_code == 0 + fake.reset_calls() + return tmp_path / "capella.yaml", cid + + +def _cluster(path, name): + return next(c for c in file_clusters(path) if c["name"] == name) + + +def _create(run, name, answers): + return run("cluster", "create", name, input="\n".join(answers) + "\n") + + +@pytest.mark.story("CAP-060", "AC1") +def test_disk_types_ac1_list(run): + r = run("cluster", "disk-types") + assert r.exit_code == 0, r.output + lines = r.output.splitlines() + assert lines[0].split() == ["S.NO", "PROVIDER", "TYPE", "SIZE", "(GB)", "IOPS", "FOR"] + assert lines[1].split()[:5] == ["1", "aws", "gp3", "50–16000", "3000–16000"] and "general-purpose" in lines[1] + assert "io2" in lines[2] and "3000–64000" in lines[2] + assert "pd-ssd" in r.output and "60 per GB" in r.output and "Ultra" in r.output + r = run("cluster", "disk-types", "--provider", "azure") + rows = [line.split()[2] for line in r.output.splitlines()[1:] if line[:1].isdigit()] + assert rows == ["P6", "P10", "P15", "P20", "P30", "P40", "P50", "P60", "Ultra"] + data = json.loads(run("-o", "json", "cluster", "disk-types", "--provider", "aws").output) + assert list(data) == ["aws"] and data["aws"]["io2"]["iops"] == [3000, 64000] + + +@pytest.mark.story("CAP-060", "AC1") +def test_disk_types_ac1_options_points_to_list(fake, run, imported): + r = run("cluster", "options") + assert r.exit_code == 0, r.output + assert "disk types aws gp3, io2 · gcp pd-ssd · azure P6" in r.output + assert "capellactl cluster disk-types" in r.output + data = json.loads(run("-o", "json", "cluster", "options").output) + assert data["provisioned"]["diskTypes"]["gcp"] == ["pd-ssd"] + + +@pytest.mark.story("CAP-060", "AC2") +@pytest.mark.parametrize("provider,disk_answers,disk,shown", [ + ("aws", ["2", "200", ""], {"type": "io2", "storage": 200, "iops": 5740}, "IOPS (3000–64000; recommended 5740"), + ("azure", ["3"], {"type": "P15"}, "P15: 256 GB, 1100 IOPS (set by the type)"), + ("gcp", ["1", "100"], {"type": "pd-ssd", "storage": 100}, "up to 3000 read and 3000 write IOPS"), +]) +def test_disk_types_ac2_create_picker(fake, run, imported, monkeypatch, provider, disk_answers, disk, shown): + monkeypatch.setattr(console, "is_interactive", lambda: True) + answers = ["provisioned", provider, "custom", "one group", "3", "8", "32", *disk_answers, "", "", "", "y"] + r = _create(run, "nova-qa-01", answers) + assert r.exit_code == 0, r.output + assert "Disk type:" in r.output and shown in r.output + assert r.output.index("Disk type:") > r.output.index("RAM GB per node") # asked after the node size + body = fake.calls("POST", "*/clusters")[0].body + assert body["serviceGroups"][0]["node"]["disk"] == disk + + +@pytest.mark.story("CAP-060", "AC2") +def test_disk_types_ac2_group_add_picker(fake, run, imported, monkeypatch): + monkeypatch.setattr(console, "is_interactive", lambda: True) + r = run("cluster", "group", "add", "nova-dev-01", "--services", "search", input="2\n2\n100\n\ny\n") + assert r.exit_code == 0, r.output + assert "Disk type:" in r.output and "Choose [1]" in r.output # the data group's gp3 is the default + groups = fake.calls("PUT", "*/clusters/*")[0].body["serviceGroups"] + assert groups[1]["node"]["disk"] == {"type": "io2", "storage": 100, "iops": 4370} + + +@pytest.mark.story("CAP-060", "AC5") +def test_disk_types_ac5_update_type(fake, run, imported): + path, _ = imported + r = run("--auto-approve", "cluster", "update", "nova-dev-01", "--disk-type", "io2") + assert r.exit_code == 0, r.output + assert 'serviceGroups[data,index,query].node.disk.type: "gp3" → "io2"' in r.output + assert "disk type io2: provisioned-IOPS SSD" in r.output and "rebalances the nodes" in r.output + puts = fake.calls("PUT", "*/clusters/*") + assert len(puts) == 1 + assert puts[0].body["serviceGroups"][0]["node"]["disk"] == {"type": "io2", "storage": 50, "iops": 3000} + assert _cluster(path, "nova-dev-01")["serviceGroups"][0]["node"]["disk"]["type"] == "io2" + + +@pytest.mark.story("CAP-060", "AC5") +def test_disk_types_ac5_rerun_noop_and_wrong_provider(fake, run, imported): + assert run("--auto-approve", "cluster", "update", "nova-dev-01", "--disk-type", "io2", "--disk-iops", + "20000").exit_code == 0 + fake.reset_calls() + r = run("--auto-approve", "cluster", "update", "nova-dev-01", "--disk-type", "io2", "--disk-iops", "20000") + assert r.exit_code == 0 and "No changes" in r.output + assert fake.writes() == [] + r = run("--auto-approve", "cluster", "update", "nova-dev-01", "--disk-type", "P6") + assert r.exit_code == 3 and "disk type P6 is not valid for aws" in r.output + r = run("--auto-approve", "cluster", "update", "nova-dev-01", "--disk-iops", "90000") + assert r.exit_code == 3 and "io2 disks take 3000–64000 IOPS" in r.output + assert fake.writes() == [] + + +@pytest.mark.story("CAP-060", "AC6") +def test_disk_types_ac6_help(run): + for args in (("cluster", "create", "--help"), ("cluster", "update", "--help"), ("cluster", "group", "add", "--help")): + r = run(*args) + assert "capellactl cluster disk-types" in " ".join(r.output.split()), args + r = run("help", "scaling") + assert "Disk types" in r.output and "capellactl cluster disk-types" in r.output diff --git a/tests/scenario/test_configure.py b/tests/scenario/test_configure.py index c6bd735..a6aa49c 100644 --- a/tests/scenario/test_configure.py +++ b/tests/scenario/test_configure.py @@ -42,6 +42,14 @@ def test_configure_ac1_key_not_echoed(fake, run): assert_no_secret(profiles_path().read_text(encoding="utf-8"), KEY) +@pytest.mark.story("CAP-055", "AC4") +def test_windows_docs_ac4_powershell_piped_key_stored_clean(fake, run, keychain): + r = run("configure", "--api-key-stdin", input=f"{KEY}\r\n", no_env=True) # Get-Clipboard | … + assert r.exit_code == 0, r.output + assert keychain.store[("capellactl", "default")] == KEY + assert fake.calls("GET", "/v4/organizations")[0].headers["Authorization"] == f"Bearer {KEY}" + + @pytest.mark.story("CAP-037", "AC2") def test_configure_ac2_invalid_key_not_saved(fake, run, keychain): fake.api_keys = {"the-real-key"} diff --git a/tests/scenario/test_custom_clusters_guide.py b/tests/scenario/test_custom_clusters_guide.py index f6b46f7..ee89e00 100644 --- a/tests/scenario/test_custom_clusters_guide.py +++ b/tests/scenario/test_custom_clusters_guide.py @@ -22,19 +22,35 @@ def _commands() -> list[str]: return out -@pytest.mark.story("CAP-051", "AC8") -def test_cluster_mds_ac8_guide_steps(fake, run, tmp_path): +def _run_guide(fake, run) -> dict: + """Every command of the guide, in order, against the fake; the clusters it leaves, by name.""" fake.seed_project("sandbox") assert run("sync").exit_code == 0 - commands = _commands() - assert any("--group data:3x8/32" in c for c in commands) and any(c.startswith("cluster group add") for c in commands) - for cmd in commands: + for cmd in _commands(): if "--skip-project" in cmd: continue # the guide's project non-prod does not exist in the fake organization r = run("--auto-approve", *shlex.split(cmd)) allowed = next((codes for key, codes in EXPECTED.items() if cmd.startswith(key)), {0}) assert r.exit_code in allowed, f"{cmd}\n{r.output}" - clusters = {c["body"]["name"]: c["body"] for c in fake.clusters.values()} + return {c["body"]["name"]: c["body"] for c in fake.clusters.values()} + + +@pytest.mark.story("CAP-051", "AC8") +def test_cluster_mds_ac8_guide_steps(fake, run, tmp_path): + commands = _commands() + assert any("--group data:3x8/32" in c for c in commands) and any(c.startswith("cluster group add") for c in commands) + clusters = _run_guide(fake, run) assert {"prod-01", "prod-02", "prod-03", "prod-01-weu", "prod-01-copy", "dev-03", "prod-04"} <= set(clusters) assert [g["services"] for g in clusters["prod-01"]["serviceGroups"]] == \ [["data"], ["index", "query"], ["search"], ["eventing"]] + + +@pytest.mark.story("CAP-061", "AC2") +def test_disk_guide_ac2_steps_run(fake, run, tmp_path): + """Section 10 (choose the disk type, CAP-060) runs with the rest of the guide.""" + assert any(c.startswith("cluster disk-types") for c in _commands()) + disks = {",".join(g["services"]): g["node"]["disk"] for g in _run_guide(fake, run)["prod-05"]["serviceGroups"]} + assert disks == {"data,index,query": {"type": "io2", "storage": 200, "iops": 20000}, + "search": {"type": "io2", "storage": 200, "iops": 5740}} + first = next(c for c in fake.calls("POST", "*/clusters") if c.body["name"] == "prod-05").body + assert first["serviceGroups"][0]["node"]["disk"] == {"type": "gp3", "storage": 200, "iops": 5740} diff --git a/tests/scenario/test_resource_verbs.py b/tests/scenario/test_resource_verbs.py index 5f47c42..0d9a8ec 100644 --- a/tests/scenario/test_resource_verbs.py +++ b/tests/scenario/test_resource_verbs.py @@ -9,9 +9,9 @@ @pytest.mark.parametrize(("group", "verbs"), [ ("project", {"list", "get", "create", "update", "delete"}), ("cluster", {"list", "get", "create", "clone", "update", "delete", "options", "scale", "protect", "power", - "schedule", "group"}), + "schedule", "group", "disk-types", "backup"}), ("cidr", {"list", "create", "delete"}), - ("bucket", {"list", "get", "create", "update", "delete"}), + ("bucket", {"list", "get", "create", "update", "delete", "backup"}), ("scope", {"list", "create", "delete"}), ("collection", {"list", "create", "update", "delete"}), ("credential", {"list", "get", "create", "update", "delete"}), diff --git a/tests/unit/api/test_endpoint_inventory.py b/tests/unit/api/test_endpoint_inventory.py index 41a007d..f4a424b 100644 --- a/tests/unit/api/test_endpoint_inventory.py +++ b/tests/unit/api/test_endpoint_inventory.py @@ -20,6 +20,13 @@ "delete_scope", "list_collections", "get_collection", "create_collection", "update_collection", "delete_collection", "list_credentials", "get_credential", "create_credential", "update_credential", "delete_credential", + # Backups (CAP-056–CAP-059) + "get_snapshot_schedule", "upsert_snapshot_schedule", "delete_snapshot_schedule", "get_snapshot_schedule_options", + "list_snapshot_regions", "list_snapshots", "list_project_snapshots", "create_snapshot", "update_snapshot_retention", + "delete_snapshot", "restore_snapshot", "list_snapshot_restores", "get_bucket_backup_schedule", + "create_bucket_backup_schedule", "update_bucket_backup_schedule", "delete_bucket_backup_schedule", + "list_backup_cycles", "list_cycle_backups", "create_bucket_backup", "list_cluster_backups", "get_backup", + "delete_backup", "restore_backup", "export_backup", "get_backup_export", } diff --git a/tests/unit/cli/test_configure_key.py b/tests/unit/cli/test_configure_key.py index 7d220bb..0876a88 100644 --- a/tests/unit/cli/test_configure_key.py +++ b/tests/unit/cli/test_configure_key.py @@ -22,3 +22,14 @@ def test_configure_ac7_cleans_pasted_key(raw, fixes): assert key == KEY assert fixed == fixes assert all(KEY not in f for f in fixed) + + +@pytest.mark.story("CAP-055", "AC4") +@pytest.mark.parametrize("raw,fixes", [ + (f"{KEY}\r\n", []), + (f"{KEY}\r\n", ["removed a byte-order mark"]), +]) +def test_windows_docs_ac4_api_key_stdin_crlf_bom(raw, fixes): + key, fixed = clean_api_key(raw) + assert key == KEY + assert fixed == fixes diff --git a/tests/unit/cli/test_help.py b/tests/unit/cli/test_help.py index 7ca2738..6f94184 100644 --- a/tests/unit/cli/test_help.py +++ b/tests/unit/cli/test_help.py @@ -53,7 +53,7 @@ def test_help_ac2_unknown_command_suggestion(run): def test_help_ac3_topics_listed(run): r = run("help", "topics") assert r.exit_code == 0 - assert len(TOPICS) == 10 + assert len(TOPICS) == 11 # backup: CAP-056 for name, summary in TOPICS.items(): assert name in r.output and summary in r.output diff --git a/tests/unit/cli/test_make_disk.py b/tests/unit/cli/test_make_disk.py new file mode 100644 index 0000000..8105982 --- /dev/null +++ b/tests/unit/cli/test_make_disk.py @@ -0,0 +1,26 @@ +"""CAP-060 AC3: without --disk-iops, a disk gets Capella's recommended IOPS for its size.""" +from __future__ import annotations + +import pytest + +from capellactl.cli.cluster_ops import make_disk + + +@pytest.mark.story("CAP-060", "AC3") +@pytest.mark.parametrize("provider,disk_type,size,iops", [ + ("aws", "gp3", 50, 3000), ("aws", "gp3", 99, 3000), ("aws", "gp3", 100, 4370), ("aws", "io2", 200, 5740), + ("aws", "gp3", 500, 9850), ("aws", "gp3", 999, 15330), ("aws", "io2", 1000, 16000), ("aws", "gp3", 4000, 16000), + ("azure", "Ultra", 64, 3000), ("azure", "Ultra", 256, 6000), ("azure", "Ultra", 2048, 16000), +]) +def test_disk_types_ac3_iops_default(provider, disk_type, size, iops): + assert make_disk(provider, disk_type, size) == {"type": disk_type, "storage": size, "iops": iops} + + +@pytest.mark.story("CAP-060", "AC3") +def test_disk_types_ac3_defaults_per_provider(): + assert make_disk("aws") == {"type": "gp3", "storage": 50, "iops": 3000} + assert make_disk("aws", size=300) == {"type": "gp3", "storage": 300, "iops": 7110} + assert make_disk("aws", "io2", 300, 20000) == {"type": "io2", "storage": 300, "iops": 20000} # given wins + assert make_disk("gcp", size=100) == {"type": "pd-ssd", "storage": 100} # IOPS follow size: none sent + assert make_disk("azure", "P15") == {"type": "P15"} # set by the type + assert make_disk("azure", "Ultra") == {"type": "Ultra", "storage": 64, "iops": 3000} # the smallest Ultra size diff --git a/tests/unit/config/test_crossref_disk.py b/tests/unit/config/test_crossref_disk.py new file mode 100644 index 0000000..0ee4f41 --- /dev/null +++ b/tests/unit/config/test_crossref_disk.py @@ -0,0 +1,56 @@ +"""CAP-060 AC4: disk sizes and IOPS outside what the type accepts are refused offline.""" +from __future__ import annotations + +import pytest + +from capellactl.config.crossref import config_warnings +from capellactl.config.loader import ConfigError, load_config +from tests.conftest import MINIMAL + +ENV = {"CAPELLA_ORG_ID": "o"} +AWS_DISK = "disk: { type: gp3, storage: 50, iops: 3000 }" + + +def _text(disk: str, provider: str = "aws") -> str: + region = {"aws": "us-east-1", "gcp": "us-east1", "azure": "eastus"}[provider] + return MINIMAL.replace(AWS_DISK, disk).replace("type: aws, region: us-east-1", f"type: {provider}, region: {region}") + + +def _load(tmp_path, text): + p = tmp_path / "c.yaml" + p.write_text(text, encoding="utf-8") + return load_config(p, env=ENV) + + +@pytest.mark.story("CAP-060", "AC4") +@pytest.mark.parametrize("provider,disk,message", [ + ("aws", "disk: { type: gp3, storage: 50, iops: 20000 }", "gp3 disks take 3000–16000 IOPS (got 20000)"), + ("aws", "disk: { type: io2, storage: 50, iops: 2000 }", "io2 disks take 3000–64000 IOPS (got 2000)"), + ("aws", "disk: { type: gp3, storage: 20000, iops: 16000 }", "gp3 disks are at most 16000 GB (got 20000)"), + ("azure", "disk: { type: Ultra, storage: 100, iops: 3000 }", "Ultra disks come in 64, 128, 256, 512, 1024, … 15360"), + ("azure", "disk: { type: Ultra, storage: 64, iops: 20000 }", "Ultra disks take 3000–19200 IOPS at 64 GB"), +]) +def test_disk_types_ac4_refused(tmp_path, provider, disk, message): + with pytest.raises(ConfigError) as ei: + _load(tmp_path, _text(disk, provider)) + assert message in str(ei.value) and "capellactl cluster disk-types" in str(ei.value) + + +@pytest.mark.story("CAP-060", "AC4") +@pytest.mark.parametrize("provider,disk", [ + ("aws", "disk: { type: io2, storage: 300, iops: 64000 }"), + ("aws", "disk: { type: gp3, storage: 150, iops: 3000 }"), # Capella allows 3000 for 100–199 GB + ("gcp", "disk: { type: pd-ssd, storage: 30000 }"), # no documented maximum + ("azure", "disk: { type: Ultra, storage: 512, iops: 80000 }"), + ("azure", "disk: { type: P30 }"), +]) +def test_disk_types_ac4_accepted(tmp_path, provider, disk): + cfg = _load(tmp_path, _text(disk, provider)) + assert not [w for w in config_warnings(cfg) if "IOPS" in w] + + +@pytest.mark.story("CAP-060", "AC4") +def test_disk_types_ac4_below_recommended_warns(tmp_path): + """Existing disks may have fewer IOPS than today's recommendation, so it is a warning, not an error.""" + cfg = _load(tmp_path, _text("disk: { type: gp3, storage: 500, iops: 3000 }")) + assert any("3000 IOPS is below Capella's recommended 9850 for 500 GB" in w for w in config_warnings(cfg)) diff --git a/tests/unit/docs/test_backup_guide.py b/tests/unit/docs/test_backup_guide.py new file mode 100644 index 0000000..11c50a8 --- /dev/null +++ b/tests/unit/docs/test_backup_guide.py @@ -0,0 +1,58 @@ +"""CAP-061 AC3–AC5: the backup and disk-type guides are live, easy to find and work in PowerShell.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from tests.unit.docs.test_windows_docs import _bash_only, _shell_lines + +ROOT = Path(__file__).resolve().parents[3] +BACKUP = (ROOT / "docs" / "backup-restore.md").read_text(encoding="utf-8") +CUSTOM = (ROOT / "docs" / "custom-clusters.md").read_text(encoding="utf-8") +DOCS = [*sorted((ROOT / "docs").glob("*.md")), ROOT / "README.md", *sorted((ROOT / "capellactl" / "help").glob("*.md"))] + + +def _section(text: str, heading: str) -> str: + start = text.index(heading) + nxt = text.find("\n## ", start + len(heading)) + return text[start:nxt if nxt > 0 else None] + + +@pytest.mark.story("CAP-061", "AC3") +def test_backup_guide_ac3_no_planned_notes(): + for doc in DOCS: + text = doc.read_text(encoding="utf-8") + assert not re.search(r"(?i)planned for (release )?1\.0\.0|disk type \(planned\)|CAP-061\)? turns them", + text), doc.name + disk = _section(CUSTOM, "## 10. Choose the disk type") + for text in (BACKUP, disk): + blocks = re.findall(r"```(\w*)\n(.*?)```", text, flags=re.S) + commands = [lang for lang, body in blocks if body.lstrip().startswith("capellactl ")] + assert commands and set(commands) == {"bash"}, "every command block is ```bash (checked by CI)" + + +@pytest.mark.story("CAP-061", "AC4") +def test_backup_guide_ac4_linked(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + guide = (ROOT / "docs" / "getting-started.md").read_text(encoding="utf-8") + runbook = (ROOT / "docs" / "runbook.md").read_text(encoding="utf-8") + assert "[Backup and restore](docs/backup-restore.md)" in readme + assert "[Backup and restore](backup-restore.md)" in _section(guide, "### Manage a provisioned cluster") + assert "custom-clusters.md#10-choose-the-disk-type" in guide + assert "docs/backup-restore.md" in (ROOT / "capellactl" / "help" / "backup.md").read_text(encoding="utf-8") + assert "capellactl cluster disk-types" in (ROOT / "capellactl" / "help" / "scaling.md").read_text(encoding="utf-8") + for heading in ("## Change a disk type", "## Set a cluster backup schedule", "## Set a bucket backup schedule", + "## Take a cluster backup before a change", "## Restore a cluster from a backup", + "## Restore a bucket from a backup", "## Download a bucket backup", "## Backups"): + assert heading in runbook, heading + assert "[backup-restore.md](backup-restore.md)" in runbook + + +@pytest.mark.story("CAP-061", "AC5") +def test_backup_guide_ac5_powershell_scan_covers_it(): + lines = _shell_lines() + ours = [line for line in lines if "backup schedule set prod-01" in line or "disk-types --provider aws" in line] + assert ours, "the CAP-055 PowerShell scan reads backup-restore.md and custom-clusters.md" + assert not [line for line in ours if _bash_only(line.split(" #")[0])] diff --git a/tests/unit/docs/test_runbook.py b/tests/unit/docs/test_runbook.py index f3facc8..a723305 100644 --- a/tests/unit/docs/test_runbook.py +++ b/tests/unit/docs/test_runbook.py @@ -14,6 +14,11 @@ "Add a collection": "collection create", "Add an app credential": "credential create", "Rotate a password": "--rotate-password", "Add an office IP": "cidr create", "Recover from deploymentFailed": "cluster delete", "Tear down": "destroy", + # CAP-060, CAP-056–CAP-059 (made live by CAP-061) + "Change a disk type": "cluster update", "Set a cluster backup schedule": "cluster backup schedule set", + "Set a bucket backup schedule": "bucket backup schedule set", "Take a cluster backup before a change": + "cluster backup create", "Restore a cluster from a backup": "cluster backup restore", + "Restore a bucket from a backup": "bucket backup restore", "Download a bucket backup": "bucket backup export", } diff --git a/tests/unit/docs/test_windows_docs.py b/tests/unit/docs/test_windows_docs.py new file mode 100644 index 0000000..d573f44 --- /dev/null +++ b/tests/unit/docs/test_windows_docs.py @@ -0,0 +1,152 @@ +"""CAP-055: the Windows install table and the PowerShell translations work in Windows PowerShell 5.1.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from tests.integration.harness import load_script + +steps_script = load_script("readme-windows-steps") +ROOT = Path(__file__).resolve().parents[3] +README = (ROOT / "README.md").read_text(encoding="utf-8") +GUIDE = (ROOT / "docs" / "getting-started.md").read_text(encoding="utf-8") +CI_HELP = (ROOT / "capellactl" / "help" / "ci.md").read_text(encoding="utf-8") +DOWNLOADS = (ROOT / "downloads" / "README.md").read_text(encoding="utf-8") + +# Syntax that only bash/zsh understand, or that means something else in Windows PowerShell 5.1. +BASH_ONLY = { + "&&": r"&&", "||": r"\|\|", "export": r"(^|;\s*)export ", "source": r"(^|;\s*)source ", ".venv/bin": r"\.venv/bin", + "trailing \\": r"\\\s*$", "pbpaste": r"\bpbpaste\b", "python3": r"\bpython3\b", "bare curl": r"(? str: + start, end = f"", f"" + assert start in text and end in text, f"missing {start} … {end}" + return text[text.index(start) + len(start):text.index(end)] + + +def _rows(table: str) -> list[list[str]]: + rows = [] + for line in table.strip().splitlines()[2:]: # skip the header and the |---| line + cells = re.split(r"(? list[str]: + """The code spans in a table cell: ``a`b`` and `a`.""" + return [a or b for a, b in re.findall(r"``\s?(.+?)\s?``|`([^`]+)`", cell)] + + +def _bash_only(command: str) -> list[str]: + return [name for name, pat in BASH_ONLY.items() if re.search(pat, command)] + + +def _powershell_commands() -> list[str]: + cmds = [c for _, commands in steps_script.steps(README) for c in commands] + cmds += [c for row in _rows(_between(GUIDE, "powershell-translation")) for c in _code(row[1])] + cmds += [line for block in re.findall(r"```powershell\n(.*?)```", DOWNLOADS, flags=re.S) + for line in block.splitlines() if line and not line.startswith("#")] + cmds += [line.split("run:", 1)[1] for line in CI_HELP.splitlines() if "LASTEXITCODE" in line] + return cmds + + +@pytest.mark.story("CAP-055", "AC1") +def test_windows_docs_ac1_readme_install_table(): + install = README[README.index("## Install"):README.index("## Quick start")] + assert "### On Windows (PowerShell)" in install and "" in install + table = steps_script.steps(README) + commands = [c for _, cmds in table for c in cmds] + for expected in ("winget install --id Python.Python.3.12 -e", "py -3.12 --version", "py -3.12 -m venv .venv", + r".venv\Scripts\python -m pip install -e .", "Set-ExecutionPolicy -Scope CurrentUser RemoteSigned", + r".venv\Scripts\Activate.ps1", "capellactl --version", r"mkdir ~\capella-env"): + assert expected in commands, expected + assert [label.split(".")[0] for label, _ in table] == [str(n) for n in range(1, len(table) + 1)] + for row in _rows(_between(README, "windows-install")): + assert len(row) == 3 and row[2], f"step {row[0]} says nothing about what to expect" + help_text = " ".join(row[2] for row in _rows(_between(README, "windows-install"))) + for failure in ("requires a different Python", "Microsoft Store", "&&", "company blocks", "is not recognized"): + assert failure in help_text, failure + + +@pytest.mark.story("CAP-055", "AC2") +@pytest.mark.parametrize("bad,name", [ + ("git clone https://x/y.git && cd y", "&&"), ("export PW='x'", "export"), ("source .venv/bin/activate", "source"), + (".venv/bin/pip install -e .", ".venv/bin"), ("capellactl cluster create a \\", "trailing \\"), + ("pbpaste", "pbpaste"), ("python3 -m venv .venv", "python3"), ("curl -s https://checkip.amazonaws.com", "bare curl"), + ("capellactl plan || [ $? -eq 2 ]", "||"), +]) +def test_windows_docs_ac2_bash_only_syntax_is_caught(bad, name): + assert name in _bash_only(bad) + + +@pytest.mark.story("CAP-055", "AC2") +def test_windows_docs_ac2_no_bash_only_syntax(): + commands = _powershell_commands() + assert len(commands) >= 20 + bad = {c: _bash_only(c) for c in commands if _bash_only(c)} + assert not bad, f"bash-only syntax in Windows commands: {bad}" + + +@pytest.mark.story("CAP-055", "AC6") +def test_windows_docs_ac6_exe_on_user_path(): + downloads = load_script("update-downloads") + generated = downloads.downloads_readme(None) + assert generated == DOWNLOADS # what update-downloads.py --check compares + block = generated[generated.index("To type `capellactl` from any folder"):] + assert r"$env:LOCALAPPDATA\Programs\capellactl\capellactl.exe" in block + assert "SetEnvironmentVariable('Path', \"$userPath;" in block and "'User')" in block + assert "Machine" not in block and "administrator rights" in block + + +@pytest.mark.story("CAP-055", "AC3") +def test_windows_docs_ac3_steps_extracted_from_readme(): + table = steps_script.steps(README) + cmds = steps_script.ci_commands(table, r"D:\a\capellactl") + readme_cmds = [c for _, commands in table[1:] for c in commands] # all but step 1 (winget) + assert len(cmds) == len(readme_cmds) and not any(c.startswith("winget") for c in cmds) + assert cmds[1] == r'git clone "D:\a\capellactl" capellactl' and cmds[2] == "cd capellactl" + assert "Set-ExecutionPolicy -Scope Process RemoteSigned -Force" in cmds + assert [c for c in cmds if "git clone" not in c and "Set-ExecutionPolicy" not in c] == \ + [c for c in readme_cmds if "git clone" not in c and "Set-ExecutionPolicy" not in c] + with_py = steps_script.ci_commands(table, "src", python=r"C:\py\python.exe") + assert r'& "C:\py\python.exe" -m venv .venv' in with_py and not any("py -3.12" in c for c in with_py) + + script = steps_script.ci_script(cmds, r"C:\work") + assert script.splitlines()[0].startswith("Set-ExecutionPolicy -Scope Process Restricted -Force") + assert script.count("README step failed") == len(cmds) + + +@pytest.mark.story("CAP-055", "AC3") +def test_windows_docs_ac3_ci_runs_the_readme_in_windows_powershell(): + ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + job = ci[ci.index(" windows-install:"):ci.index(" image-smoke:")] + assert "runs-on: windows-latest" in job and "shell: powershell" in job + assert "scripts/readme-windows-steps.py" in job and "powershell -NoProfile" in job + + +def _shell_lines() -> list[str]: + quick_start = README[README.index("## Quick start"):README.index("## Commands")] + texts = [quick_start, GUIDE[GUIDE.index("## Step by step"):]] + [ + (ROOT / "docs" / f).read_text(encoding="utf-8") for f in ("runbook.md", "custom-clusters.md", + "backup-restore.md")] + lines = [line for t in texts for b in re.findall(r"```(?:bash|sh|shell)?\n(.*?)```", t, flags=re.S) + for line in b.splitlines()] + return lines + [line.split("run:", 1)[1] for line in CI_HELP.splitlines() if "run:" in line] + + +@pytest.mark.story("CAP-055", "AC5") +def test_windows_docs_ac5_translation_table_complete(): + left = " ".join(row[0] for row in _rows(_between(GUIDE, "powershell-translation"))) + # What the table's left column must show for each construct, once the docs use it. + covers = {"&&": "&&", "||": "||", "export": "export ", "trailing \\": "ending in `\\`", "pbpaste": "pbpaste", + "bare curl": "curl -s", "$?": "$?"} + used = {name for line in _shell_lines() for name in _bash_only(line.split(" #")[0]) if name != "python3"} + assert {"export", "trailing \\", "bare curl"} <= used # the scan sees the docs' real commands + assert used <= covers.keys(), f"no PowerShell translation for {used - covers.keys()}" + missing = [name for name in used if covers[name] not in left] + assert not missing, f"getting-started 'Using the commands in PowerShell' lacks a row for {missing}" diff --git a/tests/unit/fakes/test_fake_parity.py b/tests/unit/fakes/test_fake_parity.py index a150432..ccc3fb4 100644 --- a/tests/unit/fakes/test_fake_parity.py +++ b/tests/unit/fakes/test_fake_parity.py @@ -29,6 +29,8 @@ ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")]} ACCESS = [{"privileges": ["data_reader"], "resources": {"buckets": [{"name": "orders"}]}}] +BUCKET_SCHEDULE = {"type": "weekly", "weeklySchedule": {"dayOfWeek": "sunday", "startAt": 2, "incrementalEvery": 24, + "retentionTime": "30days", "costOptimizedRetention": False}} def problems(value: Any, schema: dict[str, Any] | None, where: str) -> list[str]: @@ -86,15 +88,40 @@ def world(fake): "_password": "x"} fake.versions["user:u-1"] = 1 fake.schedules[cid] = {"activationStatus": "active", **SCHEDULE} + fake.snapshot_schedules[cid] = {"interval": 24, "retention": 720, "startTime": "2026-10-30T02:00:00Z", + "copyToRegions": ["us-west-2"]} + snap = fake.seed_snapshot(cid) + backup = fake.seed_bucket_backup(cid, "orders", cycle="cycle-1") + fake.bucket_backup_schedules[(cid, bucket_id("orders"))] = {"weeklySchedule": BUCKET_SCHEDULE["weeklySchedule"]} + # A finished export of the cycle (stored as failed so the POST in this test is not refused as a second export) + fake.exports["export-1"] = {"id": "export-1", "tenantId": "org-1", "projectId": pid, "clusterId": cid, + "cycleId": "cycle-1", "bucketName": "orders", "status": "failed", + "createdAt": "2026-10-29T04:00:00Z", "_backup": backup, "_done_at": -1.0} return {"{organizationId}": "org-1", "{projectId}": pid, "{clusterId}": cid, "{bucketId}": bucket_id("orders"), "{scopeName}": "inventory", "{collectionName}": "items", "{allowedCidrId}": "cidr-1", "{userId}": "u-1", - "free": fid, "free-bucket": bucket_id("free-b")} + "free": fid, "free-bucket": bucket_id("free-b"), "snapshot": snap, "{backupId}": backup, + "{cycleId}": "cycle-1", "{exportId}": "export-1"} -def _body(method: str, template: str) -> dict[str, Any] | None: +def _body(method: str, template: str, ids: dict[str, str]) -> dict[str, Any] | None: tail = template.rsplit("/", 1)[-1] if method not in ("POST", "PUT") or tail == "activationState": return None + if tail == "cloudsnapshotbackupschedule": + return {"interval": 12, "retention": 168, "startTime": "2026-10-30T02:00:00Z", "copyToRegions": []} + if tail == "cloudsnapshotbackups": + return {"retention": 168, "regionsToCopy": ["us-west-2"]} + if tail == "{backupId}" and "cloudsnapshotbackups" in template: + return {"retention": 2160} + if tail == "restore" and "cloudsnapshotbackups" in template: + return {} + if tail == "restore": + return {"targetClusterID": ids["{clusterId}"], "sourceClusterID": ids["{clusterId}"], + "backupID": ids["{backupId}"], "services": ["data"]} + if tail == "schedules": + return BUCKET_SCHEDULE + if tail in ("backups", "export"): + return {} if tail == "projects": return {"name": "made", "description": "d"} if tail == "{projectId}": @@ -135,6 +162,7 @@ def _body(method: str, template: str) -> dict[str, Any] | None: def _path(template: str, ids: dict[str, str]) -> str: + template = template.replace("/cloudsnapshotbackups/{backupId}", f"/cloudsnapshotbackups/{ids['snapshot']}") if "/clusters/freeTier/" in template: template = template.replace("/clusters/freeTier/{clusterId}", f"/clusters/freeTier/{ids['free']}") if "/buckets/freeTier/" in template: @@ -165,9 +193,11 @@ def test_fake_ac6_response_shape_parity(fake, world): and "/buckets/" not in template else None if method == "POST" and template.endswith("/onOffSchedule"): fake.schedules.pop(world["{clusterId}"]) + if method == "POST" and template.endswith("/backup/schedules"): + fake.bucket_backup_schedules.clear() if method == "DELETE" and template.endswith("/projects/{projectId}"): fake.clusters.clear() # Capella deletes a project only once its clusters are gone - r = requests.request(method, fake.base_url + _path(template, world), json=_body(method, template), + r = requests.request(method, fake.base_url + _path(template, world), json=_body(method, template, world), headers={"Authorization": "Bearer k-123456"}, timeout=5) if free_tier is not None: # put the seeded free-tier cluster back for the calls that follow fake.clusters.pop(r.json().get("id", ""), None)