Skip to content

Skip signing a download URL when no artifacts can exist yet - #10252

Open
Ralthos wants to merge 4 commits into
velero-io:mainfrom
Ralthos:downloadrequest-phase-guard
Open

Skip signing a download URL when no artifacts can exist yet#10252
Ralthos wants to merge 4 commits into
velero-io:mainfrom
Ralthos:downloadrequest-phase-guard

Conversation

@Ralthos

@Ralthos Ralthos commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thank you for contributing to Velero!

Please add a summary of your change

Follows #10232, where the answer was "let's do free first". This is the free half: the controller already has the backup, and the restore for restore targets, in hand before it signs, so checking the phase adds no call to the object store.

A DownloadRequest whose target never ran now stops at New with no URL, so nothing hands a caller a link that 404s.

Does your change fix a particular issue?

Refs #10232.

Please indicate you've done the following:

Three things worth flagging. Two of them are decisions, not mechanics.

The guard is narrower than the CLI's allowlist, on purpose. pkg/cmd/cli/backup/logs.go permits five phases and refuses the rest, and reusing that list in the controller was my first instinct. It would have been wrong. velero backup download has no client side phase check at all, so a controller side guard built on the log allowlist would have changed what backup download can fetch, and the phases in which a tarball is available are not the phases in which a log is.

So this refuses only the pre-execution phases, where nothing exists for any target kind:

refused
Backup New, Queued, ReadyToStart, FailedValidation
Restore New, FailedValidation

InProgress onwards may hold a partial log or other artifacts, and Deleting may still hold all of them, so those sign exactly as they do today. The only requests that change behaviour are ones that could never have succeeded.

An empty phase is left alone. A backup that has not been reconciled yet has phase: "", and that state is transient, so blocking it would trade a 404 for a spurious refusal during a race.

Where the check lives. In the controller, next to where the backup and restore are already loaded. GetDownloadURL in pkg/persistence has the target kind but not the object, so putting it there would mean fetching the backup a second time and would stop being free.

On testing: backupPhaseHasNoArtifacts and restorePhaseHasNoArtifacts are covered exhaustively, one case per phase the API defines, plus a guard test that fails if a phase is added later without updating either table.

I could not run the TestAPIs envtest suite locally, since it needs control plane binaries I do not have on this machine. It fails the same way on a clean checkout of main here, so I do not believe this change affects it, but CI is the real check.

  • Added a changelog
  • go test ./pkg/controller/ -run 'TestBackupPhaseHasNoArtifacts|TestRestorePhaseHasNoArtifacts|TestPhaseTablesCoverAllPhases' passes, 26 subtests
  • gofmt -s clean
  • Signed off the commit

@Ralthos
Ralthos requested a review from a team as a code owner August 12, 2026 16:49
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

👷 Deploy request for velero pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit b79df65

Comment thread pkg/controller/download_request_controller.go
Comment thread pkg/controller/download_request_phase_test.go Outdated
@Ralthos

Ralthos commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Both good catches. Thank you.

The coverage test. You are right, and it was worse than you describe: the assertion compares a slice I wrote by hand against its own length, so it could never fail for any reason. It has been replaced with one that reads the status.phase enum out of the generated CRDs, through the exported v1crds.CRDs that pkg/install already uses. The enum comes from the same kubebuilder markers as the Go constants, so a phase added to the API now fails here until someone classifies it. The old version could not do that whatever the phase count was.

The abandoned request. The symptom is real and I had understated it. The CLI polls until its own timeout and then reports:

download request download url timeout, check velero server logs for errors.
backup storage location may not be available

For this case that message points at the wrong thing entirely. The BSL is fine; the backup never ran. So the change swaps a fast wrong answer for a slow wrong answer, which is not obviously an improvement for anyone driving it from the CLI.

Setting a terminal phase is the right shape. The obstacle is that there is no phase to set:

// +kubebuilder:validation:Enum=New;Processed
type DownloadRequestPhase string

Adding Failed changes the CRD schema, and on #10232 @kaovilai scoped this work to a non-breaking change of the API. An additive enum value may well qualify, but that is a maintainer's call.

@kaovilai, three options as I see them:

  1. Add DownloadRequestPhaseFailed, with a message field or an event carrying the reason. Best behaviour, and it needs a decision on whether an added enum value counts as non-breaking.
  2. Set Status.Expiration to now when the guard fires, so the controller deletes the request on the next pass and the CLI's Get fails immediately. No schema change, though the caller then sees a not-found error carrying no reason.
  3. Hold the guard until 1 is settled, and land only the documentation in Document what the DownloadRequest Processed phase means #10245.

I did not want to pick between those on your behalf. Happy to write whichever you prefer.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/controller/download_request_controller.go 75.86% 6 Missing and 1 partial ⚠️
pkg/cmd/util/downloadrequest/downloadrequest.go 0.00% 3 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@kaovilai

Copy link
Copy Markdown
Member

added enum value counts as non-breaking.

Adding enum imo is non breaking. removing enum is breaking.

WTAL at the approaches later and give more thoughts later.

@kaovilai
kaovilai self-requested a review August 13, 2026 02:40
@Ralthos
Ralthos force-pushed the downloadrequest-phase-guard branch from 02e84fc to 9c03d00 Compare August 15, 2026 17:03
@Ralthos

Ralthos commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@kaovilai thanks, that settles it. I have taken option 1.

DownloadRequestPhase gains Failed, and DownloadRequestStatus gains Message. Both are additive: no existing value changes meaning, and a client that only knows New and Processed sees a request that never becomes Processed, which is what it saw before this change anyway.

The reason option 2 is not enough is worth recording. Setting Expiration to now would make the CLI's Get fail immediately, but the error it surfaces would be a not-found on a request the caller just created, which explains less than the current timeout does. The message is the part that matters here:

backup "a-backup" is in phase "FailedValidation" and has not written any artifacts

@Ayush4958's point about the CLI is handled in the same commit rather than deferred. getDownloadURL in pkg/cmd/util/downloadrequest polls on Status.DownloadURL != "" and ignores the phase, so the guard alone traded a fast wrong answer for a slow one. It now returns as soon as it sees Failed, carrying the message. ErrDownloadRequestDownloadURLTimeout still exists and still means what it says, but it no longer fires for a backup that never ran.

Checking the phase in the CLI before creating the request would work too, and I did consider it. I did not do that because it puts the rule in one caller: velero backup download, restore describe and anything else driving the API would each need their own copy, and a UI talking to the API directly would get none of it. The controller is the one place all of them go through.

Two behaviour tests cover this, asserting on the phase and the exact message a client would read. I checked they fail when the message assignment is removed, since the last version of this test could not fail at all.

CRD regenerated, gofmt clean, rebased on main.

@Ralthos
Ralthos force-pushed the downloadrequest-phase-guard branch from f575a7f to 930dce3 Compare August 15, 2026 17:28
ywk253100
ywk253100 previously approved these changes Aug 17, 2026
kaovilai
kaovilai previously approved these changes Aug 18, 2026

@kaovilai kaovilai left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is cool!

@kaovilai

Copy link
Copy Markdown
Member

rerunning tests.. github was kinda down today

@kaovilai
kaovilai requested a review from Ayush4958 August 18, 2026 04:09
Ralthos and others added 4 commits August 18, 2026 11:27
Reported in velero-io#10232: a DownloadRequest for a backup that never ran still
reaches Processed with a signed URL, and fetching it returns 404.

The controller already has the backup, and the restore for restore
targets, in hand before it signs, so checking the phase costs no extra
call to the object store.

The check is deliberately narrow. It refuses only the pre-execution
phases, where nothing has been written for any target kind: New, Queued,
ReadyToStart and FailedValidation for backups, New and FailedValidation
for restores. InProgress onwards may hold a partial log or other
artifacts, and Deleting may still hold all of them, so those keep the
behaviour callers have today.

That matters because velero backup download has no client side phase
check of its own, unlike backup logs and restore logs. Reusing the
allowlist from pkg/cmd/cli/backup/logs.go would have changed what
backup download can fetch; this does not.

A backup with an empty phase is left alone as well, since that state is
transient and the caller can retry.

Refs velero-io#10232

Signed-off-by: saral <ilovegojo2580@gmail.com>
The previous test built a slice of phases by hand and asserted its own
length, so it passed no matter what the API did. Adding a fourteenth
backup phase would not have failed it.

This reads the status.phase enum out of the generated CRDs, via the
exported v1crds.CRDs that pkg/install already uses. The enum comes from
the same kubebuilder markers as the Go constants, so a phase added to
the API fails here until it is classified.

Verified by removing Deleting from the expectations, which now fails with
'BackupPhase "Deleting" is served by the CRD but not classified'.

Signed-off-by: saral <ilovegojo2580@gmail.com>
golangci-lint runs misspell, which flags behaviour as a misspelling of
behavior. Comments only, no functional change.

Signed-off-by: saral <ilovegojo2580@gmail.com>
The guard added in the previous commit left the request at New with no URL, so
the CLI polled until its own timeout and then reported that the backup storage
location may be unavailable. The BSL is fine; the backup never ran.

DownloadRequestPhase gains Failed and DownloadRequestStatus gains Message. The
controller sets both where it refuses, and the CLI stops as soon as it sees the
phase and surfaces the message instead of its generic timeout error.

Adding an enum value is additive, per the direction on the PR discussion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: saral <ilovegojo2580@gmail.com>

@Ayush4958 Ayush4958 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CLI polling now aborts correctly on the Failed phase
Hardcoded test array replaced with dynamic CRD OpenAPI schema parsing

LGTM

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants