Move Edge App deploy orchestration to the server - #312
Conversation
The CLI drove deploys by hand: reading the manifest, diffing and writing
settings one at a time, creating a version row, uploading files against that
revision, then publishing it. A failure part-way through left an orphan
revision behind, and the ordering meant every file had to be uploaded before
the client could tell whether a deploy was needed at all.
Deploys now go through two endpoints:
POST /v3/edge-apps/{id}/deploy/preview what would change, and which files
the platform is still missing
POST /v3/edge-apps/{id}/deploy settings, version, files and publish
in one transaction
Files upload as staged assets through the existing /v4/assets route, carrying
an app_id but no app_revision, and the deploy that follows claims them. Nothing
is created until the content is already there, so a failed deploy leaves no
version behind.
The preview also lets the CLI stop early: an app that is already up to date
never posts a deploy at all.
Drops the client-side manifest reconciliation, version create/publish calls,
revision-scoped asset queries and the per-file upload bookkeeping that the
server now owns.
Requires the server endpoints from Screenly/Screenly#2558.
sergey-borovkov
left a comment
There was a problem hiding this comment.
Reviewed the diff and ran cargo check --all-targets + cargo test on the branch (clean, 189 pass). The direction is good — the client shrinks a lot — but a few of the removed client-side guards look like they need either a server-side equivalent or to stay.
Must fix
1. Staged-asset polling is app-scoped, so one failed asset blocks every future deploy — src/commands/edge_app/app.rs:280
get_staged_processing_statuses now queries app_id=eq.{id}&app_revision=is.null&status=neq.finished. Staged assets keep app_revision = null until a deploy claims them, and nothing deletes them on failure.
If a file fails processing (status = "error"), wait_for_assets_processing returns AssetProcessingError, the deploy aborts, and the errored row stays staged forever. Every subsequent screenly edge-app deploy hits the same row and fails — even after the user deletes the offending file — with no CLI command to clear it. The same mechanism makes a stuck non-error/non-finished asset burn the full 1000 s wait, and lets two concurrent deploys of the same app observe each other's staging. The old revision-scoped query gave each deploy a fresh scope. Needs either a filter that identifies this deploy's assets, or cleanup of staged assets on failure.
2. The printed remediation command isn't valid CLI syntax — src/commands/edge_app/app.rs:205
The message says Re-run with --delete-missing-settings to remove., but cli.rs:393 declares delete_missing_settings: Option<bool>, so the flag requires a value:
$ screenly edge-app deploy --delete-missing-settings
error: a value is required for '--delete-missing-settings <DELETE_MISSING_SETTINGS>' but none was supplied
Should read --delete-missing-settings true.
3. #[serde(default)] on deploy_needed turns a response-shape mismatch into a silent no-op success — src/api/edge_app/deploy.rs:81
DeployPreview defaults every field and has no deny_unknown_fields. If /deploy/preview returns 200 with a body that doesn't carry deploy_needed (renamed, wrapped in an envelope, error payload served with 200), it deserializes to false, deploy() returns early at app.rs:208, the CLI prints "Edge App is already up to date." and exits 0. The user's changed files are never deployed and nothing signals failure. The field that gates the entire operation should be required.
4. The index.html guard was dropped with no client-side replacement — src/commands/edge_app/app.rs:188
ensure_edge_app_has_all_necessary_files and CommandError::MissingRequiredFile are gone. A file-entrypoint app whose directory has no index.html now sends a file_tree without it and proceeds to a full deploy instead of failing immediately. Unless the new endpoint rejects that, the user gets a published revision that won't load on the player. Can you confirm the server enforces it? Otherwise the local check is worth keeping — it fails in a second instead of after a full upload.
5. Removing VirtualIndexHtml can leave a remote-entrypoint deploy with an empty file tree — src/commands/edge_app/app.rs:187
That guard was added in 8dd2f8e with the comment "the backend rejects publishing a version with no asset signatures", precisely because screenly edge-app create --entrypoint <URL> writes no index.html — only screenly_inject.js. A remote-entrypoint app whose directory contains just screenly.yml (inject script deleted or ignored) now produces file_tree = {} and a deploy with zero assets, which is the case the guard existed to prevent. Same question: has the publish-side check been relaxed, or does this regress?
6. deploy no longer calls update_entrypoint_value, so entrypoint URIs stop propagating — src/commands/edge_app/app.rs:224
The only remaining caller is instance.rs:73 (edge-app instance update). For EntrypointType::RemoteLocal the value comes from instance.yml's entrypoint_uri, which isn't part of DeployPayload — the server can't derive it from the manifest. So: user edits entrypoint_uri, runs screenly edge-app deploy, the screenly_entrypoint setting is never updated and the player keeps loading the old URL. If this is intentional, it needs a doc/changelog note that instance update is now required.
Non-blocking
7. created also defaults to false — src/api/edge_app/deploy.rs:92. revision is required in the same struct but created isn't. If /deploy omits it, a successful new-revision deploy takes the second arm at cli.rs:928 and prints "Settings updated. No new revision needed. Revision: N." — the opposite of what happened. The user-facing message depends entirely on this field.
8. 60 s timeout on an endpoint that now does strictly more work — src/api/edge_app/deploy.rs:153. DEPLOY_TIMEOUT_SECONDS = 60 now covers settings, version, files and publish in one transaction; the old client flow had no single 60 s ceiling over that sequence (uploads used 3600 s). A large app whose transaction runs long returns CommandError::Request while the server may still commit — an outcome the CLI can't reconcile.
9. deploy_preview discards a 409's outstanding payload — src/api/edge_app/deploy.rs:106. post_deploy deliberately hands 409 bodies back to the caller, but deploy_preview rejects anything non-200 with a bare WrongResponseStatus(409). If preview ever answers 409 the user sees a naked status code instead of the "not uploaded: …; still processing: …" message that deploy()'s Conflict arm builds from the identical body.
Staged assets keep app_revision null until a deploy claims them, and nothing removes them when processing fails. Polling by app id therefore let one bad file block every later deploy of that app until the platform swept it a week later, made concurrent deploys observe each other, and let a foreign stuck asset burn the full wait. Poll by the asset ids this run uploaded instead. upload_single_asset already asked for the created row via Prefer: return=representation and discarded it; it now returns the id. Also from review: - deploy stopped calling update_entrypoint_value, so a remote-local entrypoint URI never reached the setting: it lives in instance.yml and the server cannot derive it from the manifest. Restored on both the deploy and the early-return path, since a changed URI alone leaves deploy_needed false. - --delete-missing-settings demanded a value. Accept it bare while keeping the existing value form working. - deploy_needed and created no longer default, so a response that omits them fails instead of reading as "nothing to do" or inverting the message.
|
Thanks — this caught two things I'd have shipped. Fixed five, pushing back on three, and one needs your knowledge of the entrypoint feature. Fixed1. Staged-asset polling. You're right that the app-wide scope is the bug, and it's worse than a block: each retry adds a staged row while the errored one stays, so the pile grows for the full 7 days. Fixed by scoping the poll to the ids this run uploaded, rather than by deleting anything. That covers all three sub-points: a stale errored row has a different id and stays invisible until the sweep collects it on schedule, a concurrent deploy's rows are invisible, and a foreign stuck asset can't burn the 1000 s wait. A file this run uploaded that fails still aborts immediately with its error text, which is the behaviour the check exists for. 2. Flag syntax. Correct — 3 / 7. 6. Entrypoint URI. Confirmed regression — Pushing back4. 8. Timeout. The 3600 s is on file uploads and hasn't moved — 9. Needs your call5. Empty file tree. The constraint An empty What I can't answer: does a zero-asset version behave on the player for a remote entrypoint? You wrote 8dd2f8e. If it's fine, this closes. If it isn't, I'd add the guard to TestsThree deploy tests reworked — with id scoping, a deploy that uploads nothing no longer polls at all, so the 409 test lost its status mock and the processing-failure test needed a real upload to have something to poll. Added 190 pass, clippy and nightly rustfmt clean. |
sergey-borovkov
left a comment
There was a problem hiding this comment.
Re-reviewed cd9c0487. Verified on the branch: cargo build, cargo clippy --all-targets clean, 190 tests pass. Approving — nothing blocking left. One non-blocking item inline, plus two nits below.
Confirmed fixed
1. Asset polling. Scoping to the ids this run uploaded is the right shape — better than deleting rows, since it needs no cleanup path and is correct under concurrency for free. The empty-list early return matters too: a deploy that uploads nothing now skips the poll entirely instead of issuing a query that would match everything.
2. Flag. Fixing the flag rather than the message was the better call. Verified against the built binary — bare, true, false, and --delete-missing-settings --path X all parse, and --path isn't swallowed as the value.
3 / 7. Defaults. Both gone.
6. Entrypoint URI. Restored on both paths, and the early-return call is the one that matters. Agree on leaving it unrestricted — the branch costs more than the redundant PATCH. test_deploy_when_local_entrypoint_uri_set_and_no_new_revision_needed_should_update_setting is exactly the right test.
Pushbacks — all three accepted
4. You're right and I had it backwards: preview and deploy sharing the validation means it fails on request one, before an upload, which is faster than the local check was.
8. Fair — I conflated the upload timeout with the deploy call. A JSON body plus server-side DB work doesn't grow with app size, and matching the house value for the rest of the JSON surface is the right default.
9. Agreed, no conflict plumbing for a branch that can't fire.
5. Empty file tree — closing it
The guard was working around the publish-side rejection this PR replaces, not a player constraint. For a remote entrypoint the player loads the remote URL; the local tree isn't what renders, and screenly_inject.js reaches the device through js_injection rather than the asset tree. A zero-asset version is fine.
It's also narrower than it looks: create --entrypoint writes screenly_inject.js, and nothing excludes it — it isn't in the exclusion list in utils.rs:24, and no .ignore is written (app.rs:558 asserts that). So a fresh remote app has a one-file tree; empty requires the user to delete or ignore that file. Not worth a guard on either side. Drop it.
Nits — take or leave
outstanding.pendingis no longer waited on, so a file left processing by an interrupted earlier run now returns the 409 "still processing" instead of the CLI waiting it out. The message is clear and actionable, so this seems like the right trade rather than something to fix.update_entrypoint_valuemoved afterapi.deploy, so a failed setting PATCH leaves a published revision with a stale URL and a non-zero exit. It self-heals on re-run via the early-return path, which is arguably why it's fine.
| &self.authentication, | ||
| &format!( | ||
| "v4/assets?select=status,processing_error,title&app_id=eq.{app_id}&app_revision=eq.{revision}&status=neq.finished" | ||
| "v4/assets?select=status,processing_error,title&id=in.({})&status=neq.finished", |
There was a problem hiding this comment.
Non-blocking, but worth catching before a large app hits it: this URL now scales with the number of files uploaded.
Each ULID is ~27 bytes with its separator, so ~300 uploaded files puts the request line past the usual 8 KB header limit and the poll starts 414ing — mid-deploy, after the upload has already succeeded. The first deploy of a large app is exactly the case where every file is missing and every id lands in this list, so it's reachable in one shot rather than needing an unlucky sequence.
Chunking the ids inside the poll loop covers it — the statuses from each batch just concatenate, and the failed / pending_count logic above doesn't care that they arrived in pieces.
What
screenly edge-app deployno longer orchestrates a deploy step by step. Two server endpoints do it instead:POST /v3/edge-apps/{id}/deploy/previewPOST /v3/edge-apps/{id}/deployRequires the server side from Screenly/Screenly#2558.
Why
The old flow read the manifest, diffed and wrote settings one at a time, created a version row, uploaded files against that revision, then published it. Two problems:
How
Files upload as staged assets through the existing
/v4/assetsroute —app_idset,app_revisionleft null — and the deploy that follows claims them. Nothing is created until the content is already there, so a failed deploy leaves no version behind.The preview call also lets the CLI stop early. Three outcomes now:
The last one returns straight after the preview, without posting a deploy at all.
Notes for review
deploy()returnsDeployOutcome { revision: Option<u32>, created: bool }.revisionisNoneonly on the early return; the MCPpublish_from_htmlresponse reports it asnullrather than failing, which it used to do.wait_for_assets_processingpolls/v4/assetsfiltered onapp_revision=is.null, since staged assets have no revision and no signature until the processor has run./deploycarries{"outstanding": {...}}and surfaces asDeploy rejected: not uploaded: …; still processing: ….Testing
cargo test— 189 pass.cargo clippy --all-targetsand nightlyrustfmtclean.