@nocobase/app-installer 0.0.0-stage → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +217 -2
  2. package/bin/run.js +43 -0
  3. package/dist/cli.js +171 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/commands/install.js +383 -0
  6. package/dist/commands/install.js.map +1 -0
  7. package/dist/commands/rollback.js +287 -0
  8. package/dist/commands/rollback.js.map +1 -0
  9. package/dist/commands/status.js +155 -0
  10. package/dist/commands/status.js.map +1 -0
  11. package/dist/commands/upgrade.js +709 -0
  12. package/dist/commands/upgrade.js.map +1 -0
  13. package/dist/lib/app-cli.js +89 -0
  14. package/dist/lib/app-cli.js.map +1 -0
  15. package/dist/lib/backup.js +167 -0
  16. package/dist/lib/backup.js.map +1 -0
  17. package/dist/lib/confirm.js +36 -0
  18. package/dist/lib/confirm.js.map +1 -0
  19. package/dist/lib/current-link.js +22 -0
  20. package/dist/lib/current-link.js.map +1 -0
  21. package/dist/lib/ecosystem.js +86 -0
  22. package/dist/lib/ecosystem.js.map +1 -0
  23. package/dist/lib/env-file.js +87 -0
  24. package/dist/lib/env-file.js.map +1 -0
  25. package/dist/lib/errors.js +32 -0
  26. package/dist/lib/errors.js.map +1 -0
  27. package/dist/lib/health.js +55 -0
  28. package/dist/lib/health.js.map +1 -0
  29. package/dist/lib/initial-admin.js +39 -0
  30. package/dist/lib/initial-admin.js.map +1 -0
  31. package/dist/lib/interrupt.js +50 -0
  32. package/dist/lib/interrupt.js.map +1 -0
  33. package/dist/lib/invocation.js +33 -0
  34. package/dist/lib/invocation.js.map +1 -0
  35. package/dist/lib/layout.js +61 -0
  36. package/dist/lib/layout.js.map +1 -0
  37. package/dist/lib/lock.js +59 -0
  38. package/dist/lib/lock.js.map +1 -0
  39. package/dist/lib/mount-path.js +34 -0
  40. package/dist/lib/mount-path.js.map +1 -0
  41. package/dist/lib/output.js +61 -0
  42. package/dist/lib/output.js.map +1 -0
  43. package/dist/lib/pm2.js +92 -0
  44. package/dist/lib/pm2.js.map +1 -0
  45. package/dist/lib/prechecks.js +236 -0
  46. package/dist/lib/prechecks.js.map +1 -0
  47. package/dist/lib/registry.js +59 -0
  48. package/dist/lib/registry.js.map +1 -0
  49. package/dist/lib/release.js +328 -0
  50. package/dist/lib/release.js.map +1 -0
  51. package/dist/lib/run-command.js +108 -0
  52. package/dist/lib/run-command.js.map +1 -0
  53. package/dist/lib/service.js +100 -0
  54. package/dist/lib/service.js.map +1 -0
  55. package/dist/lib/source.js +100 -0
  56. package/dist/lib/source.js.map +1 -0
  57. package/dist/lib/state.js +73 -0
  58. package/dist/lib/state.js.map +1 -0
  59. package/dist/lib/version.js +51 -0
  60. package/dist/lib/version.js.map +1 -0
  61. package/package.json +51 -3
@@ -0,0 +1,709 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rm, statfs } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Flags } from '@oclif/core';
5
+ import { pendingTaskCount, runAppCli } from "../lib/app-cli.js";
6
+ import { backupForUpgrade, backupName, readDatabaseInventory, removeBackup, restoreDatabase, } from "../lib/backup.js";
7
+ import { confirm } from "../lib/confirm.js";
8
+ import { switchCurrent } from "../lib/current-link.js";
9
+ import { healthUrl, mountPathOf, readAppEnv } from "../lib/env-file.js";
10
+ import { assertFixedMountPath } from "../lib/mount-path.js";
11
+ import { EXIT_FAILED, EXIT_INVALID, EXIT_ROLLBACK_FAILED, EXIT_ROLLED_BACK, InstallerError, } from "../lib/errors.js";
12
+ import { quoteForShell } from '@nocobase/cli-envelope';
13
+ import { installerCommand, installerCommandLine } from "../lib/invocation.js";
14
+ import { layoutOf, releaseDir, releaseLinkTarget, } from "../lib/layout.js";
15
+ import { acquireLock } from "../lib/lock.js";
16
+ import { checkPlatform, checkPm2, checkPnpm, checkPortFree, currentNodeMajor, } from "../lib/prechecks.js";
17
+ import { resolveTemplateVersion } from "../lib/registry.js";
18
+ import { assertStorageOutsideRelease, buildFromTemplate, checkArchiveDriver, unpackRelease, verifyBuildTarget, } from "../lib/release.js";
19
+ import { runCommand } from "../lib/run-command.js";
20
+ import { capitalize, hostsApplications, resolveArchivePath, subjectOf, templateOf, } from "../lib/source.js";
21
+ import { checkPm2Ownership, errorLogCommandLine, errorLogTail, startAdvice, startApp, stopApp, } from "../lib/service.js";
22
+ import { findRelease, fixedMountPathOf, readState, writeState, } from "../lib/state.js";
23
+ import { compareVersions } from "../lib/version.js";
24
+ /** A build needs room for the sources and development dependencies (about 900 MB) plus the release and a backup. */
25
+ const MINIMUM_FREE_BYTES = 2 * 1024 ** 3;
26
+ export const UPGRADE_FLAGS = {
27
+ dir: Flags.string({
28
+ description: 'Installation root managed by app-installer. Defaults to the current directory.',
29
+ }),
30
+ archive: Flags.string({
31
+ description: 'For an installation from a deployment archive: the new archive, built by `pnpm build --tar` for this machine.',
32
+ }),
33
+ to: Flags.string({
34
+ description: 'For a template installation: the version or dist-tag to upgrade to, latest by default, or the installed version with --rebuild. A build of that version already on disk is reused.',
35
+ }),
36
+ 'backup-done': Flags.boolean({
37
+ default: false,
38
+ description: 'Confirm that the databases app-installer cannot back up, any but SQLite, are backed up.',
39
+ }),
40
+ 'health-timeout': Flags.integer({
41
+ default: 180,
42
+ min: 1,
43
+ description: 'Seconds to wait for the new release to answer its health check.',
44
+ }),
45
+ keep: Flags.integer({
46
+ default: 3,
47
+ min: 2,
48
+ description: 'Releases to keep on disk, counting the new one; the one upgraded from is always kept so rollback stays possible.',
49
+ }),
50
+ 'keep-source': Flags.boolean({
51
+ default: false,
52
+ description: 'For a template installation: keep the build directory with the sources and development dependencies.',
53
+ }),
54
+ rebuild: Flags.boolean({
55
+ default: false,
56
+ description: 'For a template installation: build the target again even when a build of it is on disk, the running version included: for a machine whose Node major changed and has no newer version to upgrade to.',
57
+ }),
58
+ yes: Flags.boolean({
59
+ default: false,
60
+ description: 'Proceed without asking for confirmation.',
61
+ }),
62
+ json: Flags.boolean({
63
+ default: false,
64
+ description: 'Print one JSON result on stdout; progress stays on stderr.',
65
+ }),
66
+ };
67
+ /**
68
+ * The installation was interrupted by a previous operation that never finished; refuse to stack another on top. The one
69
+ * operation that may follow an interrupted rebuild is the rebuild again, with `resumeRebuild`: it builds a fresh release
70
+ * and switches to it, which `rollback` cannot do when the release it would return to was built for another Node.
71
+ */
72
+ export function assertNoPending(state, root, resumeRebuild = false) {
73
+ if (!state.pending)
74
+ return;
75
+ if (state.pending.rebuild && resumeRebuild)
76
+ return;
77
+ const { action, from, to, startedAt } = state.pending;
78
+ throw new InstallerError('OPERATION_INTERRUPTED', `${state.pending.rebuild ? 'A rebuild of' : action === 'upgrade' ? 'An upgrade from' : 'A rollback from'} ${from}${state.pending.rebuild ? '' : ` to ${to}`}, started ${startedAt}, did not finish; ${subjectOf(state)} may be stopped or half-switched.`, {
79
+ exitCode: EXIT_INVALID,
80
+ suggestions: [
81
+ state.pending.rebuild
82
+ ? {
83
+ message: 'Run the rebuild again; it builds a fresh release and switches to it:',
84
+ run: installerCommandLine(['upgrade', '--dir', root, '--rebuild'], { registry: state.registry }),
85
+ }
86
+ : {
87
+ message: 'Recover first; an interrupted upgrade is undone and an interrupted rollback is finished:',
88
+ run: installerCommandLine(['rollback', '--dir', root], {
89
+ registry: state.registry,
90
+ }),
91
+ },
92
+ ],
93
+ });
94
+ }
95
+ /**
96
+ * Keeps the newest `keep` releases by install time. `protect` — the current release and the one it was upgraded from,
97
+ * which `rollback` returns to — is never pruned, whatever its install time.
98
+ */
99
+ export function releasesToPrune(releases, protect, keep) {
100
+ const newestFirst = [...releases].sort((a, b) => b.installedAt.localeCompare(a.installedAt));
101
+ const kept = new Set(protect);
102
+ for (const record of newestFirst) {
103
+ if (kept.size >= keep)
104
+ break;
105
+ kept.add(record.id);
106
+ }
107
+ return releases.filter((record) => !kept.has(record.id));
108
+ }
109
+ /** Whether a recorded release can run here: built for this platform, architecture and Node major. */
110
+ function fitsMachine(record) {
111
+ try {
112
+ verifyBuildTarget(record.buildTarget);
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /** The newest build of `version` on disk that can run here, which an upgrade to that version reuses. */
120
+ function reusableBuild(state, layout, version) {
121
+ return [...state.releases]
122
+ .filter((record) => record.version === version &&
123
+ existsSync(releaseDir(layout, record.id)) &&
124
+ fitsMachine(record))
125
+ .sort((a, b) => b.builtAt.localeCompare(a.builtAt))[0];
126
+ }
127
+ function recordOf(prepared) {
128
+ return {
129
+ id: prepared.id,
130
+ version: prepared.version,
131
+ builtAt: prepared.builtAt,
132
+ installedAt: new Date().toISOString(),
133
+ buildTarget: prepared.buildTarget,
134
+ ...(prepared.relocatable
135
+ ? { relocatable: true }
136
+ : { basePath: prepared.basePath }),
137
+ };
138
+ }
139
+ function replaceRecord(state, record) {
140
+ state.releases = [
141
+ ...state.releases.filter((entry) => entry.id !== record.id),
142
+ record,
143
+ ];
144
+ }
145
+ async function checkFreeSpace(root) {
146
+ const stats = await statfs(root);
147
+ const free = stats.bavail * stats.bsize;
148
+ if (free < MINIMUM_FREE_BYTES) {
149
+ throw new InstallerError('DISK_LOW', `${root} has ${Math.round(free / 1024 ** 2)} MB free; building a release needs about ${MINIMUM_FREE_BYTES / 1024 ** 3} GB.`, {
150
+ exitCode: EXIT_INVALID,
151
+ suggestions: [
152
+ {
153
+ message: 'Free some space, or lower --keep to prune old releases on the next upgrade.',
154
+ },
155
+ ],
156
+ });
157
+ }
158
+ }
159
+ /**
160
+ * An upgrade takes the kind of source the install did: a template installation moves between template versions, and
161
+ * one installed from an archive moves to another archive.
162
+ */
163
+ function checkUpgradeSource(state, root, flags) {
164
+ const template = templateOf(state);
165
+ if (template && flags.archive !== undefined) {
166
+ throw new InstallerError('INVALID_USAGE', `${root} was installed from the ${template.name} template, which upgrades to a template version with --to, not to an archive.`, { exitCode: EXIT_INVALID });
167
+ }
168
+ if (!template) {
169
+ if (flags.archive === undefined) {
170
+ throw new InstallerError('INVALID_USAGE', `${root} was installed from a deployment archive; pass the new one with --archive.`, {
171
+ exitCode: EXIT_INVALID,
172
+ // No `run`: a suggestion's command runs as printed, and the archive's path is not known here.
173
+ suggestions: [
174
+ {
175
+ message: `Build it for this machine in the application project, copy it to the server, then run: ${installerCommand(`upgrade --dir ${quoteForShell(root)} --archive <the copied archive>`, { registry: state.registry })}`,
176
+ },
177
+ ],
178
+ });
179
+ }
180
+ const templateOnly = [
181
+ flags.to !== undefined && '--to',
182
+ flags.rebuild && '--rebuild',
183
+ flags['keep-source'] && '--keep-source',
184
+ ].filter(Boolean);
185
+ if (templateOnly.length > 0) {
186
+ throw new InstallerError('INVALID_USAGE', `${templateOnly.join(', ')} apply to a template installation; ${root} upgrades from the archive given with --archive.`, { exitCode: EXIT_INVALID });
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * The archive must be a later build of the same application: its package name owns the migration history in the
192
+ * database. An archive from before relocatable builds also has its base path compiled into the client, so it has to
193
+ * be the path the installation serves now.
194
+ */
195
+ function checkArchiveMatches(state, prepared, env) {
196
+ if (prepared.appName !== state.appName) {
197
+ throw new InstallerError('APP_MISMATCH', `The archive holds ${prepared.appName}, but this installation runs ${state.appName}; a different application has a migration history of its own.`, {
198
+ exitCode: EXIT_INVALID,
199
+ suggestions: [
200
+ {
201
+ message: 'Install a different application into a directory of its own with install --archive.',
202
+ },
203
+ ],
204
+ });
205
+ }
206
+ if (!prepared.relocatable) {
207
+ assertFixedMountPath('The archive', prepared.basePath, mountPathOf(env));
208
+ }
209
+ }
210
+ /**
211
+ * Undoes an upgrade that failed after the switch: the new release is stopped, `current` goes back, the databases are
212
+ * restored when the new release may have migrated them, and the previous release is started again. Always throws: exit
213
+ * 3 when the previous release is healthy again, exit 4 when it is not.
214
+ *
215
+ * When the previous release does not come back, the operation stays pending and the new release stays on disk and on
216
+ * record: it may be the only one that can run, as when the machine's Node major changed, and `rollback` recovers from
217
+ * the pending state.
218
+ */
219
+ async function rollBackUpgrade(context) {
220
+ const { layout, state, service, from, to, backup } = context;
221
+ const subject = subjectOf(state);
222
+ const reason = context.cause instanceof Error
223
+ ? context.cause.message
224
+ : String(context.cause);
225
+ await service.pm2.remove(service.name).catch(() => undefined);
226
+ const log = await errorLogTail(layout);
227
+ const hasDatabase = backup.databases.some((database) => database.files.length > 0);
228
+ let databaseRestored = false;
229
+ let healthy = false;
230
+ let rollbackError;
231
+ try {
232
+ await switchCurrent(layout, releaseLinkTarget(from.id));
233
+ if (context.pending > 0 && hasDatabase) {
234
+ await restoreDatabase(layout, backup.relative);
235
+ databaseRestored = true;
236
+ }
237
+ healthy = await startApp({ ...service, timeoutMs: context.timeoutMs });
238
+ }
239
+ catch (error) {
240
+ rollbackError = error;
241
+ }
242
+ const kept = context.newRecord !== undefined;
243
+ if (healthy) {
244
+ if (kept) {
245
+ await rm(path.dirname(releaseDir(layout, to.id)), {
246
+ recursive: true,
247
+ force: true,
248
+ });
249
+ }
250
+ delete state.pending;
251
+ }
252
+ else if (kept) {
253
+ replaceRecord(state, context.newRecord);
254
+ }
255
+ state.history.push({
256
+ action: 'upgrade',
257
+ from: from.id,
258
+ to: to.id,
259
+ at: new Date().toISOString(),
260
+ outcome: 'rolled-back',
261
+ migrations: context.pending,
262
+ backup: backup.relative,
263
+ databaseRestored,
264
+ ...(context.rebuild ? { rebuild: true } : {}),
265
+ });
266
+ await writeState(layout, state);
267
+ const doing = context.rebuild
268
+ ? `Rebuilding ${to.version}`
269
+ : `Upgrading to ${to.id}`;
270
+ const externalDatabaseNote = context.pending > 0 && !hasDatabase
271
+ ? ` ${to.id} may already have migrated the external database; restore it from your own backup if ${from.id} misbehaves.`
272
+ : '';
273
+ if (healthy) {
274
+ throw new InstallerError('UPGRADE_ROLLED_BACK', `${doing} failed (${reason}). ${from.id} is running again${databaseRestored ? ' on the databases restored from the backup' : ''}.${externalDatabaseNote}`, {
275
+ exitCode: EXIT_ROLLED_BACK,
276
+ details: { log, backup: backup.relative, databaseRestored },
277
+ suggestions: [
278
+ {
279
+ message: 'The failed release logged:',
280
+ run: errorLogCommandLine(layout),
281
+ },
282
+ ],
283
+ });
284
+ }
285
+ throw new InstallerError('ROLLBACK_FAILED', `${doing} failed (${reason}), and ${from.id} did not come back either${rollbackError instanceof Error ? ` (${rollbackError.message})` : ''}. ${capitalize(subject)} is down.`, {
286
+ exitCode: EXIT_ROLLBACK_FAILED,
287
+ details: { log, backup: backup.relative, databaseRestored },
288
+ suggestions: [
289
+ {
290
+ message: 'Read the error log:',
291
+ run: errorLogCommandLine(layout),
292
+ },
293
+ ...(hasDatabase && !databaseRestored
294
+ ? [
295
+ {
296
+ message: `The databases from before the upgrade are in ${path.join(layout.root, backup.relative)}; the rollback below restores them from there.`,
297
+ },
298
+ ]
299
+ : []),
300
+ {
301
+ message: 'Once the cause is fixed, finish the rollback; it restores the databases from the backup if needed:',
302
+ run: installerCommandLine(['rollback', '--dir', layout.root], {
303
+ registry: state.registry,
304
+ }),
305
+ },
306
+ ...(kept
307
+ ? [
308
+ {
309
+ message: `Or return to ${to.id}, which was kept:`,
310
+ run: installerCommandLine([
311
+ 'rollback',
312
+ '--dir',
313
+ layout.root,
314
+ '--to',
315
+ to.id,
316
+ '--no-restore',
317
+ ], { registry: state.registry }),
318
+ },
319
+ ]
320
+ : []),
321
+ ],
322
+ });
323
+ }
324
+ export async function upgrade(input, deps) {
325
+ const { flags } = input;
326
+ const { reporter, pm2 } = deps;
327
+ const root = path.resolve(deps.cwd ?? process.cwd(), flags.dir ?? '.');
328
+ const layout = layoutOf(root);
329
+ const run = deps.run ?? runCommand;
330
+ const releaseLock = await acquireLock(layout.lockFile);
331
+ try {
332
+ // Read under the lock: a run that waited on it must see what the previous one wrote.
333
+ const state = await readState(layout);
334
+ assertNoPending(state, root, flags.rebuild);
335
+ const subject = subjectOf(state);
336
+ const template = templateOf(state);
337
+ checkUpgradeSource(state, root, flags);
338
+ const env = await readAppEnv(layout);
339
+ const from = findRelease(state, state.current);
340
+ if (!from) {
341
+ throw new InstallerError('RELEASE_MISSING', `installer.json names ${state.current} as the running release but holds no record of it.`, { exitCode: EXIT_INVALID });
342
+ }
343
+ if (state.pending?.rebuild) {
344
+ reporter.progress(`Starting the rebuild of ${from.version} again, after the one that started ${state.pending.startedAt} was interrupted`);
345
+ const orphan = state.pending.to;
346
+ if (!findRelease(state, orphan)) {
347
+ await rm(path.dirname(releaseDir(layout, orphan)), {
348
+ recursive: true,
349
+ force: true,
350
+ });
351
+ }
352
+ delete state.pending;
353
+ }
354
+ const machineMajor = currentNodeMajor();
355
+ const nodeChanged = from.buildTarget.nodeMajor !== machineMajor;
356
+ const noop = (warning) => {
357
+ if (warning)
358
+ reporter.warn(warning);
359
+ return {
360
+ status: 'success-noop',
361
+ result: {
362
+ directory: root,
363
+ current: from.id,
364
+ version: from.version,
365
+ upgraded: false,
366
+ nodeMatches: !nodeChanged,
367
+ },
368
+ summary: [
369
+ `${capitalize(subject)} is already on ${from.version} (${from.id}).`,
370
+ ...(warning ? [` ${warning}`] : []),
371
+ ],
372
+ };
373
+ };
374
+ const refuseDowngrade = (version) => {
375
+ throw new InstallerError('DOWNGRADE', `${version} is older than the running ${from.version}. An older release does not know the newer migrations and would run on a schema it does not understand.`, {
376
+ exitCode: EXIT_INVALID,
377
+ suggestions: [
378
+ {
379
+ message: 'Go back with rollback, which restores the databases backed up before the upgrade:',
380
+ run: installerCommandLine(['rollback', '--dir', root, '--to', version], { registry: state.registry }),
381
+ },
382
+ ],
383
+ });
384
+ };
385
+ const url = healthUrl(env);
386
+ const service = {
387
+ layout,
388
+ pm2,
389
+ name: state.name,
390
+ healthUrl: url,
391
+ fetchImpl: deps.fetchImpl,
392
+ };
393
+ checkPlatform();
394
+ await checkPm2(pm2);
395
+ await checkPm2Ownership(service);
396
+ // What to switch to. A template installation names a version and builds it after the prompt, since that takes
397
+ // minutes; an archive has to be unpacked first, because only its manifest says which release it is.
398
+ let targetVersion;
399
+ let reuse;
400
+ let prepared;
401
+ let rebuildCurrent = false;
402
+ if (template) {
403
+ // `--rebuild` alone means the installed version: a newer one would be an upgrade, which builds for this machine anyway.
404
+ const requested = flags.to ?? (flags.rebuild ? from.version : 'latest');
405
+ targetVersion = state.releases.some((record) => record.version === requested)
406
+ ? requested
407
+ : await resolveTemplateVersion(state.registry, template.package, requested, deps.fetchImpl);
408
+ if (targetVersion === from.version && !flags.rebuild) {
409
+ return noop(nodeChanged
410
+ ? `${from.id} was built for Node ${from.buildTarget.nodeMajor}, but this machine runs Node ${machineMajor}; build it again for this machine with \`${installerCommand(`upgrade --dir ${quoteForShell(root)} --rebuild`, { registry: state.registry })}\`.`
411
+ : undefined);
412
+ }
413
+ if ((compareVersions(targetVersion, from.version) ?? 0) < 0) {
414
+ refuseDowngrade(targetVersion);
415
+ }
416
+ // Building the running version again, for this machine: it becomes a release of its own, switched to like any other.
417
+ rebuildCurrent = flags.rebuild && targetVersion === from.version;
418
+ reuse = flags.rebuild
419
+ ? undefined
420
+ : reusableBuild(state, layout, targetVersion);
421
+ if (!reuse) {
422
+ await checkPnpm(run);
423
+ await checkFreeSpace(root);
424
+ }
425
+ }
426
+ else {
427
+ const archive = resolveArchivePath(deps.cwd ?? process.cwd(), flags.archive);
428
+ reporter.progress(`Unpacking ${archive}`);
429
+ const unpacked = await unpackRelease({
430
+ layout,
431
+ archive,
432
+ });
433
+ const discard = async () => {
434
+ if (!unpacked.reused) {
435
+ await rm(path.dirname(unpacked.dir), {
436
+ recursive: true,
437
+ force: true,
438
+ });
439
+ }
440
+ };
441
+ try {
442
+ checkArchiveMatches(state, unpacked, env);
443
+ checkArchiveDriver(unpacked.dir, state.dialect);
444
+ if (unpacked.id === from.id) {
445
+ return noop();
446
+ }
447
+ if ((compareVersions(unpacked.version, from.version) ?? 0) < 0) {
448
+ refuseDowngrade(unpacked.version);
449
+ }
450
+ }
451
+ catch (error) {
452
+ await discard();
453
+ throw error;
454
+ }
455
+ targetVersion = unpacked.version;
456
+ reuse = unpacked.reused ? findRelease(state, unpacked.id) : undefined;
457
+ prepared = reuse ? undefined : unpacked;
458
+ }
459
+ const inventory = readDatabaseInventory(layout, env);
460
+ const unprotected = [...inventory.external, ...inventory.unresolved];
461
+ const hosts = hostsApplications(state);
462
+ const discardPrepared = async () => {
463
+ if (prepared) {
464
+ await rm(path.dirname(prepared.dir), { recursive: true, force: true });
465
+ }
466
+ };
467
+ try {
468
+ if (unprotected.length > 0 && !flags['backup-done']) {
469
+ throw new InstallerError('BACKUP_REQUIRED', `app-installer cannot back up the database connection${unprotected.length === 1 ? '' : 's'} ${unprotected.join(', ')}. Back ${unprotected.length === 1 ? 'it' : 'them'} up, then pass --backup-done.`, { exitCode: EXIT_INVALID });
470
+ }
471
+ await confirm([
472
+ template && rebuildCurrent
473
+ ? `Build ${from.version} again for this machine (Node ${machineMajor}) and switch ${subject} at ${root} to the new build.`
474
+ : `Upgrade ${subject} at ${root} from ${from.id} to ${prepared?.id ?? reuse?.id ?? targetVersion}.`,
475
+ hosts
476
+ ? `${capitalize(subject)} and every application it hosts stop while the release switches; deployments in progress are marked failed.`
477
+ : `${capitalize(subject)} stops while the release switches.`,
478
+ inventory.sqlite.length > 0
479
+ ? 'The SQLite databases and the configuration are copied to backups/ before anything is migrated.'
480
+ : 'The configuration is copied to backups/ before anything is migrated.',
481
+ ...(unprotected.length > 0
482
+ ? [
483
+ `You confirmed with --backup-done that ${unprotected.join(', ')} ${unprotected.length === 1 ? 'is' : 'are'} backed up.`,
484
+ ]
485
+ : []),
486
+ ...(nodeChanged
487
+ ? [
488
+ `This machine runs Node ${machineMajor}, but ${from.id} was built for Node ${from.buildTarget.nodeMajor}: it cannot be rolled back to${hosts ? `, and hosted applications must be rebuilt with --node-version ${machineMajor}` : ''}.`,
489
+ ]
490
+ : []),
491
+ ], { yes: flags.yes, json: flags.json });
492
+ }
493
+ catch (error) {
494
+ await discardPrepared();
495
+ throw error;
496
+ }
497
+ // Everything up to the stop happens while the current release keeps serving.
498
+ let to;
499
+ let newRecord;
500
+ if (reuse) {
501
+ reporter.progress(`Reusing the ${reuse.id} release already on disk`);
502
+ to = reuse;
503
+ }
504
+ else {
505
+ prepared ??= await buildFromTemplate({
506
+ layout,
507
+ template: template,
508
+ version: targetVersion,
509
+ registry: state.registry,
510
+ drivers: state.drivers,
511
+ keepSource: flags['keep-source'],
512
+ reporter,
513
+ run,
514
+ });
515
+ to = recordOf(prepared);
516
+ newRecord = to;
517
+ }
518
+ // A release built before relocatable builds, reused or just built from an older template version, runs only at
519
+ // the path it was built for.
520
+ const fixedMountPath = fixedMountPathOf(to, state);
521
+ if (fixedMountPath !== undefined) {
522
+ try {
523
+ assertFixedMountPath(to.id, fixedMountPath, mountPathOf(env));
524
+ }
525
+ catch (error) {
526
+ if (newRecord) {
527
+ await rm(path.dirname(releaseDir(layout, to.id)), {
528
+ recursive: true,
529
+ force: true,
530
+ });
531
+ }
532
+ throw error;
533
+ }
534
+ }
535
+ const dir = releaseDir(layout, to.id);
536
+ const removeNewRelease = async () => {
537
+ if (newRecord) {
538
+ await rm(path.dirname(dir), { recursive: true, force: true });
539
+ }
540
+ };
541
+ const cli = { releaseDir: dir, cwd: root, env, run };
542
+ let pending;
543
+ try {
544
+ reporter.progress(`Checking the configuration and pending migrations with ${to.id}`);
545
+ await runAppCli(['config', 'check'], cli);
546
+ pending = pendingTaskCount(await runAppCli(['db', 'apply', '--dry-run'], cli));
547
+ }
548
+ catch (error) {
549
+ await removeNewRelease();
550
+ throw error;
551
+ }
552
+ reporter.progress(pending > 0
553
+ ? `${to.id} will apply ${pending} migration and seed task(s)`
554
+ : `${to.id} has no pending migrations`);
555
+ const name = backupName(from.id, to.id, new Date());
556
+ const backupRelative = path.join('backups', name);
557
+ state.pending = {
558
+ action: 'upgrade',
559
+ from: from.id,
560
+ to: to.id,
561
+ startedAt: new Date().toISOString(),
562
+ ...(rebuildCurrent ? { rebuild: true } : {}),
563
+ };
564
+ await writeState(layout, state);
565
+ // Downtime starts here.
566
+ reporter.progress(`Stopping ${from.id}`);
567
+ let backup;
568
+ try {
569
+ await stopApp(service);
570
+ // Health stopped answering; make sure nothing else holds the port the new release has to bind.
571
+ await checkPortFree(env.APP_SERVER_HOST ?? '127.0.0.1', Number(env.APP_SERVER_PORT ?? 13000));
572
+ reporter.progress('Backing up the databases and configuration');
573
+ backup = await backupForUpgrade(layout, name, inventory.sqlite);
574
+ const main = backup.databases.find((database) => database.connection === inventory.defaultConnection);
575
+ if (main && main.files.length === 0) {
576
+ throw new Error(`the SQLite database was not found at ${main.file}`);
577
+ }
578
+ // Recorded only now that it is complete: a recovery must never restore from a backup that was cut short.
579
+ state.pending.backup = backup.relative;
580
+ await writeState(layout, state);
581
+ }
582
+ catch (error) {
583
+ // Nothing was switched: bring the current release back and report the upgrade as not done.
584
+ await removeBackup(layout, backupRelative);
585
+ const healthy = await startApp({
586
+ ...service,
587
+ timeoutMs: flags['health-timeout'] * 1000,
588
+ }).catch(() => false);
589
+ delete state.pending;
590
+ await writeState(layout, state);
591
+ await removeNewRelease();
592
+ throw new InstallerError('UPGRADE_ABORTED', `Upgrading to ${to.id} stopped before switching: ${error instanceof Error ? error.message : String(error)}. ${healthy ? `${from.id} is running again.` : `${from.id} did not start again.`}`, {
593
+ exitCode: healthy ? EXIT_FAILED : EXIT_ROLLBACK_FAILED,
594
+ cause: error,
595
+ suggestions: healthy
596
+ ? []
597
+ : startAdvice(layout, `Start ${from.id} again once the cause is fixed:`),
598
+ });
599
+ }
600
+ try {
601
+ // Marked before the link moves, so an interruption from here on is undone with a database restore.
602
+ state.pending.switched = true;
603
+ await writeState(layout, state);
604
+ await switchCurrent(layout, releaseLinkTarget(to.id));
605
+ reporter.progress('Applying database migrations');
606
+ await runAppCli(['db', 'apply'], cli);
607
+ assertStorageOutsideRelease(dir);
608
+ reporter.progress(`Starting ${to.id}`);
609
+ const healthy = await startApp({
610
+ ...service,
611
+ timeoutMs: flags['health-timeout'] * 1000,
612
+ });
613
+ if (!healthy) {
614
+ throw new Error(`${url} did not become healthy (waited up to ${flags['health-timeout']}s)`);
615
+ }
616
+ }
617
+ catch (error) {
618
+ reporter.progress(`Rolling back to ${from.id}`);
619
+ // Awaited here rather than returned as a promise: the `finally` below awaits the lock before the caller can
620
+ // attach a handler, and a rollback that fails first would be reported as an unhandled rejection.
621
+ return await rollBackUpgrade({
622
+ layout,
623
+ state,
624
+ service,
625
+ from,
626
+ to,
627
+ rebuild: rebuildCurrent,
628
+ pending,
629
+ backup,
630
+ newRecord,
631
+ timeoutMs: flags['health-timeout'] * 1000,
632
+ cause: error,
633
+ });
634
+ }
635
+ // The new release is serving: record that first, so nothing after this can make it look unfinished.
636
+ const at = new Date().toISOString();
637
+ state.current = to.id;
638
+ if (newRecord)
639
+ replaceRecord(state, newRecord);
640
+ // A release whose directory was removed by hand can never be returned to; forget it rather than prune around it.
641
+ state.releases = state.releases.filter((record) => existsSync(releaseDir(layout, record.id)));
642
+ delete state.pending;
643
+ state.history.push({
644
+ action: 'upgrade',
645
+ from: from.id,
646
+ to: to.id,
647
+ at,
648
+ outcome: 'completed',
649
+ migrations: pending,
650
+ backup: backup.relative,
651
+ ...(rebuildCurrent ? { rebuild: true } : {}),
652
+ });
653
+ await writeState(layout, state);
654
+ const pruned = releasesToPrune(state.releases, [to.id, from.id], flags.keep);
655
+ for (const record of pruned) {
656
+ await rm(path.dirname(releaseDir(layout, record.id)), {
657
+ recursive: true,
658
+ force: true,
659
+ });
660
+ }
661
+ if (pruned.length > 0) {
662
+ state.releases = state.releases.filter((record) => !pruned.some((gone) => gone.id === record.id));
663
+ await writeState(layout, state);
664
+ }
665
+ const notes = hosts
666
+ ? [
667
+ 'Deployments that were in progress are marked failed; start them again in the Hub.',
668
+ ...(nodeChanged
669
+ ? [
670
+ `Rebuild hosted applications with --node-version ${machineMajor} before deploying them again.`,
671
+ ]
672
+ : []),
673
+ ]
674
+ : [];
675
+ return {
676
+ status: 'success',
677
+ result: {
678
+ directory: root,
679
+ from: from.id,
680
+ to: to.id,
681
+ fromVersion: from.version,
682
+ toVersion: to.version,
683
+ upgraded: true,
684
+ rebuilt: rebuildCurrent,
685
+ reused: reuse !== undefined,
686
+ migrations: pending,
687
+ backup: backup.relative,
688
+ pruned: pruned.map((record) => record.id),
689
+ notes,
690
+ },
691
+ summary: [
692
+ rebuildCurrent
693
+ ? `Rebuilt ${subject} ${to.version} for Node ${machineMajor}.`
694
+ : `Upgraded ${subject} from ${from.version} to ${to.version}.`,
695
+ ` Release ${to.id}`,
696
+ ` Migrations ${pending}`,
697
+ ` Backup ${backup.relative}`,
698
+ ...(pruned.length > 0
699
+ ? [` Pruned ${pruned.map((record) => record.id).join(', ')}`]
700
+ : []),
701
+ ...notes.map((note) => ` ${note}`),
702
+ ],
703
+ };
704
+ }
705
+ finally {
706
+ await releaseLock();
707
+ }
708
+ }
709
+ //# sourceMappingURL=upgrade.js.map