Skip to content

design-proposal: quota and accounting for backup storage - #50

Draft
Timofei Larkin (lllamnyp) wants to merge 3 commits into
mainfrom
design/backup-storage-quota
Draft

design-proposal: quota and accounting for backup storage#50
Timofei Larkin (lllamnyp) wants to merge 3 commits into
mainfrom
design/backup-storage-quota

Conversation

@lllamnyp

Copy link
Copy Markdown
Member

What this PR does

Adds a design proposal under design-proposals/backup-storage-quota/ for
limiting and reporting how much backup storage a tenant consumes.

Cozystack can back up managed applications, but nothing caps the resulting
storage and nothing reports it. A tenant with a cron Plan and a large database
fills the operator's object storage without ever meeting a limit or seeing a
number.

The shape of the problem

Enforcement is the easy half. The accounting is not, and that is where the
proposal spends its length.

Gate the request, account the artifact. A CRD gets one quota dimension for
free — count/backups.backups.cozystack.io — and using it is a trap: it fires
on Backup CREATE, which the strategy controller issues when a run finishes,
not the tenant. A tenant at their limit would watch jobs run to completion and
then fail to record the result, orphaning artifacts in object storage with no CR
referencing them. Enforcement therefore attaches to BackupJob CREATE;
accounting reads Backup.

Summing sizeBytes over live Backup objects is wrong in both directions.
backup_controller.go's cleanup switch is explicit, per driver: the Altinity
branch notes deletion "does NOT purge the upstream clickhouse-backup archive in
object storage"; MariaDB "does not own the archive, so it does not delete it on
CR removal"; FoundationDB says the same. Those branches exist specifically to
avoid falling through to the Velero default — which does clean up, and is the
one driver where deleting a Backup genuinely releases bytes.

So on every driver but one, kubectl delete backup --all resets the counter
while the bytes remain. That is a one-command bypass, not a documentation
footnote. Inversely, driver-side retention prunes archives without deleting the
CR, so the ledger overcounts.

The proposal promotes per-driver artifact ownership — knowledge the code already
has, as prose in a switch statement — to a declared strategy capability, and
splits usage into attributed (released when the CR goes) and retained
(deleted CR, driver still holds the bytes). used = attributed + retained, so
deleting CRs moves bytes between buckets and frees nothing.

Where the numbers live

corev1.ResourceQuota, via two extended dimensions
(backups.cozystack.io/size, backups.cozystack.io/count) — not a new CRD.

An earlier draft used a dedicated CRD on the assumption that
ResourceQuota.status could not be safely co-written by a third controller.
Reading upstream at v1.35 showed the assumption was wrong, and the proposal
quotes the relevant code:

  • syncResourceQuota seeds used from what is already published, overwrites
    only the keys its own evaluators computed, and masks to the key set of
    spec.hard. A dimension in spec.hard with no evaluator is carried through
    untouched; CalculateUsage skips unrecognised names without erroring.
  • The admission plugin adds a delta to the existing Status.Used rather than
    recomputing it, so foreign keys survive there too.
  • hasUsageStats only inspects resources the incoming object's evaluator
    matches, so a lagging backup figure can never 403 a Pod.

Two consequences the design leans on: kube-controller-manager mirrors
spec.Hard into status.Hard, so only status.used needs writing; and the
dimensions are inert to kube-apiserver, which makes visibility and enforcement
independently switchable — an operator can turn on measurement, size a real
limit against observed data, and only then enable the webhook.

This is emergent behaviour rather than a documented API guarantee, so the
Testing section calls for an integration test against a real
kube-controller-manager that asserts a synthetic dimension survives a resync —
gating the release, not just the merge.

Relationship to #48

#48 moves tenant quota
accounting off status.used and onto declared reservations; this proposal
adds a consumer of status.used. They are complementary, because the resources
differ in the property that decides the mechanism: a VM's memory is declared
before it exists, while a backup's size is only known after. If both are
accepted the rule becomes "reservation-based where size is declared, measured
where it is discovered" — which is coherent, but ought to be decided explicitly
rather than emerge from two proposals landing separately. Reviewers of either
should say whether they accept it.

Open questions I would most like input on

  • Whether the attributed/retained split is the right model, or heavier than
    the problem deserves.
  • Whether the strategy capability declarations belong on the Strategy CRs, on
    BackupClass, or in a controller-side registry.
  • Whether count should use the native object-count dimension after all,
    accepting the Backup-CREATE failure mode to avoid a duplicate mechanism.

Verified against cozystack/cozystack at 04b742670 and Kubernetes v1.35.2.

Cozystack can back up managed applications but cannot limit or report how
much backup storage a tenant consumes.

The proposal separates the two halves of the problem. Enforcement attaches
to BackupJob CREATE, which the tenant initiates; accounting reads Backup,
which the strategy controller writes. Gating Backup CREATE instead — the
one dimension a CRD gets for free — would reject a run's result after the
artifact already exists, orphaning it in object storage.

The accounting half is the hard one. Summing sizeBytes over live Backup
objects is wrong in both directions: for every driver except Velero,
deleting a Backup leaves the archive in place, so a tenant can reset the
counter with one command; and driver-side retention prunes archives
without deleting the CR, so the ledger overcounts. The proposal promotes
per-driver artifact ownership to a declared strategy capability and
splits usage into attributed and retained bytes accordingly.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
The first draft used a dedicated BackupQuota CRD on the assumption that
ResourceQuota.status could not be safely co-written by a third controller.
Reading upstream at v1.35 shows the assumption was wrong.

syncResourceQuota seeds status.used from what is already published,
overwrites only the keys its own evaluators computed, and masks the result
to the key set of spec.hard — so a dimension present in spec.hard with no
evaluator is carried through untouched, and CalculateUsage skips
unrecognised names without erroring. The admission plugin adds a delta to
the existing status.used rather than recomputing it, so foreign keys
survive there too. hasUsageStats only inspects resources the incoming
object's own evaluator matches, so a lagging backup figure cannot 403 a
Pod.

Two consequences the design now leans on: kube-controller-manager mirrors
spec.hard into status.hard, so only status.used needs writing; and the
dimensions are inert to kube-apiserver, which makes visibility and
enforcement independently switchable and the phased rollout real.

Records the residual risk — this is emergent behaviour, not a documented
guarantee — as an integration test that must fail loudly on a Kubernetes
bump.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
#48 moves tenant quota accounting off ResourceQuota.status.used and onto
declared reservations; this proposal adds a consumer of status.used. They
are complementary — a VM's memory is declared before it exists, a backup's
size is only known after — but the resulting split between declared and
measured accounting should be an explicit decision rather than the
accidental outcome of two proposals landing separately.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8931253b-9f72-403a-87e7-018f0f170fa6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant