dataiku-sdk 0.12.3 → 0.12.5
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/README.md +118 -7
- package/dist/packages/types/src/index.d.ts +5 -0
- package/dist/packages/types/src/index.js +5 -0
- package/dist/src/cli/coerce.d.ts +1 -3
- package/dist/src/cli/coerce.js +1 -17
- package/dist/src/cli/commands/app.js +709 -14
- package/dist/src/cli/commands/code-env.js +2 -2
- package/dist/src/cli/commands/dashboard.js +1 -1
- package/dist/src/cli/commands/dataset.js +2 -2
- package/dist/src/cli/commands/folder.js +1 -1
- package/dist/src/cli/commands/insight.js +2 -2
- package/dist/src/cli/commands/notebook.js +2 -2
- package/dist/src/cli/commands/recipe.js +3 -3
- package/dist/src/cli/commands/scenario.js +1 -1
- package/dist/src/cli/commands/sql.js +1 -1
- package/dist/src/cli/commands/variable.js +1 -1
- package/dist/src/cli/commands/wiki.js +1 -1
- package/dist/src/cli/contract.d.ts +12 -3
- package/dist/src/cli/contract.js +805 -49
- package/dist/src/cli/flags.js +8 -0
- package/dist/src/cli/helpers/app-permissions.d.ts +63 -0
- package/dist/src/cli/helpers/app-permissions.js +239 -0
- package/dist/src/cli/helpers/app-successor.d.ts +106 -0
- package/dist/src/cli/helpers/app-successor.js +625 -0
- package/dist/src/cli/helpers/cleanup.js +46 -12
- package/dist/src/cli/output.d.ts +12 -1
- package/dist/src/cli/output.js +53 -6
- package/dist/src/cli/usage.d.ts +1 -1
- package/dist/src/cli/usage.js +5 -2
- package/dist/src/cli.js +329 -32
- package/dist/src/client.d.ts +6 -0
- package/dist/src/client.js +28 -1
- package/dist/src/errors.d.ts +1 -1
- package/dist/src/resources/applications.d.ts +191 -1
- package/dist/src/resources/applications.js +549 -10
- package/dist/src/resources/datasets.js +8 -0
- package/dist/src/resources/futures.js +7 -2
- package/dist/src/skill.js +80 -15
- package/dist/src/utils/cleanup-ledger.d.ts +34 -1
- package/dist/src/utils/cleanup-ledger.js +118 -7
- package/dist/src/utils/dss-url.d.ts +6 -0
- package/dist/src/utils/dss-url.js +17 -0
- package/dist/src/utils/project-incarnation.d.ts +6 -0
- package/dist/src/utils/project-incarnation.js +27 -0
- package/dist/src/utils/stable-hash.d.ts +11 -0
- package/dist/src/utils/stable-hash.js +27 -0
- package/node_modules/@sinclair/typebox/build/cjs/compiler/compiler.js +26 -46
- package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/guard.d.ts +1 -1
- package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/token.d.ts +2 -2
- package/node_modules/@sinclair/typebox/build/cjs/syntax/mapping.d.ts +1 -1
- package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.d.ts +5 -0
- package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.js +11 -0
- package/node_modules/@sinclair/typebox/build/cjs/system/index.d.ts +1 -0
- package/node_modules/@sinclair/typebox/build/cjs/system/index.js +1 -0
- package/node_modules/@sinclair/typebox/build/cjs/type/object/object.d.ts +2 -2
- package/node_modules/@sinclair/typebox/build/cjs/type/object/object.js +18 -2
- package/node_modules/@sinclair/typebox/build/cjs/value/parse/parse.d.ts +1 -1
- package/node_modules/@sinclair/typebox/build/esm/compiler/compiler.mjs +27 -47
- package/node_modules/@sinclair/typebox/build/esm/parser/runtime/guard.d.mts +1 -1
- package/node_modules/@sinclair/typebox/build/esm/parser/runtime/token.d.mts +2 -2
- package/node_modules/@sinclair/typebox/build/esm/syntax/mapping.d.mts +1 -1
- package/node_modules/@sinclair/typebox/build/esm/system/evaluate.d.mts +5 -0
- package/node_modules/@sinclair/typebox/build/esm/system/evaluate.mjs +7 -0
- package/node_modules/@sinclair/typebox/build/esm/system/index.d.mts +1 -0
- package/node_modules/@sinclair/typebox/build/esm/system/index.mjs +1 -0
- package/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs +2 -2
- package/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs +2 -2
- package/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs +2 -2
- package/node_modules/@sinclair/typebox/build/esm/type/object/object.d.mts +2 -2
- package/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs +18 -2
- package/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs +3 -3
- package/node_modules/@sinclair/typebox/build/esm/type/type/json.mjs +2 -2
- package/node_modules/@sinclair/typebox/build/esm/value/delta/delta.mjs +4 -4
- package/node_modules/@sinclair/typebox/build/esm/value/parse/parse.d.mts +1 -1
- package/node_modules/@sinclair/typebox/package.json +1 -1
- package/package.json +7 -5
- package/packages/types/dist/index.d.ts +5 -0
- package/packages/types/dist/index.js +5 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { DataikuError, } from "../../errors.js";
|
|
2
|
+
import { projectIncarnationHash, } from "../../utils/project-incarnation.js";
|
|
3
|
+
import { stableHash, stringField, } from "../coerce.js";
|
|
4
|
+
import { readIfExists, } from "../output.js";
|
|
5
|
+
import { UsageError, } from "../usage.js";
|
|
6
|
+
import { appPermissionsHash, appPermissionsVerificationError, } from "./app-permissions.js";
|
|
7
|
+
/**
|
|
8
|
+
* A DSS API key authenticates the public REST API only: the Application UI sits
|
|
9
|
+
* behind the interactive (often SSO) session, so no command in this SDK can
|
|
10
|
+
* observe it. Every app-release result therefore carries this gate explicitly
|
|
11
|
+
* instead of implying that a green API check means the UI works.
|
|
12
|
+
*/
|
|
13
|
+
export const VISUAL_UI_GATE = {
|
|
14
|
+
gate: "visual-ui",
|
|
15
|
+
status: "required",
|
|
16
|
+
reason: "API key authenticates public REST only; use a pre-authenticated SSO browser session or dedicated UI test identity.",
|
|
17
|
+
requiredAuthentication: "pre-authenticated-sso-or-dedicated-ui-test-identity",
|
|
18
|
+
evidenceRequired: "Open the instance in the Dataiku Apps UI and exercise the affected tiles, forms, and actions to confirm the release behaves as intended.",
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* DSS answers the app-manifest probe with 400 ("neither an app template nor an
|
|
22
|
+
* app instance") for ordinary projects and 404 for missing ones. Both mean "not
|
|
23
|
+
* a verifiable app project", so they become a failed check instead of an error;
|
|
24
|
+
* every other failure (transient, permission) keeps propagating so real faults
|
|
25
|
+
* are never reported as a clean negative answer.
|
|
26
|
+
*/
|
|
27
|
+
async function readAppProject(reader) {
|
|
28
|
+
try {
|
|
29
|
+
return await readIfExists(reader);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof DataikuError && error.status === 400)
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isPlainRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
async function assertProjectIncarnation(client, projectKey, expectedHash) {
|
|
41
|
+
const details = await client.projects.get(projectKey);
|
|
42
|
+
const currentHash = projectIncarnationHash(projectKey, details);
|
|
43
|
+
if (currentHash === expectedHash)
|
|
44
|
+
return;
|
|
45
|
+
throw new UsageError(`Project ${projectKey} changed incarnation during successor verification.`, "validation_failed", "Refusing to read or write permissions on a replacement project. Re-run the successor workflow with a new target key.", {
|
|
46
|
+
projectKey,
|
|
47
|
+
expectedProjectIncarnationHash: expectedHash,
|
|
48
|
+
currentProjectIncarnationHash: currentHash ?? null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The authoritative raw manifest version marker. DSS stores a free-form string,
|
|
53
|
+
* so only a non-whitespace string is a usable version: a missing, blank or
|
|
54
|
+
* non-string marker is reported as absent instead of being compared as if it
|
|
55
|
+
* named a release.
|
|
56
|
+
*/
|
|
57
|
+
function rawVersion(manifest) {
|
|
58
|
+
if (!isPlainRecord(manifest))
|
|
59
|
+
return null;
|
|
60
|
+
const value = manifest["version"];
|
|
61
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Project type of the snapshot's project. It comes from the manifest snapshot
|
|
65
|
+
* whenever DSS includes it and only otherwise costs a project-details request,
|
|
66
|
+
* so the ordinary verification path never re-reads the manifest.
|
|
67
|
+
*/
|
|
68
|
+
async function snapshotAppType(client, snapshot, projectKey) {
|
|
69
|
+
const declared = snapshot["projectAppType"];
|
|
70
|
+
if (typeof declared === "string")
|
|
71
|
+
return declared;
|
|
72
|
+
const details = await readIfExists(() => client.projects.get(projectKey));
|
|
73
|
+
const value = details?.["projectAppType"];
|
|
74
|
+
return typeof value === "string" ? value : null;
|
|
75
|
+
}
|
|
76
|
+
function instanceCheck(check, expected, actual, ok, reason) {
|
|
77
|
+
return {
|
|
78
|
+
check,
|
|
79
|
+
status: ok ? "ok" : "failed",
|
|
80
|
+
expected,
|
|
81
|
+
actual,
|
|
82
|
+
...(!ok && reason !== undefined ? { reason, } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Never echoes response bodies: only the taxonomy DataikuError already deems safe. */
|
|
86
|
+
export function safeErrorSummary(error) {
|
|
87
|
+
if (error instanceof DataikuError) {
|
|
88
|
+
return {
|
|
89
|
+
error: error.safeMessage,
|
|
90
|
+
errorStatus: error.status,
|
|
91
|
+
errorCategory: error.category,
|
|
92
|
+
retryable: error.retryable,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { error: error instanceof Error ? error.message : String(error), };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read-only API readiness of one app instance. Only checks that the public REST
|
|
99
|
+
* API can answer authoritatively are performed; the UI gate stays external.
|
|
100
|
+
*
|
|
101
|
+
* The instance manifest is read exactly once: its version, hash and reference
|
|
102
|
+
* validation all describe that one snapshot, so a concurrent manifest edit can
|
|
103
|
+
* never produce a report whose fields contradict each other.
|
|
104
|
+
*/
|
|
105
|
+
export async function verifyAppInstance(client, appId, projectKey, expectVersion) {
|
|
106
|
+
const templateManifest = await client.applications.getAppManifest(appId);
|
|
107
|
+
const templateVersion = rawVersion(templateManifest);
|
|
108
|
+
const expectedVersion = expectVersion ?? templateVersion;
|
|
109
|
+
const instances = await client.applications.listInstances(appId);
|
|
110
|
+
const registered = instances.some((instance) => instance.projectKey === projectKey);
|
|
111
|
+
const raw = await readAppProject(() => client.applications.getInstanceManifest(projectKey));
|
|
112
|
+
const snapshot = isPlainRecord(raw) ? raw : undefined;
|
|
113
|
+
const projectAppType = snapshot ? await snapshotAppType(client, snapshot, projectKey) : null;
|
|
114
|
+
const instanceVersion = rawVersion(snapshot);
|
|
115
|
+
const referenceValidation = snapshot
|
|
116
|
+
? await client.applications.validateAppManifest(snapshot, projectKey)
|
|
117
|
+
: undefined;
|
|
118
|
+
// A template without a usable version is a failed required check even when
|
|
119
|
+
// --expect-version was supplied: without it nothing pins what the instance
|
|
120
|
+
// was supposed to inherit, so an equal pair of strings proves nothing.
|
|
121
|
+
const checks = [
|
|
122
|
+
instanceCheck("project-type", "APP_INSTANCE", projectAppType, projectAppType === "APP_INSTANCE"),
|
|
123
|
+
instanceCheck("app-registration", appId, registered ? appId : null, registered),
|
|
124
|
+
instanceCheck("manifest-version", expectedVersion, instanceVersion, templateVersion !== null && expectedVersion !== null
|
|
125
|
+
&& instanceVersion === expectedVersion, templateVersion === null ? "template-version-missing" : undefined),
|
|
126
|
+
instanceCheck("manifest-references", true, referenceValidation?.valid ?? null, referenceValidation?.valid === true),
|
|
127
|
+
];
|
|
128
|
+
return {
|
|
129
|
+
appId,
|
|
130
|
+
projectKey,
|
|
131
|
+
projectAppType,
|
|
132
|
+
registered,
|
|
133
|
+
templateVersion,
|
|
134
|
+
expectedVersion,
|
|
135
|
+
expectedVersionSource: expectVersion !== undefined ? "expect-version" : "template-manifest",
|
|
136
|
+
instanceVersion,
|
|
137
|
+
instanceManifestHash: snapshot ? stableHash(snapshot) : null,
|
|
138
|
+
templateManifestHash: stableHash(templateManifest),
|
|
139
|
+
referenceValidation: referenceValidation
|
|
140
|
+
? {
|
|
141
|
+
valid: referenceValidation.valid,
|
|
142
|
+
manifestHash: referenceValidation.manifestHash,
|
|
143
|
+
checks: referenceValidation.checks,
|
|
144
|
+
errors: referenceValidation.errors,
|
|
145
|
+
}
|
|
146
|
+
: null,
|
|
147
|
+
checks,
|
|
148
|
+
valid: checks.every((entry) => entry.status === "ok"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const SUCCESSOR_REMEDIATION = "The successor project may exist: replay the recorded cleanup entry, which future-gates deletion when DSS supplied a future ID. Without a cleanup entry, inspect the creation future and wait for it to become terminal before deleting the requested successor project. The predecessor project was not modified.";
|
|
152
|
+
const CREATE_REJECTED_REMEDIATION = "DSS refused the creation request, so no successor project was created: fix the reported problem and retry. The predecessor project was not modified.";
|
|
153
|
+
/**
|
|
154
|
+
* True only when DSS definitively refused a write: a non-retryable 4xx with a
|
|
155
|
+
* classified client-side taxonomy. Transport failures (status 0), 5xx, retryable
|
|
156
|
+
* 4xx (408/425/429) and unclassified statuses leave the outcome unknown, so the
|
|
157
|
+
* effect of the request must be assumed possible.
|
|
158
|
+
*/
|
|
159
|
+
export function isDefinitiveRejection(error) {
|
|
160
|
+
return error instanceof DataikuError && !error.retryable && error.category !== "unknown"
|
|
161
|
+
&& error.status >= 400 && error.status < 500;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Successor identity as reported by the creation future's own result.
|
|
165
|
+
*
|
|
166
|
+
* DSS names the project it created in `targetProjectKey`, and live instances of
|
|
167
|
+
* this future are also observed carrying `projectKey` for the *source template*,
|
|
168
|
+
* so the target-specific field always wins and `projectKey` speaks for the
|
|
169
|
+
* target only when no usable `targetProjectKey` is present. A missing, blank or
|
|
170
|
+
* non-string value is no identity claim at all, exactly as a blank manifest
|
|
171
|
+
* version names no release.
|
|
172
|
+
*/
|
|
173
|
+
export function futureTargetIdentity(result) {
|
|
174
|
+
if (!isPlainRecord(result))
|
|
175
|
+
return null;
|
|
176
|
+
for (const field of ["targetProjectKey", "projectKey",]) {
|
|
177
|
+
const value = result[field];
|
|
178
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
179
|
+
return { projectKey: value.trim(), field, };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Creation cleanup is safe only when the target was proven absent before POST.
|
|
186
|
+
* A hidden 403 is not absence: project listing can prove a collision, but it
|
|
187
|
+
* cannot prove that an inaccessible project does not exist.
|
|
188
|
+
*/
|
|
189
|
+
export async function requireAbsentProjectTarget(client, targetProjectKey, targetFlag) {
|
|
190
|
+
try {
|
|
191
|
+
const existing = await readIfExists(() => client.projects.get(targetProjectKey));
|
|
192
|
+
if (existing === undefined)
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!(error instanceof DataikuError) || error.status !== 403)
|
|
197
|
+
throw error;
|
|
198
|
+
const accessible = await client.projects.list();
|
|
199
|
+
if (!accessible.some((project) => project.projectKey === targetProjectKey)) {
|
|
200
|
+
throw new UsageError(`Could not confirm target project ${targetProjectKey} is absent.`, "validation_failed", "Grant project-list/details visibility or choose a target key whose absence DSS can confirm; creation never risks cleanup against an unproven target.", { targetProjectKey, targetFlag, targetProbe: "forbidden-and-not-listable", });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new UsageError(`Target project ${targetProjectKey} already exists.`, "validation_failed", `Choose an unused ${targetFlag}: app instance creation never writes into an existing project.`, { targetProjectKey, targetFlag, });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Everything that can still refuse the request runs here, before the single
|
|
207
|
+
* POST. Nothing in this function mutates DSS.
|
|
208
|
+
*/
|
|
209
|
+
async function preflightSuccessor(client, request, usage) {
|
|
210
|
+
const { appId, from, to, } = request;
|
|
211
|
+
if (from === to) {
|
|
212
|
+
throw new UsageError("--from and --to must be different project keys: a successor instance is created alongside its predecessor.", "validation_failed", `Usage: ${usage}`, { sourceProjectKey: from, targetProjectKey: to, });
|
|
213
|
+
}
|
|
214
|
+
const instances = await client.applications.listInstances(appId);
|
|
215
|
+
if (!instances.some((instance) => instance.projectKey === from)) {
|
|
216
|
+
throw new UsageError(`Project ${from} is not registered as an instance of app ${appId}.`, "validation_failed", "Check `dss app instances <appId>` and pass the predecessor instance project key to --from.", {
|
|
217
|
+
appId,
|
|
218
|
+
sourceProjectKey: from,
|
|
219
|
+
registeredInstances: instances.map((instance) => instance.projectKey ?? null),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const source = await readAppProject(() => client.applications.getManifestVersion(from));
|
|
223
|
+
if (!source) {
|
|
224
|
+
throw new UsageError(`Project ${from} could not be read as an app project.`, "validation_failed", "--from must name an existing classic Dataiku App instance project (APP_INSTANCE).", { sourceProjectKey: from, });
|
|
225
|
+
}
|
|
226
|
+
if (source.projectAppType !== "APP_INSTANCE") {
|
|
227
|
+
throw new UsageError(`Project ${from} is a ${source.projectAppType ?? "non-app"} project, not a classic Dataiku App instance.`, "validation_failed", "--from must name an APP_INSTANCE project; app templates are versioned with `dss app set-manifest-version`.", { sourceProjectKey: from, projectAppType: source.projectAppType ?? null, });
|
|
228
|
+
}
|
|
229
|
+
await requireAbsentProjectTarget(client, to, "--to");
|
|
230
|
+
const templateManifest = await client.applications.getAppManifest(appId);
|
|
231
|
+
const templateVersion = rawVersion(templateManifest);
|
|
232
|
+
if (templateVersion === null) {
|
|
233
|
+
throw new UsageError(`App template ${appId} has no manifest version, so a successor instance cannot be verified against it.`, "validation_failed", "Set the template version first with `dss app set-manifest-version --manifest-version V --project-key <templateProjectKey>`.", { appId, });
|
|
234
|
+
}
|
|
235
|
+
const sourcePermissions = request.copyPermissions
|
|
236
|
+
? await client.projects.getPermissions(from)
|
|
237
|
+
: undefined;
|
|
238
|
+
return {
|
|
239
|
+
sourceProjectAppType: source.projectAppType,
|
|
240
|
+
sourceVersion: source.version,
|
|
241
|
+
sourceManifestHash: source.manifestHash,
|
|
242
|
+
targetProjectName: request.name ?? to,
|
|
243
|
+
targetExists: false,
|
|
244
|
+
templateManifestHash: stableHash(templateManifest),
|
|
245
|
+
templateVersion,
|
|
246
|
+
...(sourcePermissions
|
|
247
|
+
? {
|
|
248
|
+
sourcePermissions,
|
|
249
|
+
sourcePermissionsHash: appPermissionsHash(sourcePermissions),
|
|
250
|
+
}
|
|
251
|
+
: {}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Create a new app instance from the current template version alongside an
|
|
256
|
+
* existing instance. Classic app instances have no public upgrade endpoint, so
|
|
257
|
+
* the predecessor is never touched: it stays available until it is separately
|
|
258
|
+
* and deliberately retired.
|
|
259
|
+
*
|
|
260
|
+
* After the creation POST this function never throws. A post-POST failure
|
|
261
|
+
* returns a failed-wait result carrying the target project key, so the CLI still
|
|
262
|
+
* exits 4 and the cleanup ledger still records an addressable successor.
|
|
263
|
+
*/
|
|
264
|
+
export async function createSuccessorInstance(client, request, usage) {
|
|
265
|
+
const { appId, from, to, } = request;
|
|
266
|
+
const preflight = await preflightSuccessor(client, request, usage);
|
|
267
|
+
const source = {
|
|
268
|
+
projectKey: from,
|
|
269
|
+
projectAppType: preflight.sourceProjectAppType,
|
|
270
|
+
version: preflight.sourceVersion,
|
|
271
|
+
manifestHash: preflight.sourceManifestHash,
|
|
272
|
+
...(preflight.sourcePermissionsHash
|
|
273
|
+
? { permissionsHash: preflight.sourcePermissionsHash, }
|
|
274
|
+
: {}),
|
|
275
|
+
};
|
|
276
|
+
const shared = {
|
|
277
|
+
appId,
|
|
278
|
+
source,
|
|
279
|
+
templateVersion: preflight.templateVersion,
|
|
280
|
+
templateManifestHash: preflight.templateManifestHash,
|
|
281
|
+
additive: true,
|
|
282
|
+
sourcePreserved: true,
|
|
283
|
+
uiPublicationVerified: false,
|
|
284
|
+
requiredExternalGates: [VISUAL_UI_GATE,],
|
|
285
|
+
targetPreflight: "confirmed-absent",
|
|
286
|
+
};
|
|
287
|
+
if (request.dryRun) {
|
|
288
|
+
return {
|
|
289
|
+
dryRun: true,
|
|
290
|
+
action: "create-successor-instance",
|
|
291
|
+
...shared,
|
|
292
|
+
target: {
|
|
293
|
+
projectKey: to,
|
|
294
|
+
name: preflight.targetProjectName,
|
|
295
|
+
exists: false,
|
|
296
|
+
},
|
|
297
|
+
copyPermissions: request.copyPermissions,
|
|
298
|
+
preflight: "passed",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
let created;
|
|
302
|
+
try {
|
|
303
|
+
created = await client.applications.createInstance(appId, {
|
|
304
|
+
targetProjectKey: to,
|
|
305
|
+
targetProjectName: preflight.targetProjectName,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const rejected = isDefinitiveRejection(error);
|
|
310
|
+
return {
|
|
311
|
+
...shared,
|
|
312
|
+
projectKey: to,
|
|
313
|
+
target: { projectKey: to, name: preflight.targetProjectName, },
|
|
314
|
+
success: false,
|
|
315
|
+
state: "CREATE_FAILED",
|
|
316
|
+
elapsedMs: 0,
|
|
317
|
+
pollCount: 0,
|
|
318
|
+
stage: "create",
|
|
319
|
+
cleanupEligible: !rejected,
|
|
320
|
+
remediation: rejected ? CREATE_REJECTED_REMEDIATION : SUCCESSOR_REMEDIATION,
|
|
321
|
+
...safeErrorSummary(error),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// DSS names the target project in `targetProjectKey` first; `projectKey` is
|
|
325
|
+
// only consulted when the target-specific field is absent, because live
|
|
326
|
+
// responses also echo `projectKey` for the *source* template.
|
|
327
|
+
const instance = isPlainRecord(created) ? created : undefined;
|
|
328
|
+
const jobId = instance ? stringField(instance, ["jobId",]) : undefined;
|
|
329
|
+
const inlineState = instance?.hasResult === true ? instance : undefined;
|
|
330
|
+
const echoedTarget = instance
|
|
331
|
+
? stringField(instance, ["targetProjectKey", "projectKey",])?.trim()
|
|
332
|
+
: undefined;
|
|
333
|
+
if (echoedTarget !== undefined && echoedTarget !== to) {
|
|
334
|
+
return {
|
|
335
|
+
...shared,
|
|
336
|
+
projectKey: to,
|
|
337
|
+
target: { projectKey: to, name: preflight.targetProjectName, },
|
|
338
|
+
success: false,
|
|
339
|
+
state: "VERIFICATION_FAILED",
|
|
340
|
+
elapsedMs: 0,
|
|
341
|
+
pollCount: 0,
|
|
342
|
+
stage: "create",
|
|
343
|
+
...(instance ? { instance, } : {}),
|
|
344
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
345
|
+
cleanupEligible: true,
|
|
346
|
+
error: "DSS named a different target project in the creation response.",
|
|
347
|
+
expected: { projectKey: to, },
|
|
348
|
+
actual: { projectKey: echoedTarget, },
|
|
349
|
+
remediation: SUCCESSOR_REMEDIATION,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const base = {
|
|
353
|
+
...shared,
|
|
354
|
+
projectKey: to,
|
|
355
|
+
target: { projectKey: to, name: preflight.targetProjectName, },
|
|
356
|
+
...(instance ? { instance, } : {}),
|
|
357
|
+
};
|
|
358
|
+
let waited;
|
|
359
|
+
if (inlineState) {
|
|
360
|
+
// `hasResult` on the creation response is the future's own verdict: the
|
|
361
|
+
// answer arrived with the POST, so polling is skipped entirely and the
|
|
362
|
+
// verification below runs on the delivered result.
|
|
363
|
+
waited = {
|
|
364
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
365
|
+
state: "DONE",
|
|
366
|
+
elapsedMs: 0,
|
|
367
|
+
pollCount: 0,
|
|
368
|
+
success: true,
|
|
369
|
+
hasResult: true,
|
|
370
|
+
...(isPlainRecord(inlineState.result) ? { result: inlineState.result, } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
else if (!jobId) {
|
|
374
|
+
return {
|
|
375
|
+
...base,
|
|
376
|
+
...(instance ? {} : {
|
|
377
|
+
responseKind: created === undefined ? "empty" : created === null
|
|
378
|
+
? "null"
|
|
379
|
+
: Array.isArray(created)
|
|
380
|
+
? "array"
|
|
381
|
+
: "scalar",
|
|
382
|
+
}),
|
|
383
|
+
success: false,
|
|
384
|
+
state: "UNTRACKABLE",
|
|
385
|
+
elapsedMs: 0,
|
|
386
|
+
pollCount: 0,
|
|
387
|
+
stage: "future-wait",
|
|
388
|
+
cleanupEligible: true,
|
|
389
|
+
error: "DSS returned no instance-creation future ID, so the successor instance cannot be verified.",
|
|
390
|
+
remediation: SUCCESSOR_REMEDIATION,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const startedAt = Date.now();
|
|
395
|
+
try {
|
|
396
|
+
waited = await client.futures.wait(jobId, {
|
|
397
|
+
pollIntervalMs: request.pollIntervalMs,
|
|
398
|
+
timeoutMs: request.timeoutMs,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
return {
|
|
403
|
+
...base,
|
|
404
|
+
jobId,
|
|
405
|
+
success: false,
|
|
406
|
+
state: "WAIT_FAILED",
|
|
407
|
+
elapsedMs: Date.now() - startedAt,
|
|
408
|
+
pollCount: 0,
|
|
409
|
+
stage: "future-wait",
|
|
410
|
+
cleanupEligible: true,
|
|
411
|
+
remediation: SUCCESSOR_REMEDIATION,
|
|
412
|
+
...safeErrorSummary(error),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// `projectKey` is re-pinned after the future spread: nothing DSS answers can
|
|
417
|
+
// rename the cleanup target away from the requested successor key.
|
|
418
|
+
const waitedBase = { ...base, ...waited, jobId, projectKey: to, };
|
|
419
|
+
// Proof that the terminal creation future named this exact target. It is set
|
|
420
|
+
// once, below, and then carried by every later result: a creation that DSS
|
|
421
|
+
// already settled on this project stays settled even when verification or the
|
|
422
|
+
// permission copy afterwards fails, so the cleanup ledger needs no second
|
|
423
|
+
// future gate to delete the successor.
|
|
424
|
+
let targetEvidence = {};
|
|
425
|
+
const failure = (state, stage, extra) => ({
|
|
426
|
+
...waitedBase,
|
|
427
|
+
...extra,
|
|
428
|
+
success: false,
|
|
429
|
+
state,
|
|
430
|
+
stage,
|
|
431
|
+
cleanupEligible: true,
|
|
432
|
+
remediation: SUCCESSOR_REMEDIATION,
|
|
433
|
+
...targetEvidence,
|
|
434
|
+
});
|
|
435
|
+
// The future's result is the last place DSS can name the project it actually
|
|
436
|
+
// created. Extract that identity even when the terminal future failed: later
|
|
437
|
+
// cleanup should not depend on an ephemeral future once DSS explicitly named
|
|
438
|
+
// this target, while a disagreement must never redirect cleanup.
|
|
439
|
+
const futureTarget = futureTargetIdentity(waited.result);
|
|
440
|
+
if (futureTarget !== null && futureTarget.projectKey !== to) {
|
|
441
|
+
return failure("VERIFICATION_FAILED", "future-target", {
|
|
442
|
+
futureTargetMismatch: true,
|
|
443
|
+
futureTargetField: futureTarget.field,
|
|
444
|
+
expected: { projectKey: to, },
|
|
445
|
+
actual: { projectKey: futureTarget.projectKey, },
|
|
446
|
+
error: "The instance-creation future reported a different successor project than the one requested.",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (futureTarget?.projectKey === to)
|
|
450
|
+
targetEvidence = { futureTargetVerified: true, };
|
|
451
|
+
if (!waited.success) {
|
|
452
|
+
return failure(waited.state, "future-wait", {
|
|
453
|
+
error: "The instance-creation future did not complete successfully.",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const details = await client.projects.get(to);
|
|
458
|
+
const incarnation = projectIncarnationHash(to, details);
|
|
459
|
+
if (!incarnation) {
|
|
460
|
+
return failure("INCARNATION_UNVERIFIED", "project-incarnation", {
|
|
461
|
+
error: "DSS did not return creationTag identity for the successor project. Refusing cleanup without project-incarnation binding.",
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
targetEvidence = { ...targetEvidence, projectIncarnationHash: incarnation, };
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
return failure("INCARNATION_UNVERIFIED", "project-incarnation", safeErrorSummary(error));
|
|
468
|
+
}
|
|
469
|
+
const boundTargetIncarnation = targetEvidence.projectIncarnationHash;
|
|
470
|
+
if (!boundTargetIncarnation) {
|
|
471
|
+
return failure("INCARNATION_UNVERIFIED", "project-incarnation", {
|
|
472
|
+
error: "The successor project incarnation could not be retained for verification.",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
let verification;
|
|
476
|
+
try {
|
|
477
|
+
verification = await verifyAppInstance(client, appId, to, preflight.templateVersion);
|
|
478
|
+
await assertProjectIncarnation(client, to, boundTargetIncarnation);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
return failure("VERIFICATION_FAILED", "verification", safeErrorSummary(error));
|
|
482
|
+
}
|
|
483
|
+
const verified = {
|
|
484
|
+
instanceVersion: verification.instanceVersion,
|
|
485
|
+
instanceManifestHash: verification.instanceManifestHash,
|
|
486
|
+
checks: verification.checks,
|
|
487
|
+
referenceValidation: verification.referenceValidation,
|
|
488
|
+
};
|
|
489
|
+
// The template was pinned during preflight and verification re-reads it. A
|
|
490
|
+
// changed version proves the successor came from a different release; an
|
|
491
|
+
// unchanged version over a changed manifest hash proves the release's own
|
|
492
|
+
// content moved underneath it. Both signals are reported independently so a
|
|
493
|
+
// content-only edit is never dressed up as a version bump, nor the reverse.
|
|
494
|
+
const templateVersionDrift = verification.templateVersion !== preflight.templateVersion;
|
|
495
|
+
const templateManifestDrift = verification.templateManifestHash !== preflight.templateManifestHash;
|
|
496
|
+
if (templateVersionDrift || templateManifestDrift) {
|
|
497
|
+
return failure("VERIFICATION_FAILED", "verification", {
|
|
498
|
+
...verified,
|
|
499
|
+
templateVersionDrift,
|
|
500
|
+
templateManifestDrift,
|
|
501
|
+
expectedTemplateVersion: preflight.templateVersion,
|
|
502
|
+
actualTemplateVersion: verification.templateVersion,
|
|
503
|
+
expectedTemplateManifestHash: preflight.templateManifestHash,
|
|
504
|
+
actualTemplateManifestHash: verification.templateManifestHash,
|
|
505
|
+
error: templateVersionDrift
|
|
506
|
+
? "The template manifest version changed after the successor creation request was accepted."
|
|
507
|
+
: "The template manifest content changed after the successor creation request was accepted, although its version marker did not.",
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
if (!verification.valid) {
|
|
511
|
+
return failure("VERIFICATION_FAILED", "verification", {
|
|
512
|
+
...verified,
|
|
513
|
+
error: "The successor instance did not pass every required API check.",
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const desiredHash = preflight.sourcePermissionsHash;
|
|
517
|
+
const sourcePermissions = preflight.sourcePermissions;
|
|
518
|
+
const copyFailure = (state, summary = {}) => ({
|
|
519
|
+
...verified,
|
|
520
|
+
permissions: {
|
|
521
|
+
requested: true,
|
|
522
|
+
// Never overclaim: only a verified copy reports true; an unresolved
|
|
523
|
+
// PUT outcome or failed verification read reports null, and every
|
|
524
|
+
// stage that can prove no copy happened reports false.
|
|
525
|
+
copied: state === "copied"
|
|
526
|
+
? true
|
|
527
|
+
: state === "unknown" || state === "completed-unverified"
|
|
528
|
+
? null
|
|
529
|
+
: false,
|
|
530
|
+
state,
|
|
531
|
+
sourceHash: desiredHash,
|
|
532
|
+
verified: false,
|
|
533
|
+
...summary,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
let permissions = {
|
|
537
|
+
requested: false,
|
|
538
|
+
copied: false,
|
|
539
|
+
state: "not-requested",
|
|
540
|
+
verified: false,
|
|
541
|
+
};
|
|
542
|
+
if (sourcePermissions && desiredHash) {
|
|
543
|
+
// The stage variable separates failures that happened before the write
|
|
544
|
+
// (the copy was definitely never started) from a definitive PUT rejection,
|
|
545
|
+
// an indeterminate PUT outcome, and a resolved PUT whose verification
|
|
546
|
+
// read failed. "recheck" is the source-ACL drift guard: the predecessor
|
|
547
|
+
// snapshot is hashed again immediately before the target write.
|
|
548
|
+
let stage = "before-read";
|
|
549
|
+
try {
|
|
550
|
+
const before = await client.projects.getPermissions(to);
|
|
551
|
+
await assertProjectIncarnation(client, to, boundTargetIncarnation);
|
|
552
|
+
const beforeHash = appPermissionsHash(before);
|
|
553
|
+
// The predecessor ACL may have changed since preflight. Re-read it
|
|
554
|
+
// even when the target already has the old hash: "unchanged" is only
|
|
555
|
+
// true when source and target still agree at this decision point.
|
|
556
|
+
stage = "recheck";
|
|
557
|
+
const sourceNow = await client.projects.getPermissions(from);
|
|
558
|
+
const sourceNowHash = appPermissionsHash(sourceNow);
|
|
559
|
+
if (sourceNowHash !== desiredHash) {
|
|
560
|
+
return failure("PERMISSIONS_FAILED", "permissions", {
|
|
561
|
+
...copyFailure("not-attempted", {
|
|
562
|
+
beforeHash,
|
|
563
|
+
sourceDrift: true,
|
|
564
|
+
sourceHashNow: sourceNowHash,
|
|
565
|
+
}),
|
|
566
|
+
error: "The predecessor ACL changed after preflight, so the stale snapshot was not copied to the successor.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
await assertProjectIncarnation(client, to, boundTargetIncarnation);
|
|
570
|
+
if (beforeHash === desiredHash) {
|
|
571
|
+
permissions = {
|
|
572
|
+
requested: true,
|
|
573
|
+
copied: false,
|
|
574
|
+
state: "unchanged",
|
|
575
|
+
reason: "unchanged",
|
|
576
|
+
sourceHash: desiredHash,
|
|
577
|
+
beforeHash,
|
|
578
|
+
verifiedHash: beforeHash,
|
|
579
|
+
verified: true,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
stage = "put";
|
|
584
|
+
await client.projects.setPermissions(to, sourceNow);
|
|
585
|
+
stage = "after-read";
|
|
586
|
+
const after = await client.projects.getPermissions(to);
|
|
587
|
+
await assertProjectIncarnation(client, to, boundTargetIncarnation);
|
|
588
|
+
const verifiedHash = appPermissionsHash(after);
|
|
589
|
+
if (verifiedHash !== desiredHash) {
|
|
590
|
+
return failure("PERMISSIONS_FAILED", "permissions", {
|
|
591
|
+
...copyFailure("rejected", {
|
|
592
|
+
beforeHash,
|
|
593
|
+
verifiedHash,
|
|
594
|
+
reason: "verified-hash-mismatch",
|
|
595
|
+
}),
|
|
596
|
+
error: appPermissionsVerificationError(to, {
|
|
597
|
+
beforeHash,
|
|
598
|
+
desiredHash,
|
|
599
|
+
verifiedHash,
|
|
600
|
+
}).message,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
permissions = {
|
|
604
|
+
requested: true,
|
|
605
|
+
copied: true,
|
|
606
|
+
state: "copied",
|
|
607
|
+
sourceHash: desiredHash,
|
|
608
|
+
beforeHash,
|
|
609
|
+
verifiedHash,
|
|
610
|
+
verified: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
return failure("PERMISSIONS_FAILED", "permissions", copyFailure(stage === "before-read" || stage === "recheck"
|
|
616
|
+
? "not-attempted"
|
|
617
|
+
: stage === "put" && isDefinitiveRejection(error)
|
|
618
|
+
? "rejected"
|
|
619
|
+
: stage === "put"
|
|
620
|
+
? "unknown"
|
|
621
|
+
: "completed-unverified", safeErrorSummary(error)));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return { ...waitedBase, ...verified, permissions, ...targetEvidence, };
|
|
625
|
+
}
|