@nocobase/app-plugin-releases 0.1.0-beta.2 → 0.1.0-beta.20
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.
- package/CHANGELOG.md +341 -0
- package/LICENSE.txt +129 -0
- package/README.md +91 -16
- package/dist/client/components/environment-form.d.ts +1 -1
- package/dist/client/components/environment-form.d.ts.map +1 -1
- package/dist/client/components/environment-form.js +9 -2
- package/dist/client/components/environment-form.js.map +1 -1
- package/dist/client/components/page-header.d.ts.map +1 -1
- package/dist/client/components/page-header.js +1 -1
- package/dist/client/components/page-header.js.map +1 -1
- package/dist/client/components/release-badges.d.ts.map +1 -1
- package/dist/client/components/release-badges.js +13 -1
- package/dist/client/components/release-badges.js.map +1 -1
- package/dist/client/components/route-overlay.d.ts +4 -1
- package/dist/client/components/route-overlay.d.ts.map +1 -1
- package/dist/client/components/route-overlay.js +32 -3
- package/dist/client/components/route-overlay.js.map +1 -1
- package/dist/client/components/runtime-policy-fields.d.ts +7 -1
- package/dist/client/components/runtime-policy-fields.d.ts.map +1 -1
- package/dist/client/components/runtime-policy-fields.js +19 -5
- package/dist/client/components/runtime-policy-fields.js.map +1 -1
- package/dist/client/components/runtime-state.d.ts +6 -0
- package/dist/client/components/runtime-state.d.ts.map +1 -0
- package/dist/client/components/runtime-state.js +67 -0
- package/dist/client/components/runtime-state.js.map +1 -0
- package/dist/client/components/shared-host.d.ts +20 -0
- package/dist/client/components/shared-host.d.ts.map +1 -0
- package/dist/client/components/shared-host.js +88 -0
- package/dist/client/components/shared-host.js.map +1 -0
- package/dist/client/components/ui/progress.d.ts +9 -0
- package/dist/client/components/ui/progress.d.ts.map +1 -0
- package/dist/client/components/ui/progress.js +20 -0
- package/dist/client/components/ui/progress.js.map +1 -0
- package/dist/client/components/use-route-overlay.d.ts +2 -9
- package/dist/client/components/use-route-overlay.d.ts.map +1 -1
- package/dist/client/components/use-route-overlay.js +5 -3
- package/dist/client/components/use-route-overlay.js.map +1 -1
- package/dist/client/components/variable-alerts.d.ts.map +1 -1
- package/dist/client/components/variable-alerts.js +6 -18
- package/dist/client/components/variable-alerts.js.map +1 -1
- package/dist/client/hooks/use-app-summary.d.ts +1 -1
- package/dist/client/hooks/use-app-summary.d.ts.map +1 -1
- package/dist/client/hooks/use-app-summary.js +4 -1
- package/dist/client/hooks/use-app-summary.js.map +1 -1
- package/dist/client/hooks/use-releases.d.ts +4 -1
- package/dist/client/hooks/use-releases.d.ts.map +1 -1
- package/dist/client/hooks/use-releases.js +6 -2
- package/dist/client/hooks/use-releases.js.map +1 -1
- package/dist/client/lib/app-state.d.ts +2 -2
- package/dist/client/lib/app-state.d.ts.map +1 -1
- package/dist/client/lib/app-state.js +10 -3
- package/dist/client/lib/app-state.js.map +1 -1
- package/dist/client/lib/errors.d.ts.map +1 -1
- package/dist/client/lib/errors.js +7 -0
- package/dist/client/lib/errors.js.map +1 -1
- package/dist/client/lib/format.d.ts +2 -0
- package/dist/client/lib/format.d.ts.map +1 -1
- package/dist/client/lib/format.js +7 -0
- package/dist/client/lib/format.js.map +1 -1
- package/dist/client/lib/paths.d.ts +5 -0
- package/dist/client/lib/paths.d.ts.map +1 -1
- package/dist/client/lib/paths.js +10 -0
- package/dist/client/lib/paths.js.map +1 -1
- package/dist/client/lib/permissions.d.ts +5 -0
- package/dist/client/lib/permissions.d.ts.map +1 -1
- package/dist/client/lib/permissions.js +8 -0
- package/dist/client/lib/permissions.js.map +1 -1
- package/dist/client/lib/runtime-state.d.ts +25 -0
- package/dist/client/lib/runtime-state.d.ts.map +1 -0
- package/dist/client/lib/runtime-state.js +58 -0
- package/dist/client/lib/runtime-state.js.map +1 -0
- package/dist/client/locales/de-DE.d.ts +5 -0
- package/dist/client/locales/de-DE.d.ts.map +1 -0
- package/dist/client/locales/de-DE.js +876 -0
- package/dist/client/locales/de-DE.js.map +1 -0
- package/dist/client/locales/en-US.d.ts +83 -9
- package/dist/client/locales/en-US.d.ts.map +1 -1
- package/dist/client/locales/en-US.js +80 -9
- package/dist/client/locales/en-US.js.map +1 -1
- package/dist/client/locales/es-ES.d.ts +5 -0
- package/dist/client/locales/es-ES.d.ts.map +1 -0
- package/dist/client/locales/es-ES.js +882 -0
- package/dist/client/locales/es-ES.js.map +1 -0
- package/dist/client/locales/fr-FR.d.ts +5 -0
- package/dist/client/locales/fr-FR.d.ts.map +1 -0
- package/dist/client/locales/fr-FR.js +882 -0
- package/dist/client/locales/fr-FR.js.map +1 -0
- package/dist/client/locales/id-ID.d.ts +5 -0
- package/dist/client/locales/id-ID.d.ts.map +1 -0
- package/dist/client/locales/id-ID.js +876 -0
- package/dist/client/locales/id-ID.js.map +1 -0
- package/dist/client/locales/index.d.ts.map +1 -1
- package/dist/client/locales/index.js +10 -0
- package/dist/client/locales/index.js.map +1 -1
- package/dist/client/locales/ja-JP.d.ts +5 -0
- package/dist/client/locales/ja-JP.d.ts.map +1 -0
- package/dist/client/locales/ja-JP.js +876 -0
- package/dist/client/locales/ja-JP.js.map +1 -0
- package/dist/client/locales/ko-KR.d.ts +5 -0
- package/dist/client/locales/ko-KR.d.ts.map +1 -0
- package/dist/client/locales/ko-KR.js +879 -0
- package/dist/client/locales/ko-KR.js.map +1 -0
- package/dist/client/locales/pt-BR.d.ts +5 -0
- package/dist/client/locales/pt-BR.d.ts.map +1 -0
- package/dist/client/locales/pt-BR.js +882 -0
- package/dist/client/locales/pt-BR.js.map +1 -0
- package/dist/client/locales/ru-RU.d.ts +5 -0
- package/dist/client/locales/ru-RU.d.ts.map +1 -0
- package/dist/client/locales/ru-RU.js +891 -0
- package/dist/client/locales/ru-RU.js.map +1 -0
- package/dist/client/locales/vi-VN.d.ts +5 -0
- package/dist/client/locales/vi-VN.d.ts.map +1 -0
- package/dist/client/locales/vi-VN.js +876 -0
- package/dist/client/locales/vi-VN.js.map +1 -0
- package/dist/client/locales/zh-CN.d.ts.map +1 -1
- package/dist/client/locales/zh-CN.js +83 -9
- package/dist/client/locales/zh-CN.js.map +1 -1
- package/dist/client/locales/zh-TW.d.ts +5 -0
- package/dist/client/locales/zh-TW.d.ts.map +1 -0
- package/dist/client/locales/zh-TW.js +876 -0
- package/dist/client/locales/zh-TW.js.map +1 -0
- package/dist/client/pages/app-page.d.ts.map +1 -1
- package/dist/client/pages/app-page.js +14 -8
- package/dist/client/pages/app-page.js.map +1 -1
- package/dist/client/pages/apps-outlet.d.ts +8 -0
- package/dist/client/pages/apps-outlet.d.ts.map +1 -0
- package/dist/client/pages/apps-outlet.js +9 -0
- package/dist/client/pages/apps-outlet.js.map +1 -0
- package/dist/client/pages/environment-page.d.ts.map +1 -1
- package/dist/client/pages/environment-page.js +112 -24
- package/dist/client/pages/environment-page.js.map +1 -1
- package/dist/client/pages/environments-page.d.ts +8 -3
- package/dist/client/pages/environments-page.d.ts.map +1 -1
- package/dist/client/pages/environments-page.js +58 -10
- package/dist/client/pages/environments-page.js.map +1 -1
- package/dist/client/pages/new-app-page.d.ts +2 -4
- package/dist/client/pages/new-app-page.d.ts.map +1 -1
- package/dist/client/pages/new-app-page.js +29 -19
- package/dist/client/pages/new-app-page.js.map +1 -1
- package/dist/client/pages/new-app-redirect.d.ts +7 -0
- package/dist/client/pages/new-app-redirect.d.ts.map +1 -0
- package/dist/client/pages/new-app-redirect.js +8 -0
- package/dist/client/pages/new-app-redirect.js.map +1 -0
- package/dist/client/pages/request-page.js +1 -1
- package/dist/client/pages.d.ts +6 -2
- package/dist/client/pages.d.ts.map +1 -1
- package/dist/client/pages.js +8 -4
- package/dist/client/pages.js.map +1 -1
- package/dist/client/routes.d.ts.map +1 -1
- package/dist/client/routes.js +37 -10
- package/dist/client/routes.js.map +1 -1
- package/dist/database/migrations/.manifest.json +10 -2
- package/dist/database/migrations/202610020001_rel_create_tables.js +1 -1
- package/dist/database/migrations/202610020001_rel_create_tables.js.map +1 -1
- package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts +4 -0
- package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts.map +1 -0
- package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js +51 -0
- package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js.map +1 -0
- package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts +8 -0
- package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts.map +1 -0
- package/dist/database/migrations/202611020001_rel_environment_purpose.js +30 -0
- package/dist/database/migrations/202611020001_rel_environment_purpose.js.map +1 -0
- package/dist/server/access/caller.d.ts +6 -0
- package/dist/server/access/caller.d.ts.map +1 -1
- package/dist/server/access/caller.js +36 -0
- package/dist/server/access/caller.js.map +1 -1
- package/dist/server/artifact-config.d.ts +9 -0
- package/dist/server/artifact-config.d.ts.map +1 -0
- package/dist/server/artifact-config.js +49 -0
- package/dist/server/artifact-config.js.map +1 -0
- package/dist/server/composition.d.ts +6 -0
- package/dist/server/composition.d.ts.map +1 -1
- package/dist/server/composition.js +7 -1
- package/dist/server/composition.js.map +1 -1
- package/dist/server/config.d.ts +18 -2
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/drivers/host/driver.d.ts +13 -2
- package/dist/server/drivers/host/driver.d.ts.map +1 -1
- package/dist/server/drivers/host/driver.js +427 -81
- package/dist/server/drivers/host/driver.js.map +1 -1
- package/dist/server/drivers/types.d.ts +79 -1
- package/dist/server/drivers/types.d.ts.map +1 -1
- package/dist/server/drivers/types.js +6 -0
- package/dist/server/drivers/types.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/locales/de-DE.d.ts +5 -0
- package/dist/server/locales/de-DE.d.ts.map +1 -0
- package/dist/server/locales/de-DE.js +133 -0
- package/dist/server/locales/de-DE.js.map +1 -0
- package/dist/server/locales/es-ES.d.ts +5 -0
- package/dist/server/locales/es-ES.d.ts.map +1 -0
- package/dist/server/locales/es-ES.js +133 -0
- package/dist/server/locales/es-ES.js.map +1 -0
- package/dist/server/locales/fr-FR.d.ts +5 -0
- package/dist/server/locales/fr-FR.d.ts.map +1 -0
- package/dist/server/locales/fr-FR.js +133 -0
- package/dist/server/locales/fr-FR.js.map +1 -0
- package/dist/server/locales/id-ID.d.ts +5 -0
- package/dist/server/locales/id-ID.d.ts.map +1 -0
- package/dist/server/locales/id-ID.js +133 -0
- package/dist/server/locales/id-ID.js.map +1 -0
- package/dist/server/locales/index.d.ts.map +1 -1
- package/dist/server/locales/index.js +10 -0
- package/dist/server/locales/index.js.map +1 -1
- package/dist/server/locales/ja-JP.d.ts +5 -0
- package/dist/server/locales/ja-JP.d.ts.map +1 -0
- package/dist/server/locales/ja-JP.js +133 -0
- package/dist/server/locales/ja-JP.js.map +1 -0
- package/dist/server/locales/ko-KR.d.ts +5 -0
- package/dist/server/locales/ko-KR.d.ts.map +1 -0
- package/dist/server/locales/ko-KR.js +133 -0
- package/dist/server/locales/ko-KR.js.map +1 -0
- package/dist/server/locales/pt-BR.d.ts +5 -0
- package/dist/server/locales/pt-BR.d.ts.map +1 -0
- package/dist/server/locales/pt-BR.js +133 -0
- package/dist/server/locales/pt-BR.js.map +1 -0
- package/dist/server/locales/ru-RU.d.ts +5 -0
- package/dist/server/locales/ru-RU.d.ts.map +1 -0
- package/dist/server/locales/ru-RU.js +133 -0
- package/dist/server/locales/ru-RU.js.map +1 -0
- package/dist/server/locales/vi-VN.d.ts +5 -0
- package/dist/server/locales/vi-VN.d.ts.map +1 -0
- package/dist/server/locales/vi-VN.js +133 -0
- package/dist/server/locales/vi-VN.js.map +1 -0
- package/dist/server/locales/zh-TW.d.ts +5 -0
- package/dist/server/locales/zh-TW.d.ts.map +1 -0
- package/dist/server/locales/zh-TW.js +133 -0
- package/dist/server/locales/zh-TW.js.map +1 -0
- package/dist/server/providers/releases.d.ts.map +1 -1
- package/dist/server/providers/releases.js +12 -5
- package/dist/server/providers/releases.js.map +1 -1
- package/dist/server/routes/api.d.ts +1 -1
- package/dist/server/routes/api.d.ts.map +1 -1
- package/dist/server/routes/api.js +120 -20
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +10 -2
- package/dist/server/routes/index.js.map +1 -1
- package/dist/server/routes/schemas.d.ts +7 -2
- package/dist/server/routes/schemas.d.ts.map +1 -1
- package/dist/server/routes/schemas.js +320 -89
- package/dist/server/routes/schemas.js.map +1 -1
- package/dist/server/services/environments.d.ts +2 -2
- package/dist/server/services/environments.d.ts.map +1 -1
- package/dist/server/services/environments.js +21 -1
- package/dist/server/services/environments.js.map +1 -1
- package/dist/server/services/releases.d.ts +69 -8
- package/dist/server/services/releases.d.ts.map +1 -1
- package/dist/server/services/releases.js +574 -128
- package/dist/server/services/releases.js.map +1 -1
- package/dist/server/services/validation.d.ts +2 -0
- package/dist/server/services/validation.d.ts.map +1 -1
- package/dist/server/services/validation.js +8 -0
- package/dist/server/services/validation.js.map +1 -1
- package/dist/server/tokens.d.ts +15 -2
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js.map +1 -1
- package/dist/shared/releases.d.ts +94 -3
- package/dist/shared/releases.d.ts.map +1 -1
- package/dist/shared/releases.js +37 -0
- package/dist/shared/releases.js.map +1 -1
- package/package.json +18 -18
- package/dist/client/pages/apps-page.d.ts +0 -4
- package/dist/client/pages/apps-page.d.ts.map +0 -1
- package/dist/client/pages/apps-page.js +0 -116
- package/dist/client/pages/apps-page.js.map +0 -1
|
@@ -13,9 +13,9 @@ import { createDriveManager, } from '@nocobase/drive';
|
|
|
13
13
|
import { appendJournal, createDiagnosticLogger, pruneJournals, readJournal, reportLoggingFailure, } from '@nocobase/logging';
|
|
14
14
|
import { parse as parseYaml } from 'yaml';
|
|
15
15
|
import { BUSINESS_ACTIONS } from '../../shared/access.js';
|
|
16
|
-
import { RESERVED_VARIABLE_NAMES } from '../../shared/releases.js';
|
|
16
|
+
import { RESERVED_VARIABLE_NAMES, runStateOf, } from '../../shared/releases.js';
|
|
17
17
|
import { SYSTEM_CALLER, actorOf, } from '../access/caller.js';
|
|
18
|
-
import { capabilitiesFor, } from '../drivers/types.js';
|
|
18
|
+
import { capabilitiesFor, UNRESTORED_APP_ERROR, } from '../drivers/types.js';
|
|
19
19
|
import { ReleasesError, forbidden, notFound } from '../errors.js';
|
|
20
20
|
import { RELEASE_VERSION_PATTERN, assertMountableAt, inspectArtifact, receiveArtifact, validateIdempotencyKey, } from './artifact.js';
|
|
21
21
|
import { decodeDate, decodeLabels, decodeOptionalDate, decodeRecord, iso, matchesLabels, nullableNumber, nullableString, } from './codec.js';
|
|
@@ -24,10 +24,17 @@ import { maskConfig, restoreConfigSecrets } from './config-secrets.js';
|
|
|
24
24
|
import { ReleaseArtifactStore, normalizeImage, } from './release-artifacts.js';
|
|
25
25
|
import { decryptText, encryptText } from './secrets.js';
|
|
26
26
|
import { DEFAULT_INITIAL_ADMIN, INITIAL_ADMIN_PATHS, PUBLIC_ORIGIN_PATH, SAMPLE_DATA_PATH, appVariableView, assertVariableName, assertVariableValue, configValueAt, decodeVariablesManifest, generateVariable, hasRealValue, isSecretName, parseVariablesManifest, randomPassword, resolveVariables, } from './variables.js';
|
|
27
|
-
import { APP_ID_PATTERN, assertAppId, assertPagination, normalizeLabels, normalizeName, normalizeRuntimePolicy, } from './validation.js';
|
|
27
|
+
import { APP_ID_PATTERN, assertAppId, assertPagination, normalizeLabels, normalizeName, normalizeRuntimePolicy, releaseArtifactKey, } from './validation.js';
|
|
28
28
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
29
|
+
/** `<appId>-<version>.tar.gz`, with anything but letters, digits, `.`, `_` and `-` replaced. */
|
|
30
|
+
export function archiveFileName(appId, version) {
|
|
31
|
+
const safe = (text) => text.replace(/[^A-Za-z0-9._-]+/gu, '_').replace(/^\.+/u, '') || 'release';
|
|
32
|
+
return `${safe(appId)}-${safe(version)}.tar.gz`;
|
|
33
|
+
}
|
|
29
34
|
export class ReleasesService {
|
|
30
35
|
options;
|
|
36
|
+
dispatch = new Map();
|
|
37
|
+
progress = new Map();
|
|
31
38
|
diagnostic;
|
|
32
39
|
disk;
|
|
33
40
|
artifacts;
|
|
@@ -38,6 +45,9 @@ export class ReleasesService {
|
|
|
38
45
|
runs = new Map();
|
|
39
46
|
restoring = null;
|
|
40
47
|
closed = false;
|
|
48
|
+
artifactKey(appId, releaseId) {
|
|
49
|
+
return releaseArtifactKey(appId, releaseId, this.options.artifactPrefix);
|
|
50
|
+
}
|
|
41
51
|
constructor(options) {
|
|
42
52
|
this.options = options;
|
|
43
53
|
this.diagnostic = createDiagnosticLogger(options.logger);
|
|
@@ -72,8 +82,12 @@ export class ReleasesService {
|
|
|
72
82
|
query = query.where((eb) => searchIds.length > 0
|
|
73
83
|
? eb('id', 'in', [...searchIds])
|
|
74
84
|
: eb('id', 'is', null));
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
const environmentIds = [
|
|
86
|
+
...(options.environmentId ? [options.environmentId] : []),
|
|
87
|
+
...(options.environmentIds ?? []),
|
|
88
|
+
];
|
|
89
|
+
if (environmentIds.length > 0)
|
|
90
|
+
query = query.where('environmentId', 'in', [...new Set(environmentIds)]);
|
|
77
91
|
const only = filter.only;
|
|
78
92
|
if (only)
|
|
79
93
|
query = query.where((eb) => only.length > 0 ? eb('id', 'in', [...only]) : eb('id', 'is', null));
|
|
@@ -85,13 +99,27 @@ export class ReleasesService {
|
|
|
85
99
|
]));
|
|
86
100
|
}
|
|
87
101
|
// Labels are opaque JSON, matched here rather than in SQL.
|
|
88
|
-
|
|
102
|
+
let rows = (await query.execute())
|
|
89
103
|
.map(decodeApp)
|
|
90
104
|
.filter((app) => matchesLabels(app.labels, options.labels));
|
|
105
|
+
// A state filter needs every candidate's runtime before the page is cut: one batched status call per environment,
|
|
106
|
+
// reused for the page's summaries.
|
|
107
|
+
let statuses;
|
|
108
|
+
if (options.runStates?.length) {
|
|
109
|
+
const wanted = new Set(options.runStates);
|
|
110
|
+
statuses = await this.runtimeStatuses(rows);
|
|
111
|
+
const known = statuses;
|
|
112
|
+
rows = rows.filter((app) => wanted.has(runStateOf(runtimeStatus(known.get(app.environmentId), app.id))));
|
|
113
|
+
}
|
|
91
114
|
const total = rows.length;
|
|
92
115
|
const page = Math.min(requestedPage, Math.max(1, Math.ceil(total / pageSize)));
|
|
93
116
|
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
|
|
94
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
items: await this.summarize(slice, statuses),
|
|
119
|
+
total,
|
|
120
|
+
page,
|
|
121
|
+
pageSize,
|
|
122
|
+
};
|
|
95
123
|
}
|
|
96
124
|
async getApp(caller, appId) {
|
|
97
125
|
const app = await this.requireApp(appId);
|
|
@@ -101,10 +129,21 @@ export class ReleasesService {
|
|
|
101
129
|
if (action !== 'create' &&
|
|
102
130
|
(await this.options.guard.canApp(caller, action, app)))
|
|
103
131
|
allowed.push(action);
|
|
104
|
-
const variables = await
|
|
132
|
+
const [variables, shared] = await Promise.all([
|
|
133
|
+
this.appVariablesOf(app).catch(() => null),
|
|
134
|
+
this.hostOf(caller, app).catch(() => null),
|
|
135
|
+
]);
|
|
136
|
+
const summary = (await this.summarize([app]))[0];
|
|
137
|
+
// A driver reporting an environment's restore error on its Apps would reveal the others it names: it is the
|
|
138
|
+
// environment's diagnosis, for those who may read it (`hostOf`).
|
|
139
|
+
const runtime = summary.runtime?.error && shared?.hiddenErrors.has(summary.runtime.error)
|
|
140
|
+
? { ...summary.runtime, error: UNRESTORED_APP_ERROR }
|
|
141
|
+
: summary.runtime;
|
|
105
142
|
return {
|
|
106
|
-
...
|
|
143
|
+
...summary,
|
|
144
|
+
runtime,
|
|
107
145
|
allowed,
|
|
146
|
+
host: shared?.view ?? null,
|
|
108
147
|
...(variables
|
|
109
148
|
? {
|
|
110
149
|
variables: {
|
|
@@ -115,7 +154,97 @@ export class ReleasesService {
|
|
|
115
154
|
: {}),
|
|
116
155
|
};
|
|
117
156
|
}
|
|
118
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The runtime the App shares: one status call to the driver. Its counts and memory are totals of the whole runtime;
|
|
159
|
+
* no other App is named, and an environment's restore error only reaches those who manage environments.
|
|
160
|
+
*/
|
|
161
|
+
async hostOf(caller, app) {
|
|
162
|
+
const session = await this.session(app.environmentId);
|
|
163
|
+
const occupancy = (await session.occupancy?.()) ?? null;
|
|
164
|
+
if (!occupancy)
|
|
165
|
+
return null;
|
|
166
|
+
return await this.hostView(caller, occupancy);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The runtimes the environments with Apps share, each once however many environments run on it, for the
|
|
170
|
+
* environments page: one driver status call per such environment, then what `hostOf` reads per runtime. Requires the
|
|
171
|
+
* `rel.environments` `read` setting; restore errors are withheld as on an App's page.
|
|
172
|
+
*/
|
|
173
|
+
async listHosts(caller) {
|
|
174
|
+
this.options.guard.requireSetting(caller, 'rel.environments', 'read');
|
|
175
|
+
// The sessions restore opened already: an environment without Apps runs nothing on any runtime.
|
|
176
|
+
const rows = await this.query()
|
|
177
|
+
.selectFrom('relApps')
|
|
178
|
+
.select('environmentId')
|
|
179
|
+
.distinct()
|
|
180
|
+
.execute();
|
|
181
|
+
const found = await Promise.all(rows.map(async (row) => {
|
|
182
|
+
const environmentId = String(row.environmentId);
|
|
183
|
+
const occupancy = await this.session(environmentId)
|
|
184
|
+
.then((session) => session.occupancy?.() ?? null)
|
|
185
|
+
.catch(() => null);
|
|
186
|
+
return occupancy ? { environmentId, occupancy } : null;
|
|
187
|
+
}));
|
|
188
|
+
const hosts = new Map();
|
|
189
|
+
for (const item of found) {
|
|
190
|
+
if (!item)
|
|
191
|
+
continue;
|
|
192
|
+
const host = hosts.get(item.occupancy.id);
|
|
193
|
+
if (host)
|
|
194
|
+
host.environmentIds.push(item.environmentId);
|
|
195
|
+
else
|
|
196
|
+
hosts.set(item.occupancy.id, {
|
|
197
|
+
occupancy: item.occupancy,
|
|
198
|
+
environmentIds: [item.environmentId],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const views = [];
|
|
202
|
+
for (const { occupancy, environmentIds } of hosts.values()) {
|
|
203
|
+
const { view } = await this.hostView(caller, occupancy);
|
|
204
|
+
const environments = [];
|
|
205
|
+
for (const id of environmentIds.sort())
|
|
206
|
+
environments.push({
|
|
207
|
+
id,
|
|
208
|
+
name: (await this.options.environments.find(id))?.name ?? id,
|
|
209
|
+
// Counted as the host's `running`, so the environment's Apps filtered to running or starting are as many.
|
|
210
|
+
running: occupancy.apps.filter((item) => item.environmentId === id &&
|
|
211
|
+
(item.state === 'running' || item.state === 'starting')).length,
|
|
212
|
+
});
|
|
213
|
+
views.push({ ...view, environments });
|
|
214
|
+
}
|
|
215
|
+
return views.sort((a, b) => a.id.localeCompare(b.id));
|
|
216
|
+
}
|
|
217
|
+
/** What `occupancy` says of the runtime, limited to what the caller may read. */
|
|
218
|
+
async hostView(caller, occupancy) {
|
|
219
|
+
const environmentNames = new Map();
|
|
220
|
+
for (const item of occupancy.failedEnvironments)
|
|
221
|
+
environmentNames.set(item.environmentId, (await this.options.environments.find(item.environmentId))?.name ??
|
|
222
|
+
null);
|
|
223
|
+
// An environment's restore error may name any of its Apps or their paths: only those who manage environments
|
|
224
|
+
// read it; everyone else learns that the restore failed.
|
|
225
|
+
const diagnoses = this.options.guard.hasSetting(caller, 'rel.environments', 'manage');
|
|
226
|
+
const hiddenErrors = new Set(diagnoses
|
|
227
|
+
? []
|
|
228
|
+
: occupancy.failedEnvironments.flatMap((item) => item.error ? [item.error] : []));
|
|
229
|
+
return {
|
|
230
|
+
view: {
|
|
231
|
+
id: occupancy.id,
|
|
232
|
+
memory: occupancy.memory,
|
|
233
|
+
recycling: occupancy.recycling,
|
|
234
|
+
deployed: occupancy.apps.length,
|
|
235
|
+
// Running or starting Apps hold the runtime; a failed one holds nothing and is not counted.
|
|
236
|
+
running: occupancy.apps.filter((item) => item.state === 'running' || item.state === 'starting').length,
|
|
237
|
+
maxActive: occupancy.maxActive,
|
|
238
|
+
failedEnvironments: occupancy.failedEnvironments.map((item) => ({
|
|
239
|
+
id: item.environmentId,
|
|
240
|
+
name: environmentNames.get(item.environmentId) ?? null,
|
|
241
|
+
error: diagnoses ? item.error : null,
|
|
242
|
+
})),
|
|
243
|
+
},
|
|
244
|
+
hiddenErrors,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async createApp(caller, input, options = {}) {
|
|
119
248
|
this.options.guard.requireCreate(caller);
|
|
120
249
|
if (!input || typeof input.id !== 'string')
|
|
121
250
|
throw new ReleasesError('An App ID is required.', 'INVALID_APP_ID', 'INVALID_ARGUMENT');
|
|
@@ -144,28 +273,32 @@ export class ReleasesService {
|
|
|
144
273
|
if (await this.findApp(id))
|
|
145
274
|
throw new ReleasesError('Application ID is unavailable. Choose a different ID.', 'APP_EXISTS', 'ALREADY_EXISTS');
|
|
146
275
|
await this.assertEnvironmentCapacity(environment);
|
|
147
|
-
await this.
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
276
|
+
await this.options.database.transaction(async (connection) => {
|
|
277
|
+
await connection.query
|
|
278
|
+
.insertInto('relApps')
|
|
279
|
+
.values({
|
|
280
|
+
id,
|
|
281
|
+
environmentId: environment.id,
|
|
282
|
+
name,
|
|
283
|
+
description: typeof input.description === 'string' &&
|
|
284
|
+
input.description.trim()
|
|
285
|
+
? input.description.trim()
|
|
286
|
+
: null,
|
|
287
|
+
currentDeploymentId: null,
|
|
288
|
+
enabled: false,
|
|
289
|
+
activation: policy.activation,
|
|
290
|
+
idleStopMinutes: policy.idleStopMinutes,
|
|
291
|
+
dormantAfterHours: policy.dormantAfterHours,
|
|
292
|
+
labels: JSON.stringify(labels),
|
|
293
|
+
previewOf,
|
|
294
|
+
createdBy: caller.userId,
|
|
295
|
+
createdVia: caller.kind,
|
|
296
|
+
createdAt: now,
|
|
297
|
+
updatedAt: now,
|
|
298
|
+
})
|
|
299
|
+
.execute();
|
|
300
|
+
await options.initialize?.(connection, id);
|
|
301
|
+
});
|
|
169
302
|
});
|
|
170
303
|
}
|
|
171
304
|
catch (error) {
|
|
@@ -268,6 +401,48 @@ export class ReleasesService {
|
|
|
268
401
|
const images = await this.artifacts.imagesOf([release.id]);
|
|
269
402
|
return releaseView(release, images.get(release.id) ?? []);
|
|
270
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Several Apps' summaries in one batched read (one runtime status call per environment), for the assembling
|
|
406
|
+
* application: no caller is checked. Unknown ids are left out; the rest keep the order asked.
|
|
407
|
+
*/
|
|
408
|
+
async summarizeApps(appIds) {
|
|
409
|
+
const ids = [...new Set(appIds)];
|
|
410
|
+
if (ids.length === 0)
|
|
411
|
+
return [];
|
|
412
|
+
const rows = await this.query()
|
|
413
|
+
.selectFrom('relApps')
|
|
414
|
+
.selectAll()
|
|
415
|
+
.where('id', 'in', ids)
|
|
416
|
+
.execute();
|
|
417
|
+
const byId = new Map(rows.map(decodeApp).map((app) => [app.id, app]));
|
|
418
|
+
const apps = ids.flatMap((id) => {
|
|
419
|
+
const app = byId.get(id);
|
|
420
|
+
return app ? [app] : [];
|
|
421
|
+
});
|
|
422
|
+
return this.summarize(apps);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A release's archive, to download: `view` on the App. An image release has none (`RELEASE_HAS_NO_ARCHIVE`), and an
|
|
426
|
+
* archive missing from the disk is `RELEASE_ARCHIVE_NOT_FOUND`. The stream is opened last, so a refusal never touches
|
|
427
|
+
* the disk.
|
|
428
|
+
*/
|
|
429
|
+
async openArchive(caller, appId, releaseId) {
|
|
430
|
+
const app = await this.requireApp(appId);
|
|
431
|
+
await this.options.guard.requireApp(caller, 'view', app);
|
|
432
|
+
const release = await this.requireRelease(appId, releaseId);
|
|
433
|
+
const key = release.artifactKey;
|
|
434
|
+
if (!key)
|
|
435
|
+
throw new ReleasesError('This release is a set of images and has no archive to download.', 'RELEASE_HAS_NO_ARCHIVE', 'FAILED_PRECONDITION');
|
|
436
|
+
// A local disk opens its file lazily, so a missing archive would fail only after the response headers went out.
|
|
437
|
+
if (!(await this.disk.exists(key)))
|
|
438
|
+
throw notFound('Archive', 'RELEASE_ARCHIVE_NOT_FOUND', releaseId);
|
|
439
|
+
return {
|
|
440
|
+
stream: await this.disk.getStream(key),
|
|
441
|
+
size: release.size,
|
|
442
|
+
checksum: release.checksum,
|
|
443
|
+
fileName: archiveFileName(app.id, release.version),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
271
446
|
/**
|
|
272
447
|
* Registers an image CI built and pushed, by digest, as a release of `version` (an image release, which Docker
|
|
273
448
|
* environments pull). It needs `upload` on the App, as uploading an archive does. The same digest again answers its
|
|
@@ -418,7 +593,7 @@ export class ReleasesService {
|
|
|
418
593
|
if (existing)
|
|
419
594
|
return existing;
|
|
420
595
|
const id = randomUUID();
|
|
421
|
-
const artifactKey =
|
|
596
|
+
const artifactKey = this.artifactKey(appId, id);
|
|
422
597
|
const release = {
|
|
423
598
|
id,
|
|
424
599
|
appId,
|
|
@@ -438,8 +613,6 @@ export class ReleasesService {
|
|
|
438
613
|
createdVia: caller.kind,
|
|
439
614
|
createdAt: new Date(),
|
|
440
615
|
};
|
|
441
|
-
if (input.deploy)
|
|
442
|
-
await this.requireNoPendingDeployment(appId);
|
|
443
616
|
// A release whose required variables are not all set is kept, and not deployed: once they are set, it
|
|
444
617
|
// deploys by its ID without another upload.
|
|
445
618
|
const missing = input.deploy
|
|
@@ -457,8 +630,6 @@ export class ReleasesService {
|
|
|
457
630
|
contentType: 'application/gzip',
|
|
458
631
|
});
|
|
459
632
|
await this.options.database.transaction(async (connection) => {
|
|
460
|
-
if (deployment)
|
|
461
|
-
await this.requireNoPendingDeployment(appId, connection);
|
|
462
633
|
await connection.query
|
|
463
634
|
.insertInto('relReleases')
|
|
464
635
|
.values(encodeRelease(release))
|
|
@@ -514,8 +685,16 @@ export class ReleasesService {
|
|
|
514
685
|
});
|
|
515
686
|
if (missing.length > 0)
|
|
516
687
|
throw variablesMissing(current, release, missing);
|
|
517
|
-
if (deployment)
|
|
688
|
+
if (deployment) {
|
|
689
|
+
try {
|
|
690
|
+
await this.replaceQueued(deployment);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
await this.markFailed(deployment, error);
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
518
696
|
this.schedule(deployment);
|
|
697
|
+
}
|
|
519
698
|
return { ...view, reused: false, deploymentId: deployment?.id ?? null };
|
|
520
699
|
});
|
|
521
700
|
}
|
|
@@ -574,7 +753,7 @@ export class ReleasesService {
|
|
|
574
753
|
}
|
|
575
754
|
const id = randomUUID();
|
|
576
755
|
const artifactKey = release.artifactKey
|
|
577
|
-
?
|
|
756
|
+
? this.artifactKey(targetAppId, id)
|
|
578
757
|
: null;
|
|
579
758
|
const copy = {
|
|
580
759
|
...release,
|
|
@@ -654,6 +833,7 @@ export class ReleasesService {
|
|
|
654
833
|
throw new ReleasesError('Configuration content is required.', 'INVALID_CONFIG_FILE', 'INVALID_ARGUMENT');
|
|
655
834
|
assertConfigSize(content);
|
|
656
835
|
return await this.withLock(appId, async () => {
|
|
836
|
+
await this.waitForAppDeployments(appId);
|
|
657
837
|
const app = await this.requireApp(appId);
|
|
658
838
|
await this.options.guard.requireApp(caller, 'configure', app);
|
|
659
839
|
const deployment = await this.currentDeployment(app);
|
|
@@ -777,12 +957,12 @@ export class ReleasesService {
|
|
|
777
957
|
.offset((page - 1) * pageSize)
|
|
778
958
|
.execute();
|
|
779
959
|
return {
|
|
780
|
-
items: rows.map((row) => deploymentView(decodeDeployment(row), typeof row.releaseVersion === 'string'
|
|
960
|
+
items: rows.map((row) => this.progressView(deploymentView(decodeDeployment(row), typeof row.releaseVersion === 'string'
|
|
781
961
|
? {
|
|
782
962
|
version: row.releaseVersion,
|
|
783
963
|
checksum: String(row.releaseChecksum),
|
|
784
964
|
}
|
|
785
|
-
: null)),
|
|
965
|
+
: null))),
|
|
786
966
|
total,
|
|
787
967
|
page,
|
|
788
968
|
pageSize,
|
|
@@ -798,7 +978,14 @@ export class ReleasesService {
|
|
|
798
978
|
await this.options.guard.requireApp(caller, 'deploy', app);
|
|
799
979
|
const deployment = await this.requireDeployment(appId, deploymentId);
|
|
800
980
|
const release = await this.findRelease(appId, deployment.releaseId);
|
|
801
|
-
|
|
981
|
+
if (['queued', 'deploying', 'cancelling'].includes(deployment.status)) {
|
|
982
|
+
const progress = await (await this.session(app.environmentId)).progress?.(deployment.id, app.id);
|
|
983
|
+
if (progress)
|
|
984
|
+
this.progress.set(deployment.id, progress);
|
|
985
|
+
}
|
|
986
|
+
return this.progressView(deploymentView(deployment, release
|
|
987
|
+
? { version: release.version, checksum: release.checksum }
|
|
988
|
+
: null));
|
|
802
989
|
}
|
|
803
990
|
/** Resolves once the deployment has finished, or with its current state at the timeout. */
|
|
804
991
|
async waitForDeployment(deploymentId, timeoutMs = 120_000) {
|
|
@@ -812,7 +999,7 @@ export class ReleasesService {
|
|
|
812
999
|
if (!row)
|
|
813
1000
|
throw notFound('Deployment', 'DEPLOYMENT_NOT_FOUND', deploymentId);
|
|
814
1001
|
const deployment = decodeDeployment(row);
|
|
815
|
-
const finished =
|
|
1002
|
+
const finished = !['queued', 'deploying', 'cancelling'].includes(deployment.status);
|
|
816
1003
|
if (finished || Date.now() >= deadline) {
|
|
817
1004
|
// A finished deployment's run still announces it (events, the request it settled); wait for that too, so a
|
|
818
1005
|
// waiter never sees the deployment done but its consequences pending.
|
|
@@ -867,6 +1054,7 @@ export class ReleasesService {
|
|
|
867
1054
|
// --- Lifecycle ----------------------------------------------------------------------------------------------
|
|
868
1055
|
async start(caller, appId) {
|
|
869
1056
|
return await this.withLock(appId, async () => {
|
|
1057
|
+
await this.waitForAppDeployments(appId);
|
|
870
1058
|
const app = await this.requireApp(appId);
|
|
871
1059
|
await this.options.guard.requireApp(caller, 'operate', app);
|
|
872
1060
|
const deployment = await this.currentDeployment(app);
|
|
@@ -887,6 +1075,7 @@ export class ReleasesService {
|
|
|
887
1075
|
}
|
|
888
1076
|
async stop(caller, appId) {
|
|
889
1077
|
return await this.withLock(appId, async () => {
|
|
1078
|
+
await this.waitForAppDeployments(appId);
|
|
890
1079
|
const app = await this.requireApp(appId);
|
|
891
1080
|
await this.options.guard.requireApp(caller, 'operate', app);
|
|
892
1081
|
if (!(await this.currentDeployment(app)))
|
|
@@ -904,6 +1093,7 @@ export class ReleasesService {
|
|
|
904
1093
|
}
|
|
905
1094
|
async restart(caller, appId) {
|
|
906
1095
|
return await this.withLock(appId, async () => {
|
|
1096
|
+
await this.waitForAppDeployments(appId);
|
|
907
1097
|
const app = await this.requireApp(appId);
|
|
908
1098
|
await this.options.guard.requireApp(caller, 'operate', app);
|
|
909
1099
|
if (!(await this.currentDeployment(app)))
|
|
@@ -927,18 +1117,20 @@ export class ReleasesService {
|
|
|
927
1117
|
* not report every App as stopped or a finished deployment as still running.
|
|
928
1118
|
*/
|
|
929
1119
|
async restore() {
|
|
930
|
-
await this.clearExpiredInitialAdmins().catch((error) => this.diagnostic.warn('Failed to clear expired first administrators', error));
|
|
931
1120
|
const interrupted = (await this.query()
|
|
932
1121
|
.selectFrom('relDeployments')
|
|
933
1122
|
.selectAll()
|
|
934
|
-
.where('status', 'in', ['queued', 'deploying'])
|
|
1123
|
+
.where('status', 'in', ['queued', 'deploying', 'cancelling'])
|
|
1124
|
+
.orderBy('createdAt', 'asc')
|
|
1125
|
+
.orderBy('id', 'asc')
|
|
935
1126
|
.execute()).map(decodeDeployment);
|
|
936
1127
|
// A deployment the target recorded (the App Host's operation log) is finalised from what it reports; every other
|
|
937
1128
|
// one was cut short.
|
|
938
1129
|
const resumed = new Set();
|
|
939
1130
|
const recorded = new Map();
|
|
940
1131
|
for (const deployment of interrupted) {
|
|
941
|
-
if (deployment.status !== 'deploying'
|
|
1132
|
+
if (deployment.status !== 'deploying' &&
|
|
1133
|
+
deployment.status !== 'cancelling')
|
|
942
1134
|
continue;
|
|
943
1135
|
const app = await this.findApp(deployment.appId);
|
|
944
1136
|
if (!app)
|
|
@@ -963,7 +1155,7 @@ export class ReleasesService {
|
|
|
963
1155
|
.set({
|
|
964
1156
|
status: 'failed',
|
|
965
1157
|
phase: 'completed',
|
|
966
|
-
error: 'Deployment was interrupted by a restart.',
|
|
1158
|
+
error: 'Deployment was interrupted by a restart. Deploy the release again.',
|
|
967
1159
|
finishedAt: new Date(),
|
|
968
1160
|
})
|
|
969
1161
|
.where('id', 'in', cutShort.map((deployment) => deployment.id))
|
|
@@ -1198,15 +1390,14 @@ export class ReleasesService {
|
|
|
1198
1390
|
}
|
|
1199
1391
|
/**
|
|
1200
1392
|
* The first administrator the App's first deployment generated, for a person who may deploy it: until someone
|
|
1201
|
-
* says it is saved
|
|
1393
|
+
* says it is saved or the App is removed.
|
|
1202
1394
|
*/
|
|
1203
1395
|
async readInitialAdmin(caller, appId) {
|
|
1204
1396
|
const app = await this.requireApp(appId);
|
|
1205
1397
|
await this.requireInitialAdminReader(caller, app);
|
|
1206
|
-
await this.clearExpiredInitialAdmins();
|
|
1207
1398
|
const found = await this.initialAdminOf(app);
|
|
1208
1399
|
if (!found)
|
|
1209
|
-
throw new ReleasesError('This App has no first administrator to show: it was saved
|
|
1400
|
+
throw new ReleasesError('This App has no first administrator to show: it was saved or the application did not generate one.', 'INITIAL_ADMIN_UNAVAILABLE', 'NOT_FOUND');
|
|
1210
1401
|
return found;
|
|
1211
1402
|
}
|
|
1212
1403
|
/** Forgets the generated first administrator once someone has saved it. */
|
|
@@ -1224,16 +1415,6 @@ export class ReleasesService {
|
|
|
1224
1415
|
.where('initialAdmin', 'is not', null)
|
|
1225
1416
|
.execute();
|
|
1226
1417
|
}
|
|
1227
|
-
/** Clears the first administrators whose 24 hours are over. */
|
|
1228
|
-
async clearExpiredInitialAdmins(now = new Date()) {
|
|
1229
|
-
await this.query()
|
|
1230
|
-
.updateTable('relDeployments')
|
|
1231
|
-
.set({ initialAdmin: null })
|
|
1232
|
-
.where('initialAdmin', 'is not', null)
|
|
1233
|
-
.where('initialAdminExpiresAt', 'is not', null)
|
|
1234
|
-
.where('initialAdminExpiresAt', '<=', now)
|
|
1235
|
-
.execute();
|
|
1236
|
-
}
|
|
1237
1418
|
async requireInitialAdminReader(caller, app) {
|
|
1238
1419
|
// A password a person copies: never an agent's or a key's, whatever they may deploy.
|
|
1239
1420
|
if (caller.kind !== 'human' &&
|
|
@@ -1250,18 +1431,14 @@ export class ReleasesService {
|
|
|
1250
1431
|
.where('initialAdmin', 'is not', null)
|
|
1251
1432
|
.orderBy('createdAt', 'desc')
|
|
1252
1433
|
.execute();
|
|
1253
|
-
const now = new Date();
|
|
1254
1434
|
for (const deployment of rows.map(decodeDeployment)) {
|
|
1255
|
-
if (deployment.initialAdminExpiresAt &&
|
|
1256
|
-
deployment.initialAdminExpiresAt <= now)
|
|
1257
|
-
continue;
|
|
1258
1435
|
const admin = this.openInitialAdmin(deployment);
|
|
1259
1436
|
if (!admin)
|
|
1260
1437
|
continue;
|
|
1261
1438
|
return {
|
|
1262
1439
|
deploymentId: deployment.id,
|
|
1263
1440
|
...admin,
|
|
1264
|
-
expiresAt:
|
|
1441
|
+
expiresAt: null,
|
|
1265
1442
|
};
|
|
1266
1443
|
}
|
|
1267
1444
|
return null;
|
|
@@ -1431,7 +1608,6 @@ export class ReleasesService {
|
|
|
1431
1608
|
for (const [name, variable] of plan.oneShot)
|
|
1432
1609
|
generated[name] = generateVariable(variable.generate);
|
|
1433
1610
|
let initialAdmin = null;
|
|
1434
|
-
let initialAdminExpiresAt = null;
|
|
1435
1611
|
if (plan.initialAdmin) {
|
|
1436
1612
|
const password = (await this.earlierInitialAdmin(app))?.password ?? randomPassword();
|
|
1437
1613
|
generated[plan.initialAdmin.name] = password;
|
|
@@ -1441,10 +1617,6 @@ export class ReleasesService {
|
|
|
1441
1617
|
password,
|
|
1442
1618
|
};
|
|
1443
1619
|
initialAdmin = encryptText(JSON.stringify(admin), [deploymentId], this.options.secrets, 'initial-admin');
|
|
1444
|
-
// A preview App's goes with the preview; any other's is deleted after a day.
|
|
1445
|
-
initialAdminExpiresAt = app.previewOf
|
|
1446
|
-
? null
|
|
1447
|
-
: new Date(Date.now() + INITIAL_ADMIN_TTL_MS);
|
|
1448
1620
|
}
|
|
1449
1621
|
const resolved = resolveVariables({
|
|
1450
1622
|
manifest: release.variables,
|
|
@@ -1462,7 +1634,6 @@ export class ReleasesService {
|
|
|
1462
1634
|
: encryptText(JSON.stringify(resolved.env), [deploymentId], this.options.secrets, 'deployment-env'),
|
|
1463
1635
|
variablesFingerprint: release.variables || names.length > 0 ? resolved.fingerprint : null,
|
|
1464
1636
|
initialAdmin,
|
|
1465
|
-
initialAdminExpiresAt,
|
|
1466
1637
|
};
|
|
1467
1638
|
}
|
|
1468
1639
|
/**
|
|
@@ -1517,7 +1688,6 @@ export class ReleasesService {
|
|
|
1517
1688
|
}
|
|
1518
1689
|
// --- Internals ----------------------------------------------------------------------------------------------
|
|
1519
1690
|
async startDeployment(caller, app, release, options, config, idempotency) {
|
|
1520
|
-
await this.requireNoPendingDeployment(app.id);
|
|
1521
1691
|
const deployment = await this.buildDeploymentRecord(app, release, caller, {
|
|
1522
1692
|
kind: options.kind,
|
|
1523
1693
|
rollbackTargetDeploymentId: options.rollbackTargetDeploymentId ?? null,
|
|
@@ -1526,7 +1696,6 @@ export class ReleasesService {
|
|
|
1526
1696
|
});
|
|
1527
1697
|
try {
|
|
1528
1698
|
await this.options.database.transaction(async (connection) => {
|
|
1529
|
-
await this.requireNoPendingDeployment(app.id, connection);
|
|
1530
1699
|
await connection.query
|
|
1531
1700
|
.insertInto('relDeployments')
|
|
1532
1701
|
.values(encodeDeployment(deployment))
|
|
@@ -1553,6 +1722,13 @@ export class ReleasesService {
|
|
|
1553
1722
|
await rm(deployment.configPath, { force: true });
|
|
1554
1723
|
throw error;
|
|
1555
1724
|
}
|
|
1725
|
+
try {
|
|
1726
|
+
await this.replaceQueued(deployment);
|
|
1727
|
+
}
|
|
1728
|
+
catch (error) {
|
|
1729
|
+
await this.markFailed(deployment, error);
|
|
1730
|
+
throw error;
|
|
1731
|
+
}
|
|
1556
1732
|
this.schedule(deployment);
|
|
1557
1733
|
return {
|
|
1558
1734
|
...deploymentView(deployment, {
|
|
@@ -1642,6 +1818,7 @@ export class ReleasesService {
|
|
|
1642
1818
|
}
|
|
1643
1819
|
async removeApp(app, caller) {
|
|
1644
1820
|
await this.withLock(`publish:${app.id}`, () => this.withLock(app.id, async () => {
|
|
1821
|
+
await this.waitForAppDeployments(app.id);
|
|
1645
1822
|
const current = await this.findApp(app.id);
|
|
1646
1823
|
if (!current)
|
|
1647
1824
|
return;
|
|
@@ -1692,7 +1869,24 @@ export class ReleasesService {
|
|
|
1692
1869
|
actor: actorOf(caller),
|
|
1693
1870
|
});
|
|
1694
1871
|
}
|
|
1695
|
-
|
|
1872
|
+
/** Each environment's driver status of these Apps, one call per environment; the error when its driver failed. */
|
|
1873
|
+
async runtimeStatuses(apps) {
|
|
1874
|
+
const statuses = new Map();
|
|
1875
|
+
for (const environmentId of new Set(apps.map((app) => app.environmentId)))
|
|
1876
|
+
try {
|
|
1877
|
+
const session = await this.session(environmentId);
|
|
1878
|
+
statuses.set(environmentId, await session.status(apps
|
|
1879
|
+
.filter((app) => app.environmentId === environmentId)
|
|
1880
|
+
.map((app) => app.id)));
|
|
1881
|
+
}
|
|
1882
|
+
catch (error) {
|
|
1883
|
+
statuses.set(environmentId, error instanceof Error ? error : new Error(String(error)));
|
|
1884
|
+
}
|
|
1885
|
+
return statuses;
|
|
1886
|
+
}
|
|
1887
|
+
async summarize(apps,
|
|
1888
|
+
/** Statuses already read for these Apps (a superset is fine), so they are not asked again. */
|
|
1889
|
+
known) {
|
|
1696
1890
|
if (!apps.length)
|
|
1697
1891
|
return [];
|
|
1698
1892
|
if (this.restoring)
|
|
@@ -1707,10 +1901,10 @@ export class ReleasesService {
|
|
|
1707
1901
|
.execute(),
|
|
1708
1902
|
this.query()
|
|
1709
1903
|
.selectFrom('relDeployments')
|
|
1710
|
-
.select('appId')
|
|
1711
|
-
.distinct()
|
|
1904
|
+
.select(['id', 'appId', 'status', 'createdAt'])
|
|
1712
1905
|
.where('appId', 'in', ids)
|
|
1713
|
-
.
|
|
1906
|
+
.orderBy('createdAt', 'asc')
|
|
1907
|
+
.where('status', 'in', ['queued', 'deploying', 'cancelling'])
|
|
1714
1908
|
.execute(),
|
|
1715
1909
|
this.query()
|
|
1716
1910
|
.selectFrom('relApps')
|
|
@@ -1722,22 +1916,22 @@ export class ReleasesService {
|
|
|
1722
1916
|
]);
|
|
1723
1917
|
const released = new Set(releases.map((row) => String(row.appId)));
|
|
1724
1918
|
const pendingApps = new Set(pending.map((row) => String(row.appId)));
|
|
1919
|
+
const pendingByApp = new Map();
|
|
1920
|
+
for (const row of pending) {
|
|
1921
|
+
const previous = pendingByApp.get(String(row.appId));
|
|
1922
|
+
if (!previous ||
|
|
1923
|
+
(previous.status === 'queued' && row.status !== 'queued'))
|
|
1924
|
+
pendingByApp.set(String(row.appId), row);
|
|
1925
|
+
}
|
|
1725
1926
|
const versions = new Map(currentReleases.map((row) => [String(row.appId), String(row.version)]));
|
|
1726
1927
|
const environments = new Map();
|
|
1727
|
-
const statuses =
|
|
1928
|
+
const statuses = known ?? (await this.runtimeStatuses(apps));
|
|
1728
1929
|
const sessions = new Map();
|
|
1729
1930
|
for (const environmentId of new Set(apps.map((app) => app.environmentId))) {
|
|
1730
1931
|
environments.set(environmentId, await this.options.environments.find(environmentId));
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
sessions.set(environmentId, session);
|
|
1735
|
-
statuses.set(environmentId, await session.status(environmentApps.map((app) => app.id)));
|
|
1736
|
-
}
|
|
1737
|
-
catch (error) {
|
|
1738
|
-
sessions.set(environmentId, null);
|
|
1739
|
-
statuses.set(environmentId, error instanceof Error ? error : new Error(String(error)));
|
|
1740
|
-
}
|
|
1932
|
+
sessions.set(environmentId, statuses.get(environmentId) instanceof Error
|
|
1933
|
+
? null
|
|
1934
|
+
: await this.session(environmentId).catch(() => null));
|
|
1741
1935
|
}
|
|
1742
1936
|
return apps.map((app) => {
|
|
1743
1937
|
const environment = environments.get(app.environmentId) ?? null;
|
|
@@ -1758,6 +1952,15 @@ export class ReleasesService {
|
|
|
1758
1952
|
currentVersion: versions.get(app.id) ?? null,
|
|
1759
1953
|
hasReleases: released.has(app.id),
|
|
1760
1954
|
hasPendingDeployment: pendingApps.has(app.id),
|
|
1955
|
+
pendingDeployment: (() => {
|
|
1956
|
+
const row = pendingByApp.get(app.id);
|
|
1957
|
+
return row
|
|
1958
|
+
? {
|
|
1959
|
+
status: row.status,
|
|
1960
|
+
queueAhead: this.progress.get(String(row.id))?.queueAhead ?? null,
|
|
1961
|
+
}
|
|
1962
|
+
: null;
|
|
1963
|
+
})(),
|
|
1761
1964
|
};
|
|
1762
1965
|
});
|
|
1763
1966
|
}
|
|
@@ -1790,6 +1993,8 @@ export class ReleasesService {
|
|
|
1790
1993
|
.driverEnvironment(environmentId)
|
|
1791
1994
|
.then(({ driver, target }) => driver.open(target, {
|
|
1792
1995
|
desired: () => this.desiredSet(environmentId),
|
|
1996
|
+
deploymentBatchBusy: (excluding) => [...this.dispatch].some(([id, item]) => !excluding.has(id) &&
|
|
1997
|
+
(!item.environmentId || item.environmentId === environmentId)),
|
|
1793
1998
|
}));
|
|
1794
1999
|
session.catch(() => {
|
|
1795
2000
|
if (this.sessions.get(environmentId) === session)
|
|
@@ -1989,7 +2194,7 @@ export class ReleasesService {
|
|
|
1989
2194
|
}
|
|
1990
2195
|
schedule(deployment) {
|
|
1991
2196
|
this.logDeployment(deployment, 'queued', 'Deployment queued');
|
|
1992
|
-
const run = this.
|
|
2197
|
+
const run = this.dispatchDeployment(deployment)
|
|
1993
2198
|
.catch((error) => this.diagnostic.error(`Deployment ${deployment.id} failed unexpectedly`, error))
|
|
1994
2199
|
.finally(() => {
|
|
1995
2200
|
this.running.delete(run);
|
|
@@ -1999,30 +2204,77 @@ export class ReleasesService {
|
|
|
1999
2204
|
this.running.add(run);
|
|
2000
2205
|
this.runs.set(deployment.id, run);
|
|
2001
2206
|
}
|
|
2002
|
-
async
|
|
2207
|
+
async dispatchDeployment(deployment) {
|
|
2208
|
+
const app = await this.requireApp(deployment.appId);
|
|
2209
|
+
const session = await this.session(app.environmentId);
|
|
2210
|
+
try {
|
|
2211
|
+
// Drivers without a native queue keep the previous per-App serialization.
|
|
2212
|
+
if (!session.progress)
|
|
2213
|
+
return await this.withLock(app.id, () => this.runDeployment(deployment.id, deployment.appId, app.environmentId));
|
|
2214
|
+
const lifecycle = this.locks.get(app.id);
|
|
2215
|
+
if (lifecycle)
|
|
2216
|
+
await lifecycle.catch(() => undefined);
|
|
2217
|
+
return await this.runDeployment(deployment.id, deployment.appId, app.environmentId);
|
|
2218
|
+
}
|
|
2219
|
+
finally {
|
|
2220
|
+
// Metadata/initial-admission failures must not leave the batch busy forever either.
|
|
2221
|
+
this.dispatch.delete(deployment.id);
|
|
2222
|
+
try {
|
|
2223
|
+
session.deploymentSettled?.();
|
|
2224
|
+
}
|
|
2225
|
+
catch (error) {
|
|
2226
|
+
this.diagnostic.error('Failed to notify the deployment driver after settlement', error);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
/** Lifecycle operations keep the deployment result current before reading its configuration. New dispatches wait on their App lock. */
|
|
2231
|
+
async waitForAppDeployments(appId) {
|
|
2232
|
+
const runs = [...this.dispatch]
|
|
2233
|
+
.filter(([, value]) => value.appId === appId)
|
|
2234
|
+
.map(([id]) => this.runs.get(id))
|
|
2235
|
+
.filter((run) => Boolean(run));
|
|
2236
|
+
await Promise.allSettled(runs);
|
|
2237
|
+
}
|
|
2238
|
+
async runDeployment(deploymentId, appId, environmentId) {
|
|
2239
|
+
const dispatch = { appId, submitted: false, cancelled: false, environmentId };
|
|
2240
|
+
this.dispatch.set(deploymentId, dispatch);
|
|
2003
2241
|
const deployment = await this.requireDeploymentById(deploymentId);
|
|
2242
|
+
if (deployment.status !== 'queued') {
|
|
2243
|
+
this.dispatch.delete(deploymentId);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2004
2246
|
const app = await this.requireApp(deployment.appId);
|
|
2005
2247
|
const release = await this.requireRelease(app.id, deployment.releaseId);
|
|
2006
|
-
|
|
2248
|
+
let settlement;
|
|
2007
2249
|
let phaseWrites = Promise.resolve();
|
|
2008
2250
|
let lastSequence = 0;
|
|
2251
|
+
let lastPhase;
|
|
2252
|
+
let switchStarted = false;
|
|
2009
2253
|
let phaseError;
|
|
2010
|
-
await this.updateDeployment(deploymentId, {
|
|
2011
|
-
status: 'deploying',
|
|
2012
|
-
phase: 'resolving',
|
|
2013
|
-
startedAt: new Date(),
|
|
2014
|
-
error: null,
|
|
2015
|
-
previousDeploymentId: app.currentDeploymentId,
|
|
2016
|
-
});
|
|
2017
2254
|
let outcome;
|
|
2018
2255
|
try {
|
|
2019
|
-
this.logDeployment(deployment, 'resolving', 'Deployment started');
|
|
2020
2256
|
const session = await this.session(app.environmentId);
|
|
2021
2257
|
const logsEnabled = this.options.logging?.deployments?.enabled !== false;
|
|
2022
2258
|
const spec = await this.deploymentSpec({ ...app, enabled: true }, deployment, 'running');
|
|
2023
2259
|
// A driver that runs no images runs the archive; one that does says what it ran (`DeploymentEvent.artifact`).
|
|
2024
2260
|
if (spec.images === undefined)
|
|
2025
2261
|
await this.updateDeployment(deploymentId, artifactColumns({ kind: 'tarball' }));
|
|
2262
|
+
if (dispatch.cancelled) {
|
|
2263
|
+
await this.markCancelled(deployment);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (!session.progress) {
|
|
2267
|
+
this.logDeployment(deployment, 'resolving', 'Deployment started');
|
|
2268
|
+
await this.updateDeployment(deploymentId, {
|
|
2269
|
+
status: 'deploying',
|
|
2270
|
+
phase: 'resolving',
|
|
2271
|
+
startedAt: new Date(),
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
dispatch.submitted = true;
|
|
2275
|
+
// Reserve in runtime submission order without holding up Host admission or cancellation.
|
|
2276
|
+
settlement = this.reserveSettlement(app.id);
|
|
2277
|
+
let started = false;
|
|
2026
2278
|
const observed = await session.apply(spec, (entry) => {
|
|
2027
2279
|
if (typeof entry.sequence === 'number') {
|
|
2028
2280
|
if (entry.sequence <= lastSequence)
|
|
@@ -2039,8 +2291,46 @@ export class ReleasesService {
|
|
|
2039
2291
|
});
|
|
2040
2292
|
if (typeof entry.phase === 'string') {
|
|
2041
2293
|
const phase = entry.phase;
|
|
2294
|
+
// A completed capacity rejection may re-enter a cancellable wait before one retry.
|
|
2295
|
+
if (entry.cancellable === true)
|
|
2296
|
+
switchStarted = false;
|
|
2297
|
+
const first = !started &&
|
|
2298
|
+
phase !== 'queued' &&
|
|
2299
|
+
phase !== 'cancelled' &&
|
|
2300
|
+
phase !== 'cancelling';
|
|
2301
|
+
if (first)
|
|
2302
|
+
started = true;
|
|
2303
|
+
if (['switching', 'starting', 'health_check'].includes(phase))
|
|
2304
|
+
switchStarted = true;
|
|
2305
|
+
this.progress.set(deploymentId, {
|
|
2306
|
+
queueAhead: phase === 'queued' && typeof entry.queueAhead === 'number'
|
|
2307
|
+
? entry.queueAhead
|
|
2308
|
+
: null,
|
|
2309
|
+
cancellable: !switchStarted &&
|
|
2310
|
+
!['completed', 'cancelled', 'cancelling'].includes(phase),
|
|
2311
|
+
});
|
|
2312
|
+
const changed = lastPhase !== phase;
|
|
2313
|
+
lastPhase = phase;
|
|
2042
2314
|
phaseWrites = phaseWrites
|
|
2043
|
-
.then(() =>
|
|
2315
|
+
.then(async () => {
|
|
2316
|
+
// Queue-position events are transient; they cause no database writes.
|
|
2317
|
+
if (phase === 'queued' || !changed)
|
|
2318
|
+
return;
|
|
2319
|
+
await this.query()
|
|
2320
|
+
.updateTable('relDeployments')
|
|
2321
|
+
.set({
|
|
2322
|
+
phase,
|
|
2323
|
+
...(first
|
|
2324
|
+
? {
|
|
2325
|
+
status: 'deploying',
|
|
2326
|
+
startedAt: new Date(),
|
|
2327
|
+
}
|
|
2328
|
+
: {}),
|
|
2329
|
+
})
|
|
2330
|
+
.where('id', '=', deploymentId)
|
|
2331
|
+
.where('status', 'in', ['queued', 'deploying'])
|
|
2332
|
+
.execute();
|
|
2333
|
+
})
|
|
2044
2334
|
.catch((error) => {
|
|
2045
2335
|
phaseError =
|
|
2046
2336
|
error instanceof Error ? error : new Error(String(error));
|
|
@@ -2059,24 +2349,64 @@ export class ReleasesService {
|
|
|
2059
2349
|
await phaseWrites;
|
|
2060
2350
|
if (phaseError)
|
|
2061
2351
|
throw phaseError;
|
|
2352
|
+
if (observed.state === 'cancelled') {
|
|
2353
|
+
await this.markCancelled(deployment);
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2062
2356
|
if (observed.state === 'failed' || observed.state === 'unknown')
|
|
2063
2357
|
throw new Error(observed.error ?? 'The runtime did not report the deployment.');
|
|
2064
|
-
await this.markSucceeded(deployment, app.id, previous);
|
|
2065
2358
|
outcome = { ok: true };
|
|
2066
2359
|
}
|
|
2067
2360
|
catch (error) {
|
|
2068
2361
|
await phaseWrites.catch(() => undefined);
|
|
2069
2362
|
outcome = { ok: false, error: errorMessage(error) };
|
|
2070
|
-
await this.markFailed(deployment, error);
|
|
2071
2363
|
}
|
|
2072
2364
|
finally {
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2365
|
+
const settle = settlement ?? this.reserveSettlement(app.id);
|
|
2366
|
+
const result = settle(async () => {
|
|
2367
|
+
if (!outcome)
|
|
2368
|
+
return;
|
|
2369
|
+
// The predecessor is read only after its writes, cleanup and outcome event have finished.
|
|
2370
|
+
const previous = await this.currentDeployment(await this.requireApp(app.id));
|
|
2371
|
+
if (outcome.ok) {
|
|
2372
|
+
try {
|
|
2373
|
+
await this.markSucceeded(deployment, app.id, previous);
|
|
2374
|
+
}
|
|
2375
|
+
catch (error) {
|
|
2376
|
+
outcome = { ok: false, error: errorMessage(error) };
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
if (!outcome.ok)
|
|
2380
|
+
await this.markFailed(deployment, new Error(outcome.error), previous);
|
|
2381
|
+
await this.emitOutcome(deployment, app, release, outcome);
|
|
2382
|
+
});
|
|
2383
|
+
try {
|
|
2384
|
+
// A queued cancellation is already terminal; it must not wait for the running predecessor.
|
|
2385
|
+
if (outcome)
|
|
2386
|
+
await result;
|
|
2387
|
+
}
|
|
2388
|
+
finally {
|
|
2389
|
+
this.dispatch.delete(deploymentId);
|
|
2390
|
+
this.progress.delete(deploymentId);
|
|
2391
|
+
const policy = this.options.logging?.deployments;
|
|
2392
|
+
await pruneJournals(path.dirname(this.deploymentLogPath(app.id, deployment.id)), {
|
|
2393
|
+
retentionDays: policy?.retentionDays ?? 30,
|
|
2394
|
+
maxSizeMB: policy?.maxTotalSizeMB ?? 1024,
|
|
2395
|
+
}, `${deployment.id}.log`).catch((error) => reportLoggingFailure('Deployment log cleanup failed', error));
|
|
2396
|
+
}
|
|
2078
2397
|
}
|
|
2079
|
-
|
|
2398
|
+
}
|
|
2399
|
+
/** Reuse the local lock chain to order results while the Host continues executing its queue. */
|
|
2400
|
+
reserveSettlement(appId) {
|
|
2401
|
+
let finish;
|
|
2402
|
+
const ready = new Promise((resolve) => {
|
|
2403
|
+
finish = resolve;
|
|
2404
|
+
});
|
|
2405
|
+
const result = this.withLock(`deployment-result:${appId}`, async () => (await ready)());
|
|
2406
|
+
return (work) => {
|
|
2407
|
+
finish(work);
|
|
2408
|
+
return result;
|
|
2409
|
+
};
|
|
2080
2410
|
}
|
|
2081
2411
|
async markSucceeded(deployment, appId, previous) {
|
|
2082
2412
|
this.logDeployment(deployment, 'completed', 'Deployment succeeded');
|
|
@@ -2084,7 +2414,12 @@ export class ReleasesService {
|
|
|
2084
2414
|
await this.options.database.transaction(async (connection) => {
|
|
2085
2415
|
await connection.query
|
|
2086
2416
|
.updateTable('relDeployments')
|
|
2087
|
-
.set({
|
|
2417
|
+
.set({
|
|
2418
|
+
status: 'succeeded',
|
|
2419
|
+
phase: 'completed',
|
|
2420
|
+
finishedAt,
|
|
2421
|
+
previousDeploymentId: previous?.id ?? null,
|
|
2422
|
+
})
|
|
2088
2423
|
.where('id', '=', deployment.id)
|
|
2089
2424
|
.execute();
|
|
2090
2425
|
await connection.query
|
|
@@ -2101,7 +2436,7 @@ export class ReleasesService {
|
|
|
2101
2436
|
if (previous?.configPath && previous.id !== deployment.id)
|
|
2102
2437
|
await rm(previous.configPath, { force: true }).catch(() => undefined);
|
|
2103
2438
|
}
|
|
2104
|
-
async markFailed(deployment, error) {
|
|
2439
|
+
async markFailed(deployment, error, previous) {
|
|
2105
2440
|
this.logDeployment(deployment, 'completed', 'Deployment failed', error);
|
|
2106
2441
|
const finishedAt = new Date();
|
|
2107
2442
|
await this.options.database.transaction(async (connection) => {
|
|
@@ -2112,6 +2447,9 @@ export class ReleasesService {
|
|
|
2112
2447
|
phase: 'completed',
|
|
2113
2448
|
error: errorMessage(error),
|
|
2114
2449
|
finishedAt,
|
|
2450
|
+
...(previous !== undefined
|
|
2451
|
+
? { previousDeploymentId: previous?.id ?? null }
|
|
2452
|
+
: {}),
|
|
2115
2453
|
})
|
|
2116
2454
|
.where('id', '=', deployment.id)
|
|
2117
2455
|
.execute();
|
|
@@ -2179,15 +2517,17 @@ export class ReleasesService {
|
|
|
2179
2517
|
};
|
|
2180
2518
|
}
|
|
2181
2519
|
const current = await this.requireDeploymentById(deployment.id);
|
|
2182
|
-
if (
|
|
2520
|
+
if (!['deploying', 'queued', 'cancelling'].includes(current.status))
|
|
2183
2521
|
return;
|
|
2184
2522
|
const app = await this.requireApp(deployment.appId);
|
|
2185
2523
|
const release = await this.requireRelease(app.id, deployment.releaseId);
|
|
2186
2524
|
let outcome;
|
|
2525
|
+
if (status?.state === 'cancelled') {
|
|
2526
|
+
await this.markCancelled(deployment);
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2187
2529
|
if (status?.state === 'running') {
|
|
2188
|
-
const previous =
|
|
2189
|
-
? await this.requireDeployment(app.id, deployment.previousDeploymentId).catch(() => null)
|
|
2190
|
-
: null;
|
|
2530
|
+
const previous = await this.currentDeployment(app);
|
|
2191
2531
|
this.logDeployment(deployment, 'switching', 'The runtime reports the deployment finished');
|
|
2192
2532
|
await this.markSucceeded(deployment, app.id, previous);
|
|
2193
2533
|
outcome = { ok: true };
|
|
@@ -2195,7 +2535,7 @@ export class ReleasesService {
|
|
|
2195
2535
|
else {
|
|
2196
2536
|
const error = status
|
|
2197
2537
|
? (status.error ?? 'The deployment failed.')
|
|
2198
|
-
: 'Deployment was interrupted by a restart.';
|
|
2538
|
+
: 'Deployment was interrupted by a restart. Deploy the release again.';
|
|
2199
2539
|
await this.markFailed(deployment, new Error(error));
|
|
2200
2540
|
outcome = { ok: false, error };
|
|
2201
2541
|
}
|
|
@@ -2211,15 +2551,120 @@ export class ReleasesService {
|
|
|
2211
2551
|
this.runs.set(deployment.id, run);
|
|
2212
2552
|
return run;
|
|
2213
2553
|
}
|
|
2214
|
-
|
|
2215
|
-
const
|
|
2554
|
+
progressView(view) {
|
|
2555
|
+
const progress = this.progress.get(view.id);
|
|
2556
|
+
return {
|
|
2557
|
+
...view,
|
|
2558
|
+
queueAhead: progress?.queueAhead ?? null,
|
|
2559
|
+
cancellable: ['queued', 'deploying'].includes(view.status) &&
|
|
2560
|
+
(progress?.cancellable ?? view.phase === 'queued'),
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
async cancelDeployment(caller, appId, deploymentId) {
|
|
2564
|
+
const app = await this.requireApp(appId);
|
|
2565
|
+
await this.options.guard.requireApp(caller, 'deploy', app);
|
|
2566
|
+
await this.withLock(`cancel:${deploymentId}`, async () => {
|
|
2567
|
+
const deployment = await this.requireDeployment(appId, deploymentId);
|
|
2568
|
+
if (deployment.status === 'cancelled' ||
|
|
2569
|
+
deployment.status === 'cancelling')
|
|
2570
|
+
return;
|
|
2571
|
+
if (!['queued', 'deploying'].includes(deployment.status))
|
|
2572
|
+
throw new ReleasesError('The deployment has finished.', 'DEPLOYMENT_NOT_CANCELLABLE', 'FAILED_PRECONDITION');
|
|
2573
|
+
const result = await this.requestCancellation(deployment, false);
|
|
2574
|
+
if (result === 'tooLate' || result === 'notFound')
|
|
2575
|
+
throw new ReleasesError('The deployment has started switching or has finished.', 'DEPLOYMENT_NOT_CANCELLABLE', 'FAILED_PRECONDITION');
|
|
2576
|
+
});
|
|
2577
|
+
return this.getDeployment(caller, appId, deploymentId);
|
|
2578
|
+
}
|
|
2579
|
+
async replaceQueued(replacement) {
|
|
2580
|
+
const rows = await this.query()
|
|
2216
2581
|
.selectFrom('relDeployments')
|
|
2217
|
-
.
|
|
2218
|
-
.where('appId', '=', appId)
|
|
2219
|
-
.where('status', '
|
|
2220
|
-
.
|
|
2221
|
-
|
|
2222
|
-
|
|
2582
|
+
.selectAll()
|
|
2583
|
+
.where('appId', '=', replacement.appId)
|
|
2584
|
+
.where('status', '=', 'queued')
|
|
2585
|
+
.where('id', '!=', replacement.id)
|
|
2586
|
+
.execute();
|
|
2587
|
+
for (const row of rows)
|
|
2588
|
+
await this.requestCancellation(decodeDeployment(row), true, replacement.id);
|
|
2589
|
+
}
|
|
2590
|
+
async requestCancellation(deployment, queuedOnly, replacedBy) {
|
|
2591
|
+
const dispatch = this.dispatch.get(deployment.id);
|
|
2592
|
+
let result;
|
|
2593
|
+
if ((dispatch && !dispatch.submitted) ||
|
|
2594
|
+
(!dispatch && deployment.status === 'queued')) {
|
|
2595
|
+
if (dispatch)
|
|
2596
|
+
dispatch.cancelled = true;
|
|
2597
|
+
result = 'cancelled';
|
|
2598
|
+
}
|
|
2599
|
+
else {
|
|
2600
|
+
const app = await this.requireApp(deployment.appId);
|
|
2601
|
+
const session = await this.session(app.environmentId);
|
|
2602
|
+
if (!session.cancel)
|
|
2603
|
+
throw new ReleasesError('The deployment driver does not support cancellation.', 'CANCELLATION_UNSUPPORTED', 'FAILED_PRECONDITION');
|
|
2604
|
+
result = await session.cancel(deployment.id, app.id, queuedOnly);
|
|
2605
|
+
}
|
|
2606
|
+
if (result === 'cancelled')
|
|
2607
|
+
await this.markCancelled(deployment, replacedBy);
|
|
2608
|
+
else if (result === 'cancelling') {
|
|
2609
|
+
this.logDeployment(deployment, 'cancelling', 'Cancellation requested; waiting for the current step to finish');
|
|
2610
|
+
await this.query()
|
|
2611
|
+
.updateTable('relDeployments')
|
|
2612
|
+
.set({ status: 'cancelling' })
|
|
2613
|
+
.where('id', '=', deployment.id)
|
|
2614
|
+
.where('status', 'in', ['queued', 'deploying'])
|
|
2615
|
+
.execute();
|
|
2616
|
+
}
|
|
2617
|
+
return result;
|
|
2618
|
+
}
|
|
2619
|
+
markCancelled(deployment, replacedBy) {
|
|
2620
|
+
return this.withLock(`settle:${deployment.id}`, () => this.markCancelledUnlocked(deployment, replacedBy));
|
|
2621
|
+
}
|
|
2622
|
+
async markCancelledUnlocked(deployment, replacedBy) {
|
|
2623
|
+
const current = await this.requireDeploymentById(deployment.id);
|
|
2624
|
+
if (!['queued', 'deploying', 'cancelling'].includes(current.status))
|
|
2625
|
+
return;
|
|
2626
|
+
const reason = replacedBy
|
|
2627
|
+
? 'Replaced by a newer deployment.'
|
|
2628
|
+
: 'Deployment cancelled.';
|
|
2629
|
+
const finishedAt = new Date();
|
|
2630
|
+
if (deployment.configPath)
|
|
2631
|
+
await rm(deployment.configPath, { force: true });
|
|
2632
|
+
await this.options.database.transaction(async (connection) => {
|
|
2633
|
+
await connection.query
|
|
2634
|
+
.updateTable('relDeployments')
|
|
2635
|
+
.set({
|
|
2636
|
+
status: 'cancelled',
|
|
2637
|
+
phase: 'cancelled',
|
|
2638
|
+
error: reason,
|
|
2639
|
+
finishedAt,
|
|
2640
|
+
})
|
|
2641
|
+
.where('id', '=', deployment.id)
|
|
2642
|
+
.where('status', 'in', ['queued', 'deploying', 'cancelling'])
|
|
2643
|
+
.execute();
|
|
2644
|
+
await this.settleRequest(connection, deployment, 'cancelled', finishedAt);
|
|
2645
|
+
});
|
|
2646
|
+
if (this.options.logging?.deployments?.enabled !== false)
|
|
2647
|
+
this.appendDeploymentLog(deployment, {
|
|
2648
|
+
time: finishedAt.toISOString(),
|
|
2649
|
+
level: 'info',
|
|
2650
|
+
appId: deployment.appId,
|
|
2651
|
+
deploymentId: deployment.id,
|
|
2652
|
+
phase: 'cancelled',
|
|
2653
|
+
msg: reason,
|
|
2654
|
+
reason: replacedBy ? 'superseded' : 'requested',
|
|
2655
|
+
...(replacedBy ? { replacedBy } : {}),
|
|
2656
|
+
});
|
|
2657
|
+
const app = await this.requireApp(deployment.appId);
|
|
2658
|
+
const release = await this.requireRelease(app.id, deployment.releaseId);
|
|
2659
|
+
await this.options.events
|
|
2660
|
+
.emit({
|
|
2661
|
+
type: 'deployment.cancelled',
|
|
2662
|
+
app: appView(app),
|
|
2663
|
+
release: releaseView(release),
|
|
2664
|
+
deployment: deploymentView(await this.requireDeploymentById(deployment.id), { version: release.version, checksum: release.checksum }),
|
|
2665
|
+
actor: { userId: deployment.actorId, kind: deployment.actorKind },
|
|
2666
|
+
})
|
|
2667
|
+
.catch((error) => this.diagnostic.error('Failed to announce deployment cancellation', error));
|
|
2223
2668
|
}
|
|
2224
2669
|
async findRelease(appId, releaseId) {
|
|
2225
2670
|
const row = await this.query()
|
|
@@ -2330,8 +2775,6 @@ export class ReleasesService {
|
|
|
2330
2775
|
return this.options.database.connection().query;
|
|
2331
2776
|
}
|
|
2332
2777
|
}
|
|
2333
|
-
// --- Variables --------------------------------------------------------------------------------------------------
|
|
2334
|
-
const INITIAL_ADMIN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2335
2778
|
/** A variable's value from the App or the environment, when one of them sets it. */
|
|
2336
2779
|
function suppliedValue(context, name) {
|
|
2337
2780
|
return (context.appVariables.find((variable) => variable.name === name)?.value ??
|
|
@@ -2576,7 +3019,8 @@ function normalizeBuild(value) {
|
|
|
2576
3019
|
throw new ReleasesError('The build is text of at most 255 characters.', 'INVALID_BUILD', 'INVALID_ARGUMENT');
|
|
2577
3020
|
return value;
|
|
2578
3021
|
}
|
|
2579
|
-
|
|
3022
|
+
/** What the driver observed of an App, as an App summary reports its runtime. */
|
|
3023
|
+
export function runtimeStatus(status, appId) {
|
|
2580
3024
|
if (!status || status instanceof Error)
|
|
2581
3025
|
return {
|
|
2582
3026
|
available: false,
|
|
@@ -2598,11 +3042,14 @@ function runtimeStatus(status, appId) {
|
|
|
2598
3042
|
};
|
|
2599
3043
|
return {
|
|
2600
3044
|
available: true,
|
|
2601
|
-
state: item.state,
|
|
3045
|
+
state: item.state === 'cancelled' ? 'failed' : item.state,
|
|
2602
3046
|
version: item.version,
|
|
2603
3047
|
startedAt: item.startedAt,
|
|
2604
3048
|
error: item.error,
|
|
2605
3049
|
lastAccessedAt: item.lastAccessedAt ?? null,
|
|
3050
|
+
...(item.holds?.length ? { holds: item.holds } : {}),
|
|
3051
|
+
...(item.waiting ? { waiting: item.waiting } : {}),
|
|
3052
|
+
...(item.unrestored ? { unrestored: true } : {}),
|
|
2606
3053
|
};
|
|
2607
3054
|
}
|
|
2608
3055
|
function decodeActorKind(value) {
|
|
@@ -2686,7 +3133,6 @@ function decodeDeployment(row) {
|
|
|
2686
3133
|
env: nullableString(row.env),
|
|
2687
3134
|
variablesFingerprint: nullableString(row.variablesFingerprint),
|
|
2688
3135
|
initialAdmin: nullableString(row.initialAdmin),
|
|
2689
|
-
initialAdminExpiresAt: decodeOptionalDate(row.initialAdminExpiresAt),
|
|
2690
3136
|
initialAdminSavedBy: nullableString(row.initialAdminSavedBy),
|
|
2691
3137
|
initialAdminSavedAt: decodeOptionalDate(row.initialAdminSavedAt),
|
|
2692
3138
|
createdAt: decodeDate(row.createdAt),
|