Skip to content

Commit 0500adf

Browse files
authored
Polish platform upgrade and rollback UX (#370)
During platform upgrades and rollbacks, administrators can now follow saved lifecycle progress at `/admin/status`. The authenticated page shows source and target versions, phase, timestamps, and the API's independently observed traffic state. It refreshes while visible, preserves the last confirmed snapshot when updates fail, and prompts for sign-in when the session expires. The CLI previews availability impact, saves resumable progress, and prints concrete recovery commands that retain custom runtime paths. `platform status <installation-id>` shows the local checkpoint before live discovery. Visitors receive a temporary-unavailability page with a manual retry link; API and mutation requests retain HTTP 503 responses without automatic replay. Existing traffic guards, administrator authentication, Sandbox drain access, and runtime-only rollback behavior remain in place. Completed operations on disabled installations still report paused traffic. The status page requires a reachable API and authentication database; these changes do not guarantee zero downtime. Documentation and regression coverage cover these workflows. The missing-package resolver regression also runs in native Node so the test runner's workspace resolution cannot mask an uninstalled package. Validation: - Core and packaged runtime builds, package typechecking, lint, and formatting passed. - Mobile browser coverage verified stale snapshots, refreshed progress, paused traffic after completion, and no horizontal overflow. - Full suite after syncing with main: 673 files passed, 1 skipped; 12,717 tests passed, 31 skipped. - Live Cloudflare deployment tests remained gated by absent E2E credentials.
1 parent 658829e commit 0500adf

30 files changed

Lines changed: 1380 additions & 28 deletions

‎docs/guide/platform/administration/operations.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ The dashboard’s **System Status** page refreshes health checks every 30 second
6666

6767
Use `void platform upgrade`, `repair`, `disable`, and `enable` to maintain your platform. See [platform maintenance](/guide/platform/installation/maintenance#resume-repair-recover-and-upgrade).
6868

69+
Upgrades and rollbacks run through the CLI; watching their progress is optional.
70+
During maintenance, navigating the admin UI automatically redirects to
71+
`/admin/status`. You can also open that page directly on the API origin. It
72+
refreshes automatically and shows saved progress and the API's traffic state
73+
while the API and authentication database remain reachable. If a status refresh
74+
fails, the last confirmed snapshot is marked as stale. Normal administrator
75+
changes stay paused during maintenance.
76+
If the CLI reports an error or a waiting state, follow its recovery instructions
77+
to resume the operation. See
78+
[Follow upgrade and rollback progress](/guide/platform/installation/maintenance#follow-upgrade-and-rollback-progress).
79+
6980
For disaster recovery, keep a matching PostgreSQL backup, provider data backups, installation identity and complete project-encryption keyring, and immutable runtime artifacts. Keep traffic disabled while restoring data, run `discover` to reconstruct verified local metadata, and review `repair --plan` before recreating missing installer-owned infrastructure. Neither command restores backed-up data. See [Prepare for disaster recovery](/guide/platform/installation/maintenance#prepare-for-disaster-recovery) for the full sequence.
7081

7182
## Using Scripts

‎docs/guide/platform/installation/maintenance.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,35 @@ Keep the earlier runtime files if you need rollback. If the installed version is
9898

9999
:::
100100

101+
### Follow upgrade and rollback progress
102+
103+
Watching progress is optional: a running upgrade or rollback proceeds through
104+
the CLI without action on the status page. During maintenance, navigating the
105+
admin UI automatically redirects to `/admin/status`. You can also open it
106+
directly on your platform API origin and sign in as an administrator.
107+
108+
The read-only page shows the saved operation, source and target versions, and
109+
the traffic state reported by the API. It refreshes automatically every five
110+
seconds while visible. If updates fail, it keeps the last confirmed snapshot
111+
and marks it as out of date. Normal administrator changes remain paused.
112+
113+
Sign-in and status remain available while the API and authentication database
114+
are reachable. An operation completing
115+
does not confirm every project's health. A disabled installation keeps traffic
116+
paused after an upgrade or rollback.
117+
118+
If an operation fails or waits for DNS or Sandbox drain work, the CLI prints
119+
the recovery instructions and command needed to resume. Resolve the reported
120+
problem, then resume with the same runtime files. For a custom rollback, retain
121+
the source runtime files too. `void platform status <id>` also prints locally saved progress
122+
before checking the live API; this local checkpoint can be out of date if another
123+
administrator changed the installation.
124+
125+
When application traffic is paused, visitors receive a temporary-unavailability
126+
page with a **Try again** link. API requests and form submissions still receive
127+
HTTP 503 responses. Void does not automatically replay requests; visitors who
128+
submitted a form should check its result before submitting it again.
129+
101130
### Manage an installation from another machine
102131

103132
Use `discover` to restore the administrator's local installation records:

‎docs/reference/cli/platform.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ void platform status [id]
1616

1717
`use` and `status` auto-select the only configured platform; with multiple platforms they show a picker interactively and require an id or URL in non-interactive use. A project with a recorded platform URL keeps using that platform when the global default changes.
1818

19+
For a self-hosted installation, `status <installation-id>` prints locally saved
20+
lifecycle progress before checking the live API. The saved checkpoint may be out
21+
of date. If live discovery fails during maintenance, the command still shows that
22+
checkpoint and exits with an error; it does not confirm current availability.
23+
1924
## Operator commands {#operator-commands}
2025

2126
Use `void platform` to administer the users and apps on your selected platform. Start with the [Platform Administration guide](../../guide/platform-administration.md) for signing in, giving people access, and investigating deployments.

‎packages/platform/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export {
6363
createPlatformInstallationManifest,
6464
parsePlatformResourceOwnershipMarker,
6565
parsePlatformInstallationManifest,
66+
parsePlatformLifecycleOperation,
6667
recordInstallationResource,
6768
recordInstallationStep,
6869
setInstallationDesiredState,
@@ -73,6 +74,7 @@ export {
7374
type InstallationStatus,
7475
type InstallationStep,
7576
type PlatformInstallationManifest,
77+
type PlatformLifecycleOperation,
7678
type PlatformInstallationEndpoints,
7779
type PlatformRuntimeProvenance,
7880
type PlatformResourceOwnershipMarker,

‎packages/platform/src/installation.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,74 @@ export type PlatformRuntimeProvenance = Readonly<{
7272
sourceRevision: string | null;
7373
}>;
7474

75+
/** Saved operator progress; this does not attest application availability. */
76+
export type PlatformLifecycleOperation = Readonly<{
77+
id: string;
78+
action: 'install' | 'upgrade' | 'rollback' | 'repair' | 'enable';
79+
fromVersion: string | null;
80+
toVersion: string;
81+
runtimeManifestSha256?: string;
82+
phase:
83+
| 'preparing'
84+
| 'deploying'
85+
| 'verifying'
86+
| 'enabling'
87+
| 'waiting-for-dns'
88+
| 'waiting-for-sandbox-drain'
89+
| 'complete'
90+
| 'failed';
91+
startedAt: string;
92+
updatedAt: string;
93+
}>;
94+
95+
export function parsePlatformLifecycleOperation(value: unknown): PlatformLifecycleOperation {
96+
assertRecord(value, 'operation');
97+
const actions = ['install', 'upgrade', 'rollback', 'repair', 'enable'] as const;
98+
const phases = [
99+
'preparing',
100+
'deploying',
101+
'verifying',
102+
'enabling',
103+
'waiting-for-dns',
104+
'waiting-for-sandbox-drain',
105+
'complete',
106+
'failed',
107+
] as const;
108+
const action = actions.find((action) => action === value.action);
109+
const phase = phases.find((phase) => phase === value.phase);
110+
if (!action || !phase) {
111+
throw new Error('Invalid platform operation action or phase.');
112+
}
113+
if (
114+
value.runtimeManifestSha256 !== undefined &&
115+
(typeof value.runtimeManifestSha256 !== 'string' ||
116+
!/^[0-9a-f]{64}$/.test(value.runtimeManifestSha256))
117+
) {
118+
throw new Error('Invalid platform operation runtime digest.');
119+
}
120+
return {
121+
id: requiredString(value.id, 'operation.id'),
122+
action,
123+
fromVersion:
124+
value.fromVersion === null
125+
? null
126+
: requiredString(value.fromVersion, 'operation.fromVersion'),
127+
toVersion: requiredString(value.toVersion, 'operation.toVersion'),
128+
...(typeof value.runtimeManifestSha256 === 'string'
129+
? { runtimeManifestSha256: value.runtimeManifestSha256 }
130+
: {}),
131+
phase,
132+
startedAt: assertTimestamp(
133+
requiredString(value.startedAt, 'operation.startedAt'),
134+
'operation.startedAt',
135+
),
136+
updatedAt: assertTimestamp(
137+
requiredString(value.updatedAt, 'operation.updatedAt'),
138+
'operation.updatedAt',
139+
),
140+
};
141+
}
142+
75143
export type PlatformInstallationManifest = Readonly<{
76144
authenticationResources?: ReadonlyArray<
77145
Readonly<{
@@ -99,6 +167,7 @@ export type PlatformInstallationManifest = Readonly<{
99167
endpoints: PlatformInstallationEndpoints;
100168
id: string;
101169
ownershipNonce: string;
170+
operation?: PlatformLifecycleOperation;
102171
platformVersion: string;
103172
resources: ReadonlyArray<InstallationResource>;
104173
runtime: PlatformRuntimeProvenance | null;
@@ -446,6 +515,9 @@ export function parsePlatformInstallationManifest(value: unknown): PlatformInsta
446515
: 'enabled',
447516
status: value.status,
448517
runtime,
518+
...(value.operation !== undefined
519+
? { operation: parsePlatformLifecycleOperation(value.operation) }
520+
: {}),
449521
...(pendingDomain ? { pendingDomain } : {}),
450522
...(value.authenticationResources !== undefined
451523
? { authenticationResources: parseAuthenticationResources(value.authenticationResources) }

‎packages/void/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ generated entries must preserve the module-boundary rules below.
6767

6868
### Key Design Decisions
6969

70+
- **Lifecycle progress**: Save operation phases under the installer database lock. Resume an operation ID only for the same action and target runtime digest. Progress describes the saved operation, not project availability; preserve traffic guards and report disabled installations separately from completed upgrades or rollbacks.
71+
7072
- **Platform resource identity**: New installations use `void-<installation-name>-<role>` names, without random suffixes. Conflicts fail closed; only matching ownership records/markers authorize reuse or resume. Externally managed Hyperdrive is an exception: users select it by ID, its name may differ from the generated name, and the selected name and origin are pinned for recovery. Keep recorded names and recovery behavior for older suffixed installations, and keep installation IDs/ownership nonces in metadata. Do not change application/user naming when changing platform infrastructure naming.
7173

7274
- **Platform test origins**: Domain installations remain the default recommendation. Explicit `platform install --workers-dev` uses schema-v2 application routing and a forwarding Worker per project; normal domain platforms keep schema v1. `platform domain set` checkpoints pending DNS separately, verifies the live runtime token and HTTPS, then hands activation to the authenticated platform API. The service durably prepares Zero Trust destinations and project routing before publishing `PLATFORM_APPLICATION_KEY`; the CLI never publishes that key directly. API and dispatch resolve that configuration into a request-local environment copy. Preserve the original workers.dev identity and API origin. Forwarders share the main slug routing entry, validate its project ID, and honor suspension/disablement. They remain for manual cleanup, and their unpurgeable workers.dev edge-cache layer is bypassed while shared ISR remains enabled.

‎packages/void/src/cli/platform-cmd.ts‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PlatformDiscoveryDocument } from '@void/platform';
1+
import type { PlatformDiscoveryDocument, PlatformInstallationManifest } from '@void/platform';
22
import { isCancel, log, select, text } from '@voidzero-dev/vite-plus-prompts';
33
import { cfAccessHeaders } from '../shared/cf-access.ts';
44
import {
@@ -15,6 +15,7 @@ import {
1515
normalizePlatformApiUrl,
1616
savePlatformConnection,
1717
selectPlatformConnection,
18+
listInstallationManifests,
1819
type PlatformConnection,
1920
} from './platform-registry.ts';
2021

@@ -171,8 +172,36 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro
171172
}
172173

173174
if (args.subcommand === 'status') {
174-
const connection = await resolveConnection(args.selector);
175-
const discovery = await discoverPlatform(connection.apiUrl);
175+
const local = args.selector
176+
? listInstallationManifests().find(
177+
(manifest) => manifest.id === args.selector && manifest.status !== 'uninstalled',
178+
)
179+
: undefined;
180+
const connection = local ? null : await resolveConnection(args.selector);
181+
const saved =
182+
local ??
183+
(connection?.installationId
184+
? listInstallationManifests().find((manifest) => manifest.id === connection.installationId)
185+
: undefined);
186+
if (saved) {
187+
showSavedInstallationStatus(saved);
188+
}
189+
const apiUrl = local?.endpoints.apiUrl ?? connection?.apiUrl;
190+
if (!apiUrl) {
191+
log.info('No verified API endpoint is saved yet. Live traffic availability is unknown.');
192+
return;
193+
}
194+
let discovery: PlatformDiscoveryDocument;
195+
try {
196+
discovery = await discoverPlatform(apiUrl);
197+
} catch (error) {
198+
if (saved) {
199+
log.info(
200+
'Live availability could not be confirmed. The saved checkpoint above may be out of date.',
201+
);
202+
}
203+
throw error;
204+
}
176205
log.success(`${discovery.displayName} is reachable.`);
177206
console.log(`API: ${discovery.apiUrl}`);
178207
console.log(`Proxy: ${discovery.proxyUrl}`);
@@ -195,3 +224,16 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro
195224
const { runPlatformLifecycleCommand } = await import('./platform-lifecycle.ts');
196225
await runPlatformLifecycleCommand(args);
197226
}
227+
228+
function showSavedInstallationStatus(manifest: PlatformInstallationManifest): void {
229+
console.log(`Saved installation: ${manifest.id} (${manifest.status})`);
230+
console.log(`Checkpoint: ${manifest.updatedAt}`);
231+
if (manifest.operation) {
232+
const operation = manifest.operation;
233+
console.log(`Saved operation: ${operation.action} — ${operation.phase}`);
234+
console.log(`Runtime: ${operation.fromVersion ?? 'new installation'} → ${operation.toVersion}`);
235+
}
236+
if (manifest.endpoints.apiUrl) {
237+
console.log(`Administrator status: ${manifest.endpoints.apiUrl}/admin/status`);
238+
}
239+
}

0 commit comments

Comments
 (0)