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.
Master added settings display ordering (#308), which encodes each setting's declaration index into its help_text and relies on deserialize_settings no longer sorting by name. Both of its call sites here -- detect_changed_settings and the create/update setting requests -- are the client-side setting management this branch moves to the server, so a plain merge would have compiled and quietly dropped the feature. Kept the ordering machinery and call assign_setting_display_orders on the manifest before building the deploy payload instead. The server stores help_text as given, so display_order survives the round trip untouched. Dropped deserialize_settings_from_array and Setting::new along with the API methods that were their only callers. test_edge_app_create_should_create_app_and_required_files needed master's expectation: dropping the sort is what makes manifest order the declared one, and git kept this branch's alphabetical expectation with no conflict.
There was a problem hiding this comment.
🟡 Changes recommended
A new warning path in EdgeAppCommand::deploy writes to stdout, which can interfere with machine-readable stdout consumers (notably the MCP tool response).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Lite
…ge-app-deploy # Conflicts: # src/cli.rs # src/commands/edge_app/app.rs # src/mcp/tools/edge_app.rs
There was a problem hiding this comment.
🔵 Needs a closer look
It rewires a core deploy workflow across multiple layers (CLI, command logic, API surface, and tests) and depends on new server behavior that should be validated by a human reviewer against real platform responses and edge cases.
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Lite
The asset processing poll built a single id=in.(...) query from every uploaded asset, so a first deploy of a large app pushed the request line past the 8 KB limit and started 414ing after the upload had succeeded. The settings warning went to stdout, which corrupts the MCP tool's JSON response.
There was a problem hiding this comment.
🟡 Changes recommended
The deploy flow currently trusts server-provided missing file paths for uploads without validating they’re safe relative paths within the edge-app directory, which can enable unintended local file exfiltration when using a custom server URL.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/api/edge_app/deploy.rs:58
- If the server returns a 409 without any
outstandingdetails,OutstandingFiles::fmtcurrently formats to an empty string, resulting in an unhelpful error likeDeploy rejected:. Consider emitting a fallback message when no details are present.
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
The deploy payload can send a manifest id that disagrees with the actual app id being deployed to when an override is used, which can break server-side validation/behavior.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
src/commands/edge_app/app.rs:231
- The deploy payload serializes the manifest as-is, including
id. If the caller supplies anapp_idoverride (CLI arg/env) that differs from the manifest’sid, the request can send a mismatchedmanifest.idto the server while targeting/v3/edge-apps/{actual_app_id}/..., which can cause server-side validation errors or confusing behavior.
src/api/edge_app/deploy.rs:168 post_deploylogs the response body usingresponse.text()?inside the error branch; if reading the body fails, this changes the returned error away from the intended status-basedWrongResponseStatus. Read the body best-effort for logging so the original status error is preserved.
src/cli.rs:939- The deploy command now orchestrates a full deploy (preview/upload/deploy), but the CLI error text still says "Failed to upload Edge App", which is misleading for users (and for scripts parsing stderr).
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
The deploy preview response is server-controlled, and its missing list was joined onto the app directory unchecked. An absolute path makes Path::join discard the base entirely and .. segments walk out of the directory, so a rogue API URL could have made the CLI read arbitrary local files and upload them. Requested paths are now looked up in the file tree the client sent, and the tree's own key is what gets joined. generate_file_tree no longer strips the root prefix from paths that collect_paths_for_upload already made relative, which mangled keys under a relative --path.
There was a problem hiding this comment.
🔵 Needs a closer look
It fundamentally changes the Edge App deploy workflow and depends on new server behavior, making production impact high even though tests cover many paths.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/api/edge_app/deploy.rs:167
- In the non-OK response branch,
debug!("Response: …", response.text()? )can turn an otherwise clearWrongResponseStatusinto aRequesterror if reading the body fails, since the log statement uses?. Logging the body should be best-effort and not affect the returned error.
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
Reading the body with ? turned a clear WrongResponseStatus into a Request error whenever the read itself failed.
There was a problem hiding this comment.
🟢 Approval recommended
The new server-orchestrated deploy flow is consistently wired end-to-end with updated tests, with only a minor error-message robustness tweak suggested.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/api/edge_app/deploy.rs:60
OutstandingFilesformats to an empty string when all lists are empty, which can yield an unhelpful error likeDeploy rejected:if the server responds with{}/ missing fields (the struct defaults everything). Consider providing a fallback message whenpartsis empty so the error is always actionable.
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
Every field of OutstandingFiles defaults, so a 409 with an empty or unexpected body rendered as "Deploy rejected: " with nothing after it.
There was a problem hiding this comment.
🔵 Needs a closer look
Server-provided filenames/errors are interpolated into user-facing error strings without escaping, which risks terminal control-sequence injection and should be hardened before approval.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/api/edge_app/deploy.rs:44
file.pathandfile.errorcome from the server and are embedded into a user-visible error string without escaping. This allows control characters/ANSI escapes/newlines from a malicious (or misconfigured) API base URL to be printed to the user’s terminal. Escaping these fields keeps the message safe while still informative.
This issue also appears on line 48 of the same file.
src/api/edge_app/deploy.rs:54
missing/pendingfilenames inOutstandingFilesare server-provided and are joined directly into the displayed error string. Escaping these strings avoids terminal control-sequence injection and keeps error output one-line and predictable.
let mut parts = Vec::new();
if !self.missing.is_empty() {
parts.push(format!("not uploaded: {}", self.missing.join(", ")));
}
if !self.pending.is_empty() {
parts.push(format!("still processing: {}", self.pending.join(", ")));
}
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
It significantly changes the core deploy workflow and shifts correctness onto new server endpoints/semantics, so it warrants final human review despite the accompanying test updates.
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Lite
Issue: #2580
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.