Repository navigation
feat(template): upgrade a deployed template instance, and roll it back one step #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a70bed3
8bd65a5
b9b119a
4a464b6
649a9a5
75fed5e
3f1295d
5b5f117
f2929bc
549f58d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -142,12 +142,19 @@ export function parseSetFlags(pairs: string[]): Record<string, string> { | |||||||
| const values: Record<string, string> = {} | ||||||||
| for (const pair of pairs) { | ||||||||
| const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair) | ||||||||
| if (!m) throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${pair}`) | ||||||||
| if (!m) throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${redactSetPair(pair)}`) | ||||||||
| values[m[1]!] = m[2]! | ||||||||
| } | ||||||||
| return values | ||||||||
| } | ||||||||
|
|
||||||||
| // --set carries secrets and this error reaches stderr and CI logs, so only the name is ever shown. | ||||||||
| function redactSetPair(pair: string): string { | ||||||||
| const eq = pair.indexOf('=') | ||||||||
| // Nothing of a pair with no `=`: it was never split, so the whole token may be the value itself. | ||||||||
| return eq >= 0 ? `${pair.slice(0, eq)}=<value hidden>` : '<value hidden: no NAME= in front of it>' | ||||||||
| } | ||||||||
|
|
||||||||
| export function missingVariablesMessage(missing: TemplateVar[]): string { | ||||||||
| return [ | ||||||||
| 'missing required template variables:', | ||||||||
|
|
@@ -254,8 +261,12 @@ export function deploymentUrls(dep: any): string[] { | |||||||
| } | ||||||||
|
|
||||||||
| // One line per service with its terminal state — the anatomy of a partial/failed run. | ||||||||
| // `skipped` is the platform saying the user deleted that service and the run stepped over it. Its | ||||||||
| // `state` stays `pending`, which under the marks below would read as "not reached yet" — a run that | ||||||||
| // is still going — so it gets its own mark and names the reason instead. | ||||||||
| export function serviceStateLines(dep: any): string[] { | ||||||||
| return (dep?.services ?? []).map((s: any) => { | ||||||||
| if (s?.skipped) return ` - ${s?.name ?? 'service'} [skipped: it no longer exists, so the run stepped over it]` | ||||||||
| const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•' | ||||||||
| return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}` | ||||||||
| }) | ||||||||
|
|
@@ -266,9 +277,13 @@ export function serviceStateLines(dep: any): string[] { | |||||||
| // deploy, or clean up), not just that something went wrong. | ||||||||
| export function partialMessage(dep: any): string { | ||||||||
| const services: any[] = dep?.services ?? [] | ||||||||
| const healthy = services.filter((s) => s?.state === 'healthy').length | ||||||||
| // A skipped service was never part of this run — the user had deleted it — so counting it in the | ||||||||
| // denominator would report a run that did everything it could as having left something behind. | ||||||||
| // It is still LISTED below, because "where did it go" is the question the count then raises. | ||||||||
| const ran = services.filter((s) => !s?.skipped) | ||||||||
| const healthy = ran.filter((s) => s?.state === 'healthy').length | ||||||||
| return [ | ||||||||
| `template deployment finished partial: ${healthy}/${services.length} services healthy`, | ||||||||
| `template deployment finished partial: ${healthy}/${ran.length} services healthy`, | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When every service in a partial run is marked Prompt for AI agents
Suggested change
|
||||||||
| ...serviceStateLines(dep), | ||||||||
| ...(dep?.error ? [` ${dep.error}`] : []), | ||||||||
| ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []), | ||||||||
|
|
@@ -458,3 +473,203 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = | |||||||
| info('next: `insta postgres url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env') | ||||||||
| renderNextActions(dep.nextActions) | ||||||||
| } | ||||||||
|
|
||||||||
| // ---- upgrade / rollback ---- | ||||||||
|
|
||||||||
| export type TemplateUpgradeOpts = { set?: string[]; branch?: string; yes?: boolean; json?: boolean } | ||||||||
| export type TemplateRollbackOpts = { set?: string[]; branch?: string; yes?: boolean; json?: boolean } | ||||||||
|
|
||||||||
| type PlanField = { field: string; label: string; deployed: string | null; live: string | null; next: string | null; drifted: boolean; verdict: string } | ||||||||
| type PlanService = { key: string; service_name: string | null; gone: boolean; added: boolean; fields: PlanField[]; missing_variables: string[] } | ||||||||
| type UpgradePlanBody = { from_version: string; to_version: string; to_digest: string; services: PlanService[]; removed: string[]; refusals: string[] } | ||||||||
|
|
||||||||
| /** The review, as lines. Current is the LIVE value: what the upgrade will overwrite. */ | ||||||||
| export function upgradePlanLines(plan: UpgradePlanBody): string[] { | ||||||||
| if (plan.refusals.length) { | ||||||||
| return ['This upgrade cannot run:', ...plan.refusals.map((r) => ` ${r}`)] | ||||||||
| } | ||||||||
| const lines = [`${plan.from_version} → ${plan.to_version}`] | ||||||||
| for (const s of plan.services) { | ||||||||
| const who = s.service_name ?? s.key | ||||||||
| if (s.gone) { lines.push(` ${who} no longer exists, skipped`); continue } | ||||||||
| // Created, not reconciled: there is no live value to compare, so its rows follow below as they come. | ||||||||
| if (s.added) lines.push(` ${who} added by ${plan.to_version}, created by this upgrade`) | ||||||||
| for (const f of s.fields) { | ||||||||
| const drift = f.drifted ? ' (you changed this, the upgrade will overwrite it)' : '' | ||||||||
| lines.push(` ${who} ${f.label} ${f.live ?? '(none)'} → ${f.next}${drift}`) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A null target value appears as literal Prompt for AI agents
Suggested change
|
||||||||
| } | ||||||||
| for (const name of s.missing_variables) { | ||||||||
| lines.push(` ${who} ${name} is required by ${plan.to_version} and has no value, pass --set ${name}=...`) | ||||||||
| } | ||||||||
| } | ||||||||
| for (const key of plan.removed) lines.push(` ${key} dropped by ${plan.to_version}, left running`) | ||||||||
| return lines | ||||||||
| } | ||||||||
|
|
||||||||
| // The 409 codes a user will actually hit, in a sentence that says what to do next. | ||||||||
| const UPGRADE_409: Record<string, string> = { | ||||||||
| template_version_changed: 'The template was republished since the plan was shown. Nothing was changed. Run the command again to review the new version.', | ||||||||
| } | ||||||||
| const ROLLBACK_409: Record<string, string> = { | ||||||||
| template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.', | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Codes whose platform sentence names the deployment or the version to act on. Replacing it with | ||||||||
| * wording of our own would drop the one name that makes it actionable, so the platform's sentence | ||||||||
| * is printed as given and only the next step is appended. | ||||||||
| */ | ||||||||
| const KEEP_PLATFORM_SENTENCE: Record<string, string> = { | ||||||||
| // "this deployment was already replaced by <code>@<version> — act on that deployment instead". | ||||||||
| // Reachable here only for a service a NEWER version dropped: it keeps pointing at the deployment | ||||||||
| // the instance has moved off, which is the one this CLI resolves from the service name. | ||||||||
| template_superseded: 'Nothing was changed. This command addresses a service, so name one that the newer deployment carries.', | ||||||||
| // "<code>@<version> was republished with different content after this instance ran it …". | ||||||||
| template_version_content_changed: 'Nothing was changed.', | ||||||||
| // Two causes now, and the second one names the version: "this instance was already rolled back | ||||||||
| // to <version> … upgrade it to move forward", beside the original "was not upgraded from | ||||||||
| // anything". Our own wording could only say one of them, and said the wrong one for a rollback. | ||||||||
| template_no_step_back: 'Nothing was changed.', | ||||||||
| } | ||||||||
|
|
||||||||
| /** The sentence to raise for a coded 409, or undefined when this is not one we explain. */ | ||||||||
| function coded409(e: unknown, advice: Record<string, string>): Error | undefined { | ||||||||
| if (!(e instanceof ApiError) || e.status !== 409) return undefined | ||||||||
| const code = typeof e.body?.code === 'string' ? e.body.code : '' | ||||||||
| if (advice[code]) return new Error(advice[code]) | ||||||||
| const next = KEEP_PLATFORM_SENTENCE[code] | ||||||||
| return next ? new Error(`${e.message}. ${next}`) : undefined | ||||||||
| } | ||||||||
|
|
||||||||
| async function postWithAdvice(api: TemplateApi, path: string, body: unknown, advice: Record<string, string>) { | ||||||||
| try { | ||||||||
| return await api.rawRequest('POST', path, body) | ||||||||
| } catch (e) { | ||||||||
| throw coded409(e, advice) ?? e | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * A service name is what a person has; a deployment uuid is not. The services list already carries | ||||||||
| * the attribution, so the lookup lives here rather than in the user's head. | ||||||||
| */ | ||||||||
| async function deploymentOfService(api: TemplateApi, projectId: string, branch: string, service: string, verb: 'upgrade' | 'roll back') { | ||||||||
| const body = await api.request('GET', `/projects/${projectId}/services?branch=${encodeURIComponent(branch)}`) | ||||||||
| const row = (body.services ?? []).find((s: any) => s.name === service) | ||||||||
| if (!row) throw new Error(`no service named ${service} on branch ${branch}`) | ||||||||
| // Named by the caller: the same sentence under `rollback` would send the user to upgrade instead. | ||||||||
| if (!row.template_deployment_id) throw new Error(`${service} was not deployed from a template, so there is nothing to ${verb}`) | ||||||||
| return { deploymentId: row.template_deployment_id as string } | ||||||||
| } | ||||||||
|
|
||||||||
| export async function templateUpgrade(service: string, opts: TemplateUpgradeOpts = {}, deps: TemplateDeployDeps = {}): Promise<void> { | ||||||||
| const given = parseSetFlags(opts.set ?? []) // a typo'd --set fails before any network access | ||||||||
| const api = deps.api ?? (await ApiClient.load()) | ||||||||
| const p = deps.project ?? (await requireProject()) | ||||||||
| const branchName = opts.branch ?? p.branch | ||||||||
| const ask = deps.ask ?? promptVariable | ||||||||
|
|
||||||||
| const target = await deploymentOfService(api, p.projectId, branchName, service, 'upgrade') | ||||||||
| // The poll and preview routes are keyed by deployment id, not project: name the project so agent | ||||||||
| // mode signs with the project-bound session, exactly as templateDeploy does. | ||||||||
| // The preview is a GET and raises coded 409s of its own (template_superseded among them), so it | ||||||||
| // gets the same reading as the POST rather than the raw "<sentence> (HTTP 409)" the guard prints. | ||||||||
| let plan | ||||||||
| try { | ||||||||
| ;({ plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId })) | ||||||||
| } catch (e) { | ||||||||
| throw coded409(e, UPGRADE_409) ?? e | ||||||||
| } | ||||||||
| // --json alone is "tell me" (the plan, no upgrade); --json --yes is "do it and tell me". | ||||||||
| const quiet = !!opts.json | ||||||||
| if (opts.json && (!opts.yes || plan.refusals.length)) { | ||||||||
| printJson(plan) | ||||||||
| if (plan.refusals.length) process.exitCode = 1 | ||||||||
| return | ||||||||
| } | ||||||||
|
|
||||||||
| if (!quiet) for (const line of upgradePlanLines(plan)) info(line) | ||||||||
| // A refusal is not a prompt to confirm past: there is nothing to confirm. | ||||||||
| if (plan.refusals.length) { process.exitCode = 1; return } | ||||||||
|
|
||||||||
| // Only what the new version ADDS is ever asked for: the platform recovers every value the | ||||||||
| // instance already holds, so re-prompting would invite the user to overwrite a live credential. | ||||||||
| const missing = plan.services.flatMap((s: PlanService) => s.missing_variables.map((name) => ({ name, required: true }) as TemplateVar)) | ||||||||
| const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY | ||||||||
| const variables = missing.length ? await resolveVariables(missing, given, { tty, ask }) : given | ||||||||
|
|
||||||||
| if (!opts.yes && tty) { | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A piped upgrade skips this confirmation and still posts unless Prompt for AI agents
Suggested change
|
||||||||
| const go = await clack.confirm({ message: `Upgrade ${service} from ${plan.from_version} to ${plan.to_version}?` }) | ||||||||
| if (clack.isCancel(go)) throw new CliCancel() | ||||||||
| if (!go) return | ||||||||
| } | ||||||||
|
|
||||||||
| // The version and digest the user just read. A republish in between answers 409 rather than | ||||||||
| // running something they never saw. | ||||||||
| const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, { | ||||||||
| variables, expectedVersion: plan.to_version, expectedDigest: plan.to_digest, | ||||||||
| }, UPGRADE_409) | ||||||||
| if (handleApproval(res, opts.json)) return | ||||||||
|
|
||||||||
| const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id | ||||||||
| if (!quiet) info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`) | ||||||||
| const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => {} : info, deps.wait) | ||||||||
| if (quiet) return printJson(dep) | ||||||||
| info(`${service} is on ${plan.to_version}`) | ||||||||
| info(`next: \`insta template rollback ${service}\` returns it to ${plan.from_version}`) | ||||||||
| renderNextActions(dep.nextActions) | ||||||||
| } | ||||||||
|
|
||||||||
| export async function templateRollback(service: string, opts: TemplateRollbackOpts = {}, deps: TemplateDeployDeps = {}): Promise<void> { | ||||||||
| const given = parseSetFlags(opts.set ?? []) // a typo'd --set fails before any network access | ||||||||
| const quiet = !!opts.json | ||||||||
| const out = quiet ? () => {} : info | ||||||||
| // Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out | ||||||||
| // loud rather than roll back unseen through a pipe. | ||||||||
| // Same rule as `template publish`: ask on a terminal, anywhere else (a pipe, --json) say --yes. | ||||||||
| const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY | ||||||||
| if (!opts.yes && !tty) throw new Error('rollback needs --yes without a terminal to confirm on: it does not restore data the app migrated, so nothing was rolled back') | ||||||||
| const api = deps.api ?? (await ApiClient.load()) | ||||||||
| const p = deps.project ?? (await requireProject()) | ||||||||
| const branchName = opts.branch ?? p.branch | ||||||||
| const ask = deps.ask ?? promptVariable | ||||||||
| const target = await deploymentOfService(api, p.projectId, branchName, service, 'roll back') | ||||||||
|
|
||||||||
| // Said BEFORE the confirmation, not after the fact: rollback prints no plan, so these lines are | ||||||||
| // the whole disclosure, and what they warn of is not recoverable by running the command again. | ||||||||
| out('Going back restores every setting that version declares: the image, the start command, the port, always-on, public access, the volume mount path and the recorded variables.') | ||||||||
| out('A setting the older version does not declare keeps its current value.') | ||||||||
| out('Public access on a storage bucket reaches that bucket on every branch that has a copy, not only this one.') | ||||||||
| out('It does not restore data the app migrated under the newer version, and a volume only grows.') | ||||||||
| if (tty) { | ||||||||
| const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` }) | ||||||||
| if (clack.isCancel(go)) throw new CliCancel() | ||||||||
| if (!go) return | ||||||||
| } | ||||||||
|
|
||||||||
| // The platform recovers every value the services still hold, so a rollback normally sends | ||||||||
| // nothing. What it cannot recover is a variable the NEWER version stopped declaring: the upgrade | ||||||||
| // deleted that value and the older manifest still requires it. Rollback prints no plan, so the | ||||||||
| // platform's 400 naming them is the only place those names exist — prompt from it and retry | ||||||||
| // once, exactly as templateDeploy does with its own missing_variables. | ||||||||
| const rollbackPath = `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback` | ||||||||
| const variables: Record<string, string> = { ...given } | ||||||||
| let res | ||||||||
| try { | ||||||||
| res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409) | ||||||||
| } catch (e) { | ||||||||
| const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null | ||||||||
| if (!missing?.length) throw e | ||||||||
| // No TTY (or --yes) fails here with the `--set NAME=value` list, the same rule the deploy and | ||||||||
| // upgrade paths follow — nothing has been rolled back at this point. | ||||||||
| Object.assign(variables, await resolveVariables(missing, {}, { tty, ask })) | ||||||||
| res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409) | ||||||||
| } | ||||||||
| if (handleApproval(res, opts.json)) return | ||||||||
| const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id | ||||||||
| out(`rolling ${service} back (${deploymentId})`) | ||||||||
| const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, out, deps.wait) | ||||||||
| if (quiet) return printJson(dep) | ||||||||
| info(`${service} is back on ${(dep.deployment ?? dep).template_version ?? 'its previous version'}`) | ||||||||
| renderNextActions(dep.nextActions) | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -573,6 +573,18 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a | |||||
| .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') | ||||||
| .option('--json') | ||||||
| .action(guard((target, o) => template.templateDeploy(target, o))) | ||||||
| tpl.command('upgrade <service>').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. A service that already exists is reused, and every setting the new version declares is applied: image, start command, port, always-on, public access, the volume and its mount path, and the variables, each of them listed in the plan first. A service the new version ADDS is created by the upgrade: the plan names it as added and then lists every setting it will be created with, each written `(none) -> <new value>` because there is no live service to compare against, followed by the variables it requires. A service the new version no longer declares is left running, named in the plan as dropped. A volume the new version adds is attached at your plan\'s size and mounted where the manifest says; an existing one is never resized. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. Refused with a 409 when the template was republished since the plan, or when this instance has already moved off this deployment') | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The plan prints Prompt for AI agents
Suggested change
|
||||||
| .option('--branch <b>', 'branch the service is on (default: current)') | ||||||
| .option('--set <NAME=value>', 'set a template variable the new version requires (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) | ||||||
| .option('-y, --yes', 'do not ask for confirmation') | ||||||
| .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment') | ||||||
| .action(guard((service, o) => template.templateUpgrade(service, o))) | ||||||
| tpl.command('rollback <service>').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. It does not restore data the app migrated, and a volume only grows. Refused with a 409 when the service was never upgraded, when that version\'s manifest is no longer recorded, or when that version was republished with different content after this instance ran it') | ||||||
| .option('--branch <b>', 'branch the service is on (default: current)') | ||||||
| .option('--set <NAME=value>', "set a template variable the older version requires that the instance no longer holds — an upgrade to a version that stopped declaring it deleted the value (repeatable)", (v: string, prev: string[]) => [...prev, v], [] as string[]) | ||||||
| .option('-y, --yes', 'do not ask for confirmation') | ||||||
| .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes') | ||||||
| .action(guard((service, o) => template.templateRollback(service, o))) | ||||||
| // Authoring: the org's community templates, the same drafts the console editor changes. | ||||||
| tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link") | ||||||
| .option('--project <id>', 'project to generate the draft from (default: the linked project)') | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Malformed names are echoed without validation, so
--set hunter2-live-key=xleakshunter2-live-keyto stderr despite being rejected. Show the prefix only when it matches the allowed variable-name pattern; otherwise redact the whole token.Prompt for AI agents