dataiku-sdk 0.12.4 → 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 +76 -18
- 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
|
@@ -1,5 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ClientValidationError, DataikuError, } from "../../errors.js";
|
|
2
|
+
import { projectIncarnationHash, } from "../../utils/project-incarnation.js";
|
|
3
|
+
import { jsonInput, num, parseBooleanOption, plainRecord, requiredJsonInput, requiredStringFlag, stringField, } from "../coerce.js";
|
|
4
|
+
import { appPermissionsHash, appPermissionsVerificationError, assertAppPermissionsSnapshotBinding, buildAppPermissionsSnapshot, diffAppPermissions, readAppPermissionsSnapshot, writeAppPermissionsSnapshot, } from "../helpers/app-permissions.js";
|
|
5
|
+
import { createSuccessorInstance, futureTargetIdentity, isDefinitiveRejection, requireAbsentProjectTarget, safeErrorSummary, verifyAppInstance, VISUAL_UI_GATE, } from "../helpers/app-successor.js";
|
|
6
|
+
import { CommandResultFailure, } from "../output.js";
|
|
7
|
+
import { requireArgs, requireNoArgs, UsageError, } from "../usage.js";
|
|
8
|
+
const CREATE_INSTANCE_USAGE = "dss app create-instance <appId> (--data JSON|--data-file PATH|--stdin) [--wait] [--timeout MS] [--poll-interval MS]";
|
|
9
|
+
const MANIFEST_VERSION_USAGE = "dss app manifest-version [--project-key KEY]";
|
|
10
|
+
const SET_MANIFEST_VERSION_USAGE = "dss app set-manifest-version (--manifest-version V|--version-notes NOTES) [--expect-hash SHA256] [--dry-run] [--project-key KEY]";
|
|
11
|
+
const CREATE_SUCCESSOR_USAGE = "dss app create-successor-instance <appId> --from KEY --to KEY [--name NAME] [--copy-permissions] [--timeout MS] [--poll-interval MS] [--dry-run] [--record-cleanup PATH]";
|
|
12
|
+
const VERIFY_INSTANCE_USAGE = "dss app verify-instance <appId> --project-key KEY [--expect-version V]";
|
|
13
|
+
const DELETE_INSTANCE_USAGE = "dss app delete-instance --project-key KEY [--future-id ID] [--expect-project-incarnation SHA256] [--unconfirmed-creation] [--timeout MS] [--poll-interval MS]";
|
|
14
|
+
const CREATE_INSTANCE_INDETERMINATE_REMEDIATION = "DSS may have accepted the creation, but no future ID proves when it settles. The recorded cleanup entry intentionally stops as unresolved; inspect `dss app instances <appId>` and retry deletion only after creation is known terminal.";
|
|
15
|
+
const CREATE_INSTANCE_REJECTED_REMEDIATION = "DSS refused the creation request, so no instance project was created: fix the reported problem and retry.";
|
|
16
|
+
const DELETE_INSTANCE_FUTURE_REMEDIATION = "The supplied creation future could not authorize deletion. Inspect that future and the target project, then retry only with a future whose terminal result reports this project.";
|
|
17
|
+
const DELETE_INSTANCE_UNCONFIRMED_REMEDIATION = "Creation may still be running and DSS returned no future ID. Inspect the app's instances and wait until creation is known terminal before running delete-instance without --unconfirmed-creation.";
|
|
18
|
+
const DELETE_INSTANCE_DELETE_REMEDIATION = "Future safety checks passed, but the project DELETE outcome is unresolved. Inspect the target project; if it still exists, retry delete-instance.";
|
|
19
|
+
const DELETE_INSTANCE_TARGET_VALIDATION_REMEDIATION = "Deletion was not attempted: validating the target project failed before any DELETE request, so no project was deleted by this command. Fix the reported problem and retry.";
|
|
20
|
+
/**
|
|
21
|
+
* Only a DataikuError whose retry metadata names the DELETE method proves the
|
|
22
|
+
* DELETE request was issued: pre-delete manifest/details GET failures and
|
|
23
|
+
* client-side validation errors carry retry.method "GET" (or none), which
|
|
24
|
+
* means the deletion itself was never attempted.
|
|
25
|
+
*/
|
|
26
|
+
function deleteAttemptedBy(error) {
|
|
27
|
+
return error instanceof DataikuError && error.retry?.method === "DELETE";
|
|
28
|
+
}
|
|
29
|
+
function deleteFailureClassification(error) {
|
|
30
|
+
if (deleteAttemptedBy(error)) {
|
|
31
|
+
return error instanceof ClientValidationError || isDefinitiveRejection(error)
|
|
32
|
+
? { deletePerformed: false, remediation: DELETE_INSTANCE_DELETE_REMEDIATION, }
|
|
33
|
+
: { deletePerformed: null, remediation: DELETE_INSTANCE_DELETE_REMEDIATION, };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
deletePerformed: false,
|
|
37
|
+
remediation: DELETE_INSTANCE_TARGET_VALIDATION_REMEDIATION,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function requireProjectIncarnationHash(client, projectKey) {
|
|
41
|
+
const details = await client.projects.get(projectKey);
|
|
42
|
+
const hash = projectIncarnationHash(projectKey, details);
|
|
43
|
+
if (hash)
|
|
44
|
+
return hash;
|
|
45
|
+
throw new ClientValidationError(`DSS did not return creationTag identity for project ${projectKey}.`, "validation_failed", "Refusing an incarnation-sensitive operation because project-key reuse cannot be excluded.", { projectKey, });
|
|
46
|
+
}
|
|
47
|
+
async function assertProjectIncarnationHash(client, projectKey, expected) {
|
|
48
|
+
const current = await requireProjectIncarnationHash(client, projectKey);
|
|
49
|
+
if (current === expected)
|
|
50
|
+
return;
|
|
51
|
+
throw new ClientValidationError(`Project ${projectKey} is not the same project incarnation recorded for this operation.`, "validation_failed", "Refusing to continue after project-key reuse. Re-read the current project and retry only with an artifact captured from that incarnation.", { projectKey, expectedProjectIncarnationHash: expected, currentProjectIncarnationHash: current, });
|
|
52
|
+
}
|
|
3
53
|
export const appCommands = {
|
|
4
54
|
list: {
|
|
5
55
|
handler: (c) => c.applications.listApps(),
|
|
@@ -25,15 +75,294 @@ export const appCommands = {
|
|
|
25
75
|
description: "List instances created from a Dataiku App template.",
|
|
26
76
|
examples: ["dss app instances my-app",],
|
|
27
77
|
},
|
|
28
|
-
"
|
|
78
|
+
"manifest-version": {
|
|
29
79
|
handler: (c, a, f) => {
|
|
30
|
-
|
|
80
|
+
requireNoArgs(a, MANIFEST_VERSION_USAGE);
|
|
81
|
+
return c.applications.getManifestVersion(f["project-key"]);
|
|
82
|
+
},
|
|
83
|
+
usage: MANIFEST_VERSION_USAGE,
|
|
84
|
+
description: "Read the app-manifest version of a Dataiku App template or app-instance project: the raw persisted string only, never a synthesized default.",
|
|
85
|
+
examples: ["dss app manifest-version --project-key MYAPP_TEMPLATE",],
|
|
86
|
+
},
|
|
87
|
+
"verify-instance": {
|
|
88
|
+
handler: async (c, a, f) => {
|
|
89
|
+
requireArgs(a, 1, VERIFY_INSTANCE_USAGE);
|
|
90
|
+
const projectKey = requiredStringFlag(f, "project-key", VERIFY_INSTANCE_USAGE);
|
|
91
|
+
const result = await verifyAppInstance(c, a[0], projectKey, f["expect-version"]);
|
|
92
|
+
if (!result.valid) {
|
|
93
|
+
throw new UsageError("App instance failed required API checks.", "validation_failed", "Fix the failing checks, or create a new successor instance from the current template version.", { result: { ...result, requiredExternalGates: [VISUAL_UI_GATE,], }, });
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...result,
|
|
97
|
+
status: "API_VERIFIED_UI_PENDING",
|
|
98
|
+
apiReady: true,
|
|
99
|
+
uiPublicationVerified: false,
|
|
100
|
+
requiredExternalGates: [VISUAL_UI_GATE,],
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
usage: VERIFY_INSTANCE_USAGE,
|
|
104
|
+
description: "Read-only API readiness check for an app instance: project type, registration under the app, authoritative raw manifest version, and manifest reference validation. The visual UI remains an explicit external SSO gate.",
|
|
105
|
+
examples: [
|
|
106
|
+
"dss app verify-instance my-app --project-key RELEASE_INSTANCE",
|
|
107
|
+
"dss app verify-instance my-app --project-key RELEASE_INSTANCE --expect-version 2.0.0",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
"create-instance": {
|
|
111
|
+
handler: async (c, a, f) => {
|
|
112
|
+
requireArgs(a, 1, CREATE_INSTANCE_USAGE);
|
|
31
113
|
const body = requiredJsonInput(f, "--data, --data-file, or --stdin is required (instance creation payload).");
|
|
32
|
-
|
|
114
|
+
const requestedProjectKey = stringField(body, ["targetProjectKey",])?.trim();
|
|
115
|
+
if (!requestedProjectKey) {
|
|
116
|
+
throw new UsageError("Instance creation payload must include a non-empty targetProjectKey.", "validation_failed", `Usage: ${CREATE_INSTANCE_USAGE}`);
|
|
117
|
+
}
|
|
118
|
+
body.targetProjectKey = requestedProjectKey;
|
|
119
|
+
await requireAbsentProjectTarget(c, requestedProjectKey, "targetProjectKey");
|
|
120
|
+
let created;
|
|
121
|
+
try {
|
|
122
|
+
created = await c.applications.createInstance(a[0], body);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// A definitive rejection proves nothing was created; anything else
|
|
126
|
+
// (5xx, retryable 4xx, transport failure) leaves the outcome
|
|
127
|
+
// unknown and stays cleanup-eligible against the requested key.
|
|
128
|
+
const rejected = isDefinitiveRejection(error);
|
|
129
|
+
return {
|
|
130
|
+
success: false,
|
|
131
|
+
state: "CREATE_FAILED",
|
|
132
|
+
elapsedMs: 0,
|
|
133
|
+
pollCount: 0,
|
|
134
|
+
projectKey: requestedProjectKey,
|
|
135
|
+
cleanupEligible: !rejected,
|
|
136
|
+
remediation: rejected
|
|
137
|
+
? CREATE_INSTANCE_REJECTED_REMEDIATION
|
|
138
|
+
: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
139
|
+
...safeErrorSummary(error),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const instance = plainRecord(created);
|
|
143
|
+
if (!instance) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
state: "UNTRACKABLE",
|
|
147
|
+
elapsedMs: 0,
|
|
148
|
+
pollCount: 0,
|
|
149
|
+
projectKey: requestedProjectKey,
|
|
150
|
+
cleanupEligible: true,
|
|
151
|
+
responseKind: created === undefined
|
|
152
|
+
? "empty"
|
|
153
|
+
: created === null
|
|
154
|
+
? "null"
|
|
155
|
+
: Array.isArray(created)
|
|
156
|
+
? "array"
|
|
157
|
+
: "scalar",
|
|
158
|
+
error: "DSS accepted instance creation without returning a structured creation state.",
|
|
159
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// The create endpoint's `jobId` is a DSS future ID (the official client wraps it in
|
|
163
|
+
// DSSFuture), not a project Flow job ID.
|
|
164
|
+
const jobId = stringField(instance, ["jobId",]);
|
|
165
|
+
// The requested key stays authoritative: a response that names a
|
|
166
|
+
// different project fails instead of redirecting output or cleanup.
|
|
167
|
+
const echoedTarget = stringField(instance, ["targetProjectKey", "projectKey",])?.trim();
|
|
168
|
+
if (echoedTarget !== undefined && echoedTarget !== requestedProjectKey) {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
state: "VERIFICATION_FAILED",
|
|
172
|
+
elapsedMs: 0,
|
|
173
|
+
pollCount: 0,
|
|
174
|
+
projectKey: requestedProjectKey,
|
|
175
|
+
instance,
|
|
176
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
177
|
+
cleanupEligible: true,
|
|
178
|
+
error: "DSS named a different target project in the creation response.",
|
|
179
|
+
expected: { projectKey: requestedProjectKey, },
|
|
180
|
+
actual: { projectKey: echoedTarget, },
|
|
181
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (instance["hasResult"] === true) {
|
|
185
|
+
const futureTarget = futureTargetIdentity(instance.result);
|
|
186
|
+
if (futureTarget !== null && futureTarget.projectKey !== requestedProjectKey) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
state: "VERIFICATION_FAILED",
|
|
190
|
+
elapsedMs: 0,
|
|
191
|
+
pollCount: 0,
|
|
192
|
+
projectKey: requestedProjectKey,
|
|
193
|
+
instance,
|
|
194
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
195
|
+
cleanupEligible: true,
|
|
196
|
+
error: "The instance-creation future named a different target project.",
|
|
197
|
+
expected: { projectKey: requestedProjectKey, },
|
|
198
|
+
actual: {
|
|
199
|
+
projectKey: futureTarget.projectKey,
|
|
200
|
+
field: futureTarget.field,
|
|
201
|
+
},
|
|
202
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
let projectIncarnation;
|
|
206
|
+
try {
|
|
207
|
+
projectIncarnation = await requireProjectIncarnationHash(c, requestedProjectKey);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
state: "INCARNATION_UNVERIFIED",
|
|
213
|
+
hasResult: true,
|
|
214
|
+
elapsedMs: 0,
|
|
215
|
+
pollCount: 0,
|
|
216
|
+
projectKey: requestedProjectKey,
|
|
217
|
+
instance,
|
|
218
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
219
|
+
...(futureTarget?.projectKey === requestedProjectKey
|
|
220
|
+
? { futureTargetVerified: true, }
|
|
221
|
+
: {}),
|
|
222
|
+
cleanupEligible: true,
|
|
223
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
224
|
+
...safeErrorSummary(error),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Inline completion: the future answered with the POST itself, so
|
|
228
|
+
// there is nothing to poll and no live future left to clean up.
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
state: "DONE",
|
|
232
|
+
hasResult: true,
|
|
233
|
+
elapsedMs: 0,
|
|
234
|
+
pollCount: 0,
|
|
235
|
+
projectKey: requestedProjectKey,
|
|
236
|
+
instance,
|
|
237
|
+
projectIncarnationHash: projectIncarnation,
|
|
238
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
239
|
+
...(futureTarget?.projectKey === requestedProjectKey
|
|
240
|
+
? { futureTargetVerified: true, }
|
|
241
|
+
: {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (f["wait"] !== true) {
|
|
245
|
+
return {
|
|
246
|
+
projectKey: requestedProjectKey,
|
|
247
|
+
instance,
|
|
248
|
+
...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (!jobId) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
state: "UNTRACKABLE",
|
|
255
|
+
elapsedMs: 0,
|
|
256
|
+
pollCount: 0,
|
|
257
|
+
projectKey: requestedProjectKey,
|
|
258
|
+
instance,
|
|
259
|
+
cleanupEligible: true,
|
|
260
|
+
error: "DSS returned no instance-creation future ID. The instance may still have been created; inspect app instances or replay the recorded cleanup entry.",
|
|
261
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
let waited;
|
|
265
|
+
try {
|
|
266
|
+
waited = await c.futures.wait(jobId, {
|
|
267
|
+
pollIntervalMs: num(f["poll-interval"]),
|
|
268
|
+
timeoutMs: num(f["timeout"]),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
success: false,
|
|
274
|
+
state: "WAIT_FAILED",
|
|
275
|
+
elapsedMs: 0,
|
|
276
|
+
pollCount: 0,
|
|
277
|
+
projectKey: requestedProjectKey,
|
|
278
|
+
instance,
|
|
279
|
+
jobId,
|
|
280
|
+
cleanupEligible: true,
|
|
281
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
282
|
+
...safeErrorSummary(error),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const waitedTarget = futureTargetIdentity(waited.result);
|
|
286
|
+
if (waitedTarget !== null && waitedTarget.projectKey !== requestedProjectKey) {
|
|
287
|
+
return {
|
|
288
|
+
...waited,
|
|
289
|
+
success: false,
|
|
290
|
+
state: "VERIFICATION_FAILED",
|
|
291
|
+
projectKey: requestedProjectKey,
|
|
292
|
+
instance,
|
|
293
|
+
jobId,
|
|
294
|
+
cleanupEligible: true,
|
|
295
|
+
error: "The instance-creation future named a different target project.",
|
|
296
|
+
expected: { projectKey: requestedProjectKey, },
|
|
297
|
+
actual: {
|
|
298
|
+
projectKey: waitedTarget.projectKey,
|
|
299
|
+
field: waitedTarget.field,
|
|
300
|
+
},
|
|
301
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
let projectIncarnation;
|
|
305
|
+
if (waited.success) {
|
|
306
|
+
try {
|
|
307
|
+
projectIncarnation = await requireProjectIncarnationHash(c, requestedProjectKey);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
...waited,
|
|
312
|
+
success: false,
|
|
313
|
+
state: "INCARNATION_UNVERIFIED",
|
|
314
|
+
projectKey: requestedProjectKey,
|
|
315
|
+
instance,
|
|
316
|
+
jobId,
|
|
317
|
+
...(waitedTarget?.projectKey === requestedProjectKey
|
|
318
|
+
? { futureTargetVerified: true, }
|
|
319
|
+
: {}),
|
|
320
|
+
cleanupEligible: true,
|
|
321
|
+
remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
|
|
322
|
+
...safeErrorSummary(error),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
...waited,
|
|
328
|
+
instance,
|
|
329
|
+
jobId,
|
|
330
|
+
projectKey: requestedProjectKey,
|
|
331
|
+
...(projectIncarnation !== undefined
|
|
332
|
+
? { projectIncarnationHash: projectIncarnation, }
|
|
333
|
+
: {}),
|
|
334
|
+
...(waitedTarget?.projectKey === requestedProjectKey
|
|
335
|
+
? { futureTargetVerified: true, }
|
|
336
|
+
: {}),
|
|
337
|
+
};
|
|
33
338
|
},
|
|
34
|
-
usage:
|
|
35
|
-
description: "Create an app instance from a Dataiku App template.",
|
|
36
|
-
examples: [
|
|
339
|
+
usage: CREATE_INSTANCE_USAGE,
|
|
340
|
+
description: "Create an app instance from a Dataiku App template, optionally waiting for the instance-creation future returned by DSS.",
|
|
341
|
+
examples: [
|
|
342
|
+
'dss app create-instance my-app --data \'{"targetProjectKey":"NEWPROJ"}\'',
|
|
343
|
+
'dss app create-instance my-app --data \'{"targetProjectKey":"NEWPROJ"}\' --wait --timeout 120000 --poll-interval 2000',
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
"create-successor-instance": {
|
|
347
|
+
handler: async (c, a, f) => {
|
|
348
|
+
requireArgs(a, 1, CREATE_SUCCESSOR_USAGE);
|
|
349
|
+
return createSuccessorInstance(c, {
|
|
350
|
+
appId: a[0],
|
|
351
|
+
from: requiredStringFlag(f, "from", CREATE_SUCCESSOR_USAGE),
|
|
352
|
+
to: requiredStringFlag(f, "to", CREATE_SUCCESSOR_USAGE),
|
|
353
|
+
...(f["name"] !== undefined ? { name: f["name"], } : {}),
|
|
354
|
+
copyPermissions: parseBooleanOption(f["copy-permissions"], "--copy-permissions") ?? false,
|
|
355
|
+
dryRun: parseBooleanOption(f["dry-run"], "--dry-run") ?? false,
|
|
356
|
+
timeoutMs: num(f["timeout"]),
|
|
357
|
+
pollIntervalMs: num(f["poll-interval"]),
|
|
358
|
+
}, CREATE_SUCCESSOR_USAGE);
|
|
359
|
+
},
|
|
360
|
+
usage: CREATE_SUCCESSOR_USAGE,
|
|
361
|
+
description: "Create a new app instance from the current template version alongside an existing instance. The predecessor is never modified and is retired separately and deliberately; the creation future is always awaited.",
|
|
362
|
+
examples: [
|
|
363
|
+
"dss app create-successor-instance my-app --from OLD_INSTANCE --to NEW_INSTANCE",
|
|
364
|
+
'dss app create-successor-instance my-app --from OLD_INSTANCE --to NEW_INSTANCE --name "Release 2" --copy-permissions',
|
|
365
|
+
],
|
|
37
366
|
},
|
|
38
367
|
"instance-manifest": {
|
|
39
368
|
handler: (c, a, f) => {
|
|
@@ -56,15 +385,271 @@ export const appCommands = {
|
|
|
56
385
|
"dss app save-instance-manifest --data-file manifest.json --project-key MYAPP_TEMPLATE",
|
|
57
386
|
],
|
|
58
387
|
},
|
|
388
|
+
"set-manifest-version": {
|
|
389
|
+
handler: async (c, a, f) => {
|
|
390
|
+
requireNoArgs(a, SET_MANIFEST_VERSION_USAGE);
|
|
391
|
+
const version = f["manifest-version"];
|
|
392
|
+
const versionNotes = f["version-notes"];
|
|
393
|
+
if (version === undefined && versionNotes === undefined) {
|
|
394
|
+
throw new UsageError("At least one of --manifest-version or --version-notes is required.", "usage_error", `Usage: ${SET_MANIFEST_VERSION_USAGE}`);
|
|
395
|
+
}
|
|
396
|
+
// Shape validation runs before any client access: a batch dry-run
|
|
397
|
+
// executes handlers against a throwing proxy client, so invalid
|
|
398
|
+
// versions and hashes must fail here to keep the dry-run truthful.
|
|
399
|
+
if (version !== undefined
|
|
400
|
+
&& (typeof version !== "string" || version.trim() === "")) {
|
|
401
|
+
throw new ClientValidationError("App manifest version must be a non-empty string.", "validation_failed", "Pass the version string DSS should display in the Application header, for example 1.2.0.", { projectKey: f["project-key"] ?? null, });
|
|
402
|
+
}
|
|
403
|
+
if (versionNotes !== undefined && typeof versionNotes !== "string") {
|
|
404
|
+
throw new ClientValidationError("App manifest version notes must be a string.", "validation_failed", "Pass release notes as text, or an empty string to clear them.", { projectKey: f["project-key"] ?? null, });
|
|
405
|
+
}
|
|
406
|
+
const expectHash = f["expect-hash"];
|
|
407
|
+
if (expectHash !== undefined
|
|
408
|
+
&& (typeof expectHash !== "string" || !/^[0-9a-fA-F]{64}$/.test(expectHash))) {
|
|
409
|
+
throw new ClientValidationError("Expected manifest hash must be a 64-character SHA-256 hex digest.", "validation_failed", "Use the manifestHash value returned by the manifest-version read.", { projectKey: f["project-key"] ?? null, });
|
|
410
|
+
}
|
|
411
|
+
const result = await c.applications.setManifestVersion({
|
|
412
|
+
...(version !== undefined ? { version, } : {}),
|
|
413
|
+
...(versionNotes !== undefined ? { versionNotes, } : {}),
|
|
414
|
+
...(f["expect-hash"] !== undefined ? { expectHash: f["expect-hash"], } : {}),
|
|
415
|
+
dryRun: parseBooleanOption(f["dry-run"], "--dry-run") ?? false,
|
|
416
|
+
}, f["project-key"]);
|
|
417
|
+
if (result.outcome === "indeterminate") {
|
|
418
|
+
// The PUT may or may not have landed. Fail nonzero without
|
|
419
|
+
// claiming publication: the structured result travels in the
|
|
420
|
+
// error details, and direct/batch runs surface exit code 2.
|
|
421
|
+
throw new CommandResultFailure({
|
|
422
|
+
...result,
|
|
423
|
+
publicationTransport: "public-app-manifest",
|
|
424
|
+
uiPublicationVerified: false,
|
|
425
|
+
}, 2, "ambiguous_outcome");
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
...result,
|
|
429
|
+
publicationTransport: "public-app-manifest",
|
|
430
|
+
uiPublicationVerified: false,
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
usage: SET_MANIFEST_VERSION_USAGE,
|
|
434
|
+
description: "Set the raw app-manifest version and/or version notes of a Dataiku App template through the public app-manifest endpoint. This is not a publish transaction: the persisted string becomes the template version that new instances inherit, and the visual UI remains an external gate.",
|
|
435
|
+
examples: [
|
|
436
|
+
"dss app set-manifest-version --manifest-version 2.0.0 --project-key MYAPP_TEMPLATE",
|
|
437
|
+
'dss app set-manifest-version --version-notes "Release 2" --expect-hash 480039fce035bfcd98740cb4fd9e67763b8ba68cac43d16a6e25bd9abb7548e8 --dry-run',
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
"validate-manifest": {
|
|
441
|
+
handler: async (c, a, f) => {
|
|
442
|
+
requireNoArgs(a, "dss app validate-manifest [--data JSON|--data-file PATH|--stdin] [--project-key KEY]");
|
|
443
|
+
const payload = jsonInput(f);
|
|
444
|
+
const manifest = payload ?? await c.applications.getInstanceManifest(f["project-key"]);
|
|
445
|
+
const result = await c.applications.validateAppManifest(manifest, f["project-key"]);
|
|
446
|
+
if (!result.valid) {
|
|
447
|
+
throw new UsageError("App manifest validation failed.", "validation_failed", undefined, { result, });
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
},
|
|
451
|
+
usage: "dss app validate-manifest [--data JSON|--data-file PATH|--stdin] [--project-key KEY]",
|
|
452
|
+
description: "Validate an app manifest against source-verifiable reference data (scenario IDs, managed-folder IDs, variable names). Without a payload, reads the target project's app manifest.",
|
|
453
|
+
examples: [
|
|
454
|
+
"dss app validate-manifest --data-file manifest.json --project-key MYAPP_TEMPLATE",
|
|
455
|
+
"dss app validate-manifest --project-key MYAPP_TEMPLATE",
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
"compare-manifest": {
|
|
459
|
+
handler: (c, a, f) => {
|
|
460
|
+
requireArgs(a, 1, "dss app compare-manifest <appId> --project-key KEY");
|
|
461
|
+
const projectKey = requiredStringFlag(f, "project-key", "dss app compare-manifest <appId> --project-key KEY");
|
|
462
|
+
return c.applications.compareAppManifest(a[0], projectKey);
|
|
463
|
+
},
|
|
464
|
+
usage: "dss app compare-manifest <appId> --project-key KEY",
|
|
465
|
+
description: "Compare a Dataiku App template with the target app instance (normalized hashes and deterministic path-level differences; only project identity fields are omitted).",
|
|
466
|
+
examples: ["dss app compare-manifest my-app --project-key MYINSTANCE",],
|
|
467
|
+
},
|
|
59
468
|
"delete-instance": {
|
|
60
469
|
handler: async (c, a, f) => {
|
|
61
|
-
requireNoArgs(a,
|
|
62
|
-
|
|
63
|
-
|
|
470
|
+
requireNoArgs(a, DELETE_INSTANCE_USAGE);
|
|
471
|
+
const projectKey = requiredStringFlag(f, "project-key", DELETE_INSTANCE_USAGE);
|
|
472
|
+
const futureId = f["future-id"] === undefined
|
|
473
|
+
? undefined
|
|
474
|
+
: requiredStringFlag(f, "future-id", DELETE_INSTANCE_USAGE);
|
|
475
|
+
const expectedProjectIncarnation = f["expect-project-incarnation"] === undefined
|
|
476
|
+
? undefined
|
|
477
|
+
: requiredStringFlag(f, "expect-project-incarnation", DELETE_INSTANCE_USAGE);
|
|
478
|
+
if (expectedProjectIncarnation !== undefined
|
|
479
|
+
&& !/^[0-9a-f]{64}$/.test(expectedProjectIncarnation)) {
|
|
480
|
+
throw new UsageError("--expect-project-incarnation must be a 64-character lowercase SHA-256 hash.", "validation_failed");
|
|
481
|
+
}
|
|
482
|
+
const unconfirmedCreation = parseBooleanOption(f["unconfirmed-creation"], "--unconfirmed-creation") ?? false;
|
|
483
|
+
if (unconfirmedCreation) {
|
|
484
|
+
return {
|
|
485
|
+
success: false,
|
|
486
|
+
state: "UNCONFIRMED_CREATION",
|
|
487
|
+
elapsedMs: 0,
|
|
488
|
+
pollCount: 0,
|
|
489
|
+
projectKey,
|
|
490
|
+
...(futureId !== undefined ? { futureId, } : {}),
|
|
491
|
+
deletePerformed: false,
|
|
492
|
+
cleanupResolved: false,
|
|
493
|
+
error: "Deletion cannot be automated because DSS returned no creation future ID and creation may still be running.",
|
|
494
|
+
remediation: DELETE_INSTANCE_UNCONFIRMED_REMEDIATION,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (futureId === undefined) {
|
|
498
|
+
try {
|
|
499
|
+
await c.applications.deleteInstance(projectKey, expectedProjectIncarnation === undefined
|
|
500
|
+
? undefined
|
|
501
|
+
: { expectedProjectIncarnationHash: expectedProjectIncarnation, });
|
|
502
|
+
return { deleted: true, projectKey, };
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (error instanceof DataikuError && error.status === 404) {
|
|
506
|
+
return { deleted: false, alreadyAbsent: true, projectKey, };
|
|
507
|
+
}
|
|
508
|
+
const classification = deleteFailureClassification(error);
|
|
509
|
+
return {
|
|
510
|
+
success: false,
|
|
511
|
+
state: "DELETE_FAILED",
|
|
512
|
+
elapsedMs: 0,
|
|
513
|
+
pollCount: 0,
|
|
514
|
+
projectKey,
|
|
515
|
+
deletePerformed: classification.deletePerformed,
|
|
516
|
+
remediation: classification.remediation,
|
|
517
|
+
...safeErrorSummary(error),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Reject an existing ordinary/template target before the supplied
|
|
522
|
+
// future is touched. A missing target is allowed: its real creation
|
|
523
|
+
// future may not have materialized the project yet.
|
|
524
|
+
try {
|
|
525
|
+
const manifest = await c.applications.getInstanceManifest(projectKey);
|
|
526
|
+
const details = typeof manifest.projectAppType === "string"
|
|
527
|
+
? undefined
|
|
528
|
+
: await c.projects.get(projectKey);
|
|
529
|
+
const projectAppType = typeof manifest.projectAppType === "string"
|
|
530
|
+
? manifest.projectAppType
|
|
531
|
+
: details?.projectAppType;
|
|
532
|
+
if (projectAppType !== "APP_INSTANCE") {
|
|
533
|
+
throw new ClientValidationError("Only classic Dataiku App instance projects can be deleted through app delete-instance.", "validation_failed", "Use `dss app delete-instance` only for APP_INSTANCE projects; use `dss project delete` for templates or ordinary projects.", { projectAppType: projectAppType ?? null, projectKey, });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (error instanceof DataikuError && error.status === 404) {
|
|
538
|
+
// The project may not exist yet while its verified creation
|
|
539
|
+
// future is still live; let the future decide terminality.
|
|
540
|
+
}
|
|
541
|
+
else if (error instanceof DataikuError && error.status === 400) {
|
|
542
|
+
throw new ClientValidationError("Only classic Dataiku App instance projects can be deleted through app delete-instance.", "validation_failed", "Use `dss app delete-instance` only for APP_INSTANCE projects; use `dss project delete` for templates or ordinary projects.", { projectAppType: null, projectKey, });
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Waiting is read-only. The CLI never aborts a caller-supplied future:
|
|
549
|
+
// a mismatched ID must not be able to cancel unrelated DSS work.
|
|
550
|
+
let waited;
|
|
551
|
+
try {
|
|
552
|
+
waited = await c.futures.wait(futureId, {
|
|
553
|
+
pollIntervalMs: num(f["poll-interval"]),
|
|
554
|
+
timeoutMs: num(f["timeout"]),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
const absent = error instanceof DataikuError && error.status === 404;
|
|
559
|
+
return {
|
|
560
|
+
success: false,
|
|
561
|
+
state: absent ? "FUTURE_UNVERIFIABLE" : "FUTURE_WAIT_FAILED",
|
|
562
|
+
elapsedMs: 0,
|
|
563
|
+
pollCount: 0,
|
|
564
|
+
futureId,
|
|
565
|
+
projectKey,
|
|
566
|
+
deletePerformed: false,
|
|
567
|
+
remediation: DELETE_INSTANCE_FUTURE_REMEDIATION,
|
|
568
|
+
...(absent
|
|
569
|
+
? {
|
|
570
|
+
error: "The supplied creation future no longer exists, so its target identity cannot be verified.",
|
|
571
|
+
}
|
|
572
|
+
: safeErrorSummary(error)),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (waited.timedOut === true) {
|
|
576
|
+
return {
|
|
577
|
+
...waited,
|
|
578
|
+
success: false,
|
|
579
|
+
state: "FUTURE_STILL_RUNNING",
|
|
580
|
+
projectKey,
|
|
581
|
+
deletePerformed: false,
|
|
582
|
+
terminal: false,
|
|
583
|
+
error: "The instance-creation future is still running, so the instance project was not deleted.",
|
|
584
|
+
remediation: DELETE_INSTANCE_FUTURE_REMEDIATION,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const futureTarget = futureTargetIdentity(waited.result);
|
|
588
|
+
if (futureTarget === null || futureTarget.projectKey !== projectKey) {
|
|
589
|
+
return {
|
|
590
|
+
...waited,
|
|
591
|
+
success: false,
|
|
592
|
+
state: futureTarget === null
|
|
593
|
+
? "FUTURE_TARGET_UNVERIFIED"
|
|
594
|
+
: "VERIFICATION_FAILED",
|
|
595
|
+
projectKey,
|
|
596
|
+
deletePerformed: false,
|
|
597
|
+
error: futureTarget === null
|
|
598
|
+
? "The terminal future did not report a target project, so it cannot authorize deletion."
|
|
599
|
+
: "The terminal future names a different target project.",
|
|
600
|
+
expected: { projectKey, },
|
|
601
|
+
actual: futureTarget === null
|
|
602
|
+
? { projectKey: null, }
|
|
603
|
+
: { projectKey: futureTarget.projectKey, field: futureTarget.field, },
|
|
604
|
+
remediation: DELETE_INSTANCE_FUTURE_REMEDIATION,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
if (expectedProjectIncarnation === undefined) {
|
|
608
|
+
throw new UsageError("--future-id requires --expect-project-incarnation so a completed future cannot authorize deletion of a replacement project that reused the same key.", "validation_failed");
|
|
609
|
+
}
|
|
610
|
+
let deleted;
|
|
611
|
+
try {
|
|
612
|
+
await c.applications.deleteInstance(projectKey, {
|
|
613
|
+
expectedProjectIncarnationHash: expectedProjectIncarnation,
|
|
614
|
+
});
|
|
615
|
+
deleted = true;
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (error instanceof DataikuError && error.status === 404) {
|
|
619
|
+
deleted = false;
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
const classification = deleteFailureClassification(error);
|
|
623
|
+
return {
|
|
624
|
+
success: false,
|
|
625
|
+
state: "DELETE_FAILED",
|
|
626
|
+
elapsedMs: waited.elapsedMs,
|
|
627
|
+
pollCount: waited.pollCount,
|
|
628
|
+
futureId,
|
|
629
|
+
projectKey,
|
|
630
|
+
deletePerformed: classification.deletePerformed,
|
|
631
|
+
remediation: classification.remediation,
|
|
632
|
+
...safeErrorSummary(error),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
deleted,
|
|
638
|
+
...(deleted ? {} : { alreadyAbsent: true, }),
|
|
639
|
+
success: true,
|
|
640
|
+
projectKey,
|
|
641
|
+
futureId,
|
|
642
|
+
futureState: waited.state,
|
|
643
|
+
elapsedMs: waited.elapsedMs,
|
|
644
|
+
pollCount: waited.pollCount,
|
|
645
|
+
};
|
|
64
646
|
},
|
|
65
|
-
usage:
|
|
66
|
-
description: "Delete an app-instance project (destructive: removes the instance project).",
|
|
67
|
-
examples: [
|
|
647
|
+
usage: DELETE_INSTANCE_USAGE,
|
|
648
|
+
description: "Delete an app-instance project (destructive: removes the instance project). With --future-id, requires the recorded project-incarnation hash, waits without aborting, verifies that the terminal future reports this target, rechecks the incarnation, then deletes.",
|
|
649
|
+
examples: [
|
|
650
|
+
"dss app delete-instance --project-key MYINSTANCE",
|
|
651
|
+
"dss app delete-instance --project-key MYINSTANCE --future-id dss-future-42 --expect-project-incarnation 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --timeout 30000 --poll-interval 2000",
|
|
652
|
+
],
|
|
68
653
|
},
|
|
69
654
|
"business-app-instance-permissions": {
|
|
70
655
|
handler: (c, a) => {
|
|
@@ -75,4 +660,114 @@ export const appCommands = {
|
|
|
75
660
|
description: "Get a user's effective permissions on a Business App instance.",
|
|
76
661
|
examples: ["dss app business-app-instance-permissions my-bapp INSTANCEPROJ alice",],
|
|
77
662
|
},
|
|
663
|
+
"permissions-snapshot": {
|
|
664
|
+
handler: async (c, _a, f) => {
|
|
665
|
+
const usage = "dss app permissions-snapshot --output PATH [--project-key KEY]";
|
|
666
|
+
requireNoArgs(_a, usage);
|
|
667
|
+
const output = requiredStringFlag(f, "output", usage);
|
|
668
|
+
const projectKey = c.resolveProjectKey(f["project-key"]);
|
|
669
|
+
const projectIncarnation = await requireProjectIncarnationHash(c, projectKey);
|
|
670
|
+
const permissions = await c.projects.getPermissions(projectKey);
|
|
671
|
+
await assertProjectIncarnationHash(c, projectKey, projectIncarnation);
|
|
672
|
+
const snapshot = buildAppPermissionsSnapshot(projectKey, c.getBaseUrl(), projectIncarnation, permissions);
|
|
673
|
+
writeAppPermissionsSnapshot(output, snapshot);
|
|
674
|
+
return {
|
|
675
|
+
output,
|
|
676
|
+
projectKey,
|
|
677
|
+
dssUrl: snapshot.dssUrl,
|
|
678
|
+
hash: snapshot.hash,
|
|
679
|
+
projectIncarnationHash: snapshot.projectIncarnationHash,
|
|
680
|
+
permissionsHash: snapshot.permissionsHash,
|
|
681
|
+
capturedAt: snapshot.capturedAt,
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
usage: "dss app permissions-snapshot --output PATH [--project-key KEY]",
|
|
685
|
+
description: "Snapshot a project's access-control permissions to a local owner-only JSON file with a canonical integrity hash; commit it only when repository policy permits.",
|
|
686
|
+
examples: [
|
|
687
|
+
"dss app permissions-snapshot --output app-permissions.json --project-key MYINSTANCE",
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
"permissions-diff": {
|
|
691
|
+
handler: async (c, _a, f) => {
|
|
692
|
+
const usage = "dss app permissions-diff --file PATH [--project-key KEY]";
|
|
693
|
+
requireNoArgs(_a, usage);
|
|
694
|
+
const file = requiredStringFlag(f, "file", usage);
|
|
695
|
+
const snapshot = readAppPermissionsSnapshot(file);
|
|
696
|
+
const projectKey = c.resolveProjectKey(f["project-key"]);
|
|
697
|
+
assertAppPermissionsSnapshotBinding(snapshot, projectKey, c.getBaseUrl());
|
|
698
|
+
const currentProjectIncarnationHash = await requireProjectIncarnationHash(c, projectKey);
|
|
699
|
+
assertAppPermissionsSnapshotBinding(snapshot, projectKey, c.getBaseUrl(), currentProjectIncarnationHash);
|
|
700
|
+
const current = await c.projects.getPermissions(projectKey);
|
|
701
|
+
await assertProjectIncarnationHash(c, projectKey, snapshot.projectIncarnationHash);
|
|
702
|
+
const currentHash = appPermissionsHash(current);
|
|
703
|
+
const diff = diffAppPermissions(snapshot.permissions, current);
|
|
704
|
+
return {
|
|
705
|
+
projectKey,
|
|
706
|
+
backupHash: snapshot.permissionsHash,
|
|
707
|
+
currentHash,
|
|
708
|
+
changed: diff.changed,
|
|
709
|
+
differences: diff.differences,
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
usage: "dss app permissions-diff --file PATH [--project-key KEY]",
|
|
713
|
+
description: "Compare a permission snapshot file against the live project permissions, returning deterministic path-level differences.",
|
|
714
|
+
examples: ["dss app permissions-diff --file app-permissions.json --project-key MYINSTANCE",],
|
|
715
|
+
},
|
|
716
|
+
"permissions-restore": {
|
|
717
|
+
handler: async (c, _a, f) => {
|
|
718
|
+
const usage = "dss app permissions-restore --file PATH [--project-key KEY] [--dry-run]";
|
|
719
|
+
requireNoArgs(_a, usage);
|
|
720
|
+
const file = requiredStringFlag(f, "file", usage);
|
|
721
|
+
const snapshot = readAppPermissionsSnapshot(file);
|
|
722
|
+
const projectKey = c.resolveProjectKey(f["project-key"]);
|
|
723
|
+
assertAppPermissionsSnapshotBinding(snapshot, projectKey, c.getBaseUrl());
|
|
724
|
+
const currentProjectIncarnationHash = await requireProjectIncarnationHash(c, projectKey);
|
|
725
|
+
assertAppPermissionsSnapshotBinding(snapshot, projectKey, c.getBaseUrl(), currentProjectIncarnationHash);
|
|
726
|
+
const dryRun = parseBooleanOption(f["dry-run"], "--dry-run") ?? false;
|
|
727
|
+
const before = await c.projects.getPermissions(projectKey);
|
|
728
|
+
await assertProjectIncarnationHash(c, projectKey, snapshot.projectIncarnationHash);
|
|
729
|
+
const beforeHash = appPermissionsHash(before);
|
|
730
|
+
const desiredHash = snapshot.permissionsHash;
|
|
731
|
+
if (beforeHash === desiredHash) {
|
|
732
|
+
return {
|
|
733
|
+
projectKey,
|
|
734
|
+
beforeHash,
|
|
735
|
+
desiredHash,
|
|
736
|
+
verifiedHash: beforeHash,
|
|
737
|
+
applied: false,
|
|
738
|
+
reason: "unchanged",
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (dryRun) {
|
|
742
|
+
return {
|
|
743
|
+
dryRun: true,
|
|
744
|
+
projectKey,
|
|
745
|
+
beforeHash,
|
|
746
|
+
desiredHash,
|
|
747
|
+
verifiedHash: null,
|
|
748
|
+
applied: false,
|
|
749
|
+
differences: diffAppPermissions(snapshot.permissions, before).differences,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
await c.projects.setPermissions(projectKey, snapshot.permissions);
|
|
753
|
+
const after = await c.projects.getPermissions(projectKey);
|
|
754
|
+
await assertProjectIncarnationHash(c, projectKey, snapshot.projectIncarnationHash);
|
|
755
|
+
const verifiedHash = appPermissionsHash(after);
|
|
756
|
+
if (verifiedHash !== desiredHash) {
|
|
757
|
+
throw appPermissionsVerificationError(projectKey, { beforeHash, desiredHash, verifiedHash, });
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
projectKey,
|
|
761
|
+
beforeHash,
|
|
762
|
+
desiredHash,
|
|
763
|
+
verifiedHash,
|
|
764
|
+
applied: true,
|
|
765
|
+
};
|
|
766
|
+
},
|
|
767
|
+
usage: "dss app permissions-restore --file PATH [--project-key KEY] [--dry-run]",
|
|
768
|
+
description: "Restore a project's permissions from a snapshot file: hash-verifies the file, refuses cross-project restores, reads before writing, only PUTs when changed, and refetches to verify the result.",
|
|
769
|
+
examples: [
|
|
770
|
+
"dss app permissions-restore --file app-permissions.json --project-key MYINSTANCE --dry-run",
|
|
771
|
+
],
|
|
772
|
+
},
|
|
78
773
|
};
|