Skip to content

Plan and apply

ShinyHub has two deployment paths:

  • shinyhub deploy is the fast path. It prepares the current source and deploys it immediately.
  • shinyhub plan --out followed by shinyhub apply is the reviewable path. It saves the exact bundle, desired state, target, expiry, and remote revision that were reviewed, then applies those bytes without rebuilding from the working tree.

Use the exact path when a deployment needs human approval, an audit trail, or a strict separation between review and execution:

shinyhub doctor .
shinyhub plan . --out sales.plan
shinyhub plan show sales.plan
shinyhub apply sales.plan

Pass the source path explicitly. Plan, like deploy, never assumes that the current directory is safe to bundle.

Read a plan

Human output follows the same decision order for a single app and a fleet:

  1. outcome;
  2. user, availability, ownership, or destructive impact;
  3. semantic changes;
  4. bundle or resource detail;
  5. create/update/adopt/delete totals;
  6. the safest next command.

At narrow terminal widths the layout stacks instead of discarding information. Color is additive: action words, symbols, headings, and ordering retain the same meaning with --no-color, NO_COLOR, or ASCII-only output.

The default view is decision-sized. Ask for implementation detail only when it is useful:

shinyhub plan . --details
shinyhub plan show --details sales.plan
shinyhub plan show --files sales.plan

The detail view includes every bundled path, launch command, readiness contract, manifest effect, permission, target URL, and saved-plan integrity field. JSON is always complete and does not apply the human view's progressive disclosure.

What plan verifies

shinyhub plan uses the same bundle preparation, launch resolution, manifest validation, and target selection as deploy. It reports:

  • create, update, or unchanged/redeploy intent;
  • current and planned content digests and typed configuration values;
  • create/manage permission, access, ownership, and lifecycle effects;
  • source size, upload size, included paths, ignore rules, and protected data or cache paths;
  • runtime, dependency preparation, launch command, readiness endpoint, and startup deadline;
  • hooks, schedules, access declarations, tracing, and other manifest effects;
  • fleet adoption and every prune candidate in an isolated destructive section;
  • degraded comparisons, version skew, in-progress deployment, ownership, and availability warnings.

Planning is read-only. It performs local preparation and remote reads, but never creates an app, uploads a bundle, changes access, starts a stopped app, or probes permission with a write.

An unchanged digest means the bundle's files, executable bits, and manifest match the newest successful deployment. Applying is still explicit because a redeploy may replace replicas. If an older server cannot report a live digest, plan reports the comparison as unknown; it never invents equality.

Saved plans are exact and private

--out writes an owner-only (0600) plan container containing application source. Treat it like a release artifact:

  • do not commit it;
  • transfer and retain it only where the source itself is allowed;
  • use --expires-in to shorten the default 24-hour lifetime;
  • use --force only when deliberately replacing an existing plan file.

Before any mutation, shinyhub apply verifies the container structure, integrity digest, embedded bundle digest, expiry, target host, desired-state consistency, server compatibility, and the target's resource revision. A changed working tree is irrelevant: apply uploads the reviewed embedded bundle.

If the app changed after planning, apply returns a conflict and tells you to create a new plan. It does not silently recompute or apply against the newer state. Creates use an expected-absent precondition, so another actor claiming the slug is also rejected before deployment.

Use plan show for offline inspection. It verifies the artifact but permits an expired plan because inspection cannot mutate the server; apply never permits an expired plan.

Fleet plan and apply

Fleet planning uses the same action vocabulary and count model:

shinyhub fleet plan -f fleets/eu.toml
shinyhub fleet apply -f fleets/eu.toml

Ownership transfers require --adopt. Deletion requires --prune plus an interactive confirmation, or an explicit --yes supplied by the operator. Suggested and recovery commands intentionally never add --yes.

Fleet apply is non-atomic and continues across resources. Its report therefore records a terminal status and a separate mutation state for every app or project:

  • none: ShinyHub can prove no mutation occurred;
  • committed: the requested resource change completed;
  • partial: a mutation committed but a later convergence step failed;
  • unknown: the client cannot prove whether the remote mutation committed.

Every run has a cryptographically random run ID for correlation with server audit records. Current servers add a monotonic sequence, client heartbeat, and immutable terminal status. Late writes from an older overlapping run cannot replace newer per-app convergence, and a run that stops heartbeating without a terminal result is distinguishable from success. On failure, human and JSON output identify committed, partial, and unknown resources and provide one of three recovery strategies:

  • resume: transient failure; re-run apply;
  • repair_then_resume: fix the deterministic or post-commit failure, then re-run apply;
  • replan: remote state conflicted; review a fresh plan before re-applying.

Two repair_then_resume cases are deliberate refusals rather than defects, and the recovery output names the specific next action for each instead of a generic repair. A downtime_required failure means the deploy could not be completed without dropping live sessions, because parallel generation handoff does not support that app's current shape; rather than drop them unasked the server made no change and left the working version serving. Re-run with --allow-downtime to permit the stop-first deployment. A schedule_stale failure means a freshness gate is unsatisfied and apply will not dispatch producers itself; the recovery lists the shinyhub schedule run <slug> <schedule> commands to run first, and only for schedules that are plainly overdue rather than already refreshing.

Worker isolation and generation handoff

Code updates to an app using grouped isolation can deploy without stopping its existing workers. ShinyHub prepares the replacement bundle and health-checks one new worker before publishing it. Existing client bindings and WebSockets remain on the old version; new clients use the replacement. The Switch to latest action gives that browser a new binding without moving other clients. No additional flag is needed, and --allow-downtime still permits a fallback rather than forcing one.

This path also supports switching between grouped and multiplex isolation on the default native tier of a single server. The candidate starts with its target policy; each generation retains its own recorded isolation. Metadata, resource limits, worker policy, and schedule declarations can be staged and published with the replacement when no producer needs to run. Hooks, deploy-time shared producers, other isolation modes, and unsupported placement still require stop-first deployment. The refusal names the reason. A consecutive compatible deploy can publish another generation while earlier sessions drain. Each outgoing generation retains its original retirement deadline; a later deploy does not extend it. Candidate admission uses the host's remaining memory, so retained generations can defer a new handoff for capacity. Unconfirmed process cleanup still fences new launches, and stop-first operations wait for all retained generations. Rolling restarts can also proceed beside a previous deploy's drain; a currently executing roll remains serialized.

The server reserves memory for the candidate and configured warm spares, using explicit limits or observed worker startup peaks. A candidate that fails readiness leaves the old version and its configuration serving.

An outgoing multiplex generation retains already-open HTTP streams and WebSockets; subsequent requests use the new generation. An outgoing grouped generation also retains its existing client bindings through reconnect grace. New clients always use the target mode. Structural settings changes, scaling, and unrelated rolling activations defer until the previous drain completes.

After a hub restart, recorded isolation determines how living processes are interpreted even if the fleet default changed. Grouped client bindings live in hub memory and cannot be recovered after a restart; their workers are confirmed stopped before fresh demand-driven workers start. A verified cold start with no old process, route, or cleanup identity may adopt newly desired isolation for the same bundle. Suspended apps resume with their recorded isolation; a saved isolation change takes effect after a cold replacement.

Old workers count as draining and do not accept new clients or consume the new generation's max_workers allowance. They retain memory until their clients finish, including the reconnect grace window. server.drain_timeout remains the hard limit: sessions still using the old version at that deadline are terminated. Further workers and warm spares start on demand using the newly published bundle. A server restart ends grouped sessions and cleans up recorded workers before starting a fresh pool; this feature covers app deployment, not session preservation across a server restart.

Re-running fleet apply recomputes current state. Already completed resources become unchanged, so they are not applied twice. Deploy attempts automatically retry only readiness timeouts, transport failures, and server errors; config patches retry only server-side failures. Invalid bundles, missing runtimes, build/hook failures, validation errors, conflicts, and crashes are not repeated implicitly. Once an upload has committed, its retry budget re-checks health and digest readback without uploading the bundle again. Source uploads on current servers are also fenced by the digest and fleet owner observed by plan, so a late overlapping apply conflicts before promotion instead of becoming the last writer.

Older servers without fleet preconditions remain usable in a clearly marked degraded mode. Config reconciliation narrows the race with a fresh read, while pruning is disabled unless the operator explicitly accepts the risk with --allow-unsafe-degraded-prune.

Automation contract

Request JSON explicitly instead of parsing terminal text:

shinyhub plan . --output json
shinyhub plan . --detailed-exitcode --output json
shinyhub fleet plan -f fleets/eu.toml --output json
shinyhub fleet apply -f fleets/eu.toml --output json

The plan envelope includes a schema version, typed resources and values, impacts, warnings, counts, and next actions. Single-app output also includes the bundle, launch, manifest, remote state, and deploy command. Fleet apply adds the run ID, run status, per-resource result and mutation state, summary exit fields, and structured recovery guidance.

Plan exit codes:

Code Meaning
0 Plan printed; with detailed exit codes, content is unchanged
1 Local validation or CLI/server protocol compatibility failed
2 Detailed mode only: content is new, changed, or cannot be compared
3 Network, authentication, or authorization failed
6 The host answered, but ShinyHub was not ready

Fleet apply additionally uses 4 for partial convergence and 5 for a remote precondition conflict. A successfully printed ordinary plan returns 0 unless --detailed-exitcode (or its --fail-on-changes alias) was requested.

Fast path

For an interactive deployment where a separately approved artifact is not needed:

shinyhub doctor .
shinyhub plan .
shinyhub deploy . --open

--open waits for health, verifies the routed app when possible, and opens its canonical URL. The plan's final command remains copy-pasteable and uses --wait for automation; replace it with --open for the browser handoff.