@noodleseed/one 0.168.0 → 0.169.0
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/dist/commands/deployment-delete-ops.d.ts +12 -0
- package/dist/commands/deployment-delete-ops.d.ts.map +1 -0
- package/dist/commands/deployment-delete-ops.js +230 -0
- package/dist/commands/deployment-delete-ops.js.map +1 -0
- package/dist/commands/deployment-delete-recovery.d.ts +35 -0
- package/dist/commands/deployment-delete-recovery.d.ts.map +1 -0
- package/dist/commands/deployment-delete-recovery.js +99 -0
- package/dist/commands/deployment-delete-recovery.js.map +1 -0
- package/dist/commands/deployments-ops.d.ts +2 -1
- package/dist/commands/deployments-ops.d.ts.map +1 -1
- package/dist/commands/deployments-ops.js +5 -1
- package/dist/commands/deployments-ops.js.map +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-tenant-resources.js +27 -1
- package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.d.ts +44 -0
- package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.js +105 -0
- package/node_modules/@noodle-borg/control-plane/dist/deployment-deletion-contracts.d.ts +20 -0
- package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
- package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
- package/node_modules/@noodle-borg/service/dist/deployment-deletion.js +40 -0
- package/node_modules/@noodle-borg/service/dist/registry-compile.js +6 -0
- package/node_modules/@noodle-borg/service/dist/registry-deletion.js +75 -0
- package/node_modules/@noodle-borg/service/dist/registry-state.js +4 -2
- package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -0
- package/node_modules/@noodle-borg/service/dist/registry.d.ts +2 -0
- package/node_modules/@noodle-borg/service/dist/registry.js +7 -23
- package/node_modules/@noodle-borg/service/dist/routes/deploy-dispatch.js +15 -1
- package/node_modules/@noodle-borg/service/dist/routes/deployment-delete.js +53 -0
- package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -1
- package/node_modules/@noodle-borg/service/dist/store/in-memory.js +8 -0
- package/node_modules/@noodle-borg/service/dist/store/json-file-deletion.js +64 -0
- package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -1
- package/node_modules/@noodle-borg/service/dist/store/json-file.js +23 -5
- package/node_modules/@noodle-borg/service/dist/store.d.ts +5 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.d.ts +29 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.js +33 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const ERRORS = {
|
|
2
|
+
deployment_not_found: 'Deployment not found.',
|
|
3
|
+
version_not_found: 'Version not found.',
|
|
4
|
+
active_deployment: 'Roll back to another deployment before deleting the live deployment, or delete the whole version.',
|
|
5
|
+
deployment_locked: 'Unlock this version before deleting it.',
|
|
6
|
+
app_archived: 'Restore the archived app before deleting its deployments.',
|
|
7
|
+
deployment_delete_conflict: 'The deployment history changed. Refresh and review it before deleting.',
|
|
8
|
+
};
|
|
9
|
+
/** One owner-authorized deletion path for every host; stores commit the exact selection atomically. */
|
|
10
|
+
export async function deleteDeploymentOperation(deps, input) {
|
|
11
|
+
const { actor: identity, scope: route } = input;
|
|
12
|
+
const org = route.kind === 'version' ? route.target.org : route.org;
|
|
13
|
+
const member = identity.superAdmin
|
|
14
|
+
? undefined
|
|
15
|
+
: await deps.controlPlane.getOrgMember({ org, subject: identity.subject });
|
|
16
|
+
if (identity.developerGrantId !== undefined ||
|
|
17
|
+
(!identity.superAdmin && member?.role !== 'owner')) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
status: 403,
|
|
21
|
+
code: 'organization_owner_required',
|
|
22
|
+
error: 'Only an organization owner can delete deployments or versions.',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
let target;
|
|
26
|
+
let selection;
|
|
27
|
+
if (route.kind === 'deployment') {
|
|
28
|
+
const record = await deps.registry.getDeployment(route.org, route.deploymentId);
|
|
29
|
+
if (!record)
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
status: 404,
|
|
33
|
+
code: 'deployment_not_found',
|
|
34
|
+
error: ERRORS.deployment_not_found,
|
|
35
|
+
};
|
|
36
|
+
target = { org: record.orgSlug, app: record.appSlug, env: record.environment };
|
|
37
|
+
selection = { kind: 'deployment', deploymentId: route.deploymentId };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (!input.expectedDeploymentIds?.length)
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
status: 400,
|
|
44
|
+
code: 'invalid_deletion_confirmation',
|
|
45
|
+
error: 'Invalid version deletion confirmation.',
|
|
46
|
+
};
|
|
47
|
+
target = route.target;
|
|
48
|
+
selection = {
|
|
49
|
+
kind: 'version',
|
|
50
|
+
expectedDeploymentIds: input.expectedDeploymentIds,
|
|
51
|
+
...(route.serverVersion === undefined ? {} : { serverVersion: route.serverVersion }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const result = await deps.registry.deleteDeployments(target, selection);
|
|
55
|
+
const status = result.ok
|
|
56
|
+
? 200
|
|
57
|
+
: result.code === 'deployment_not_found' || result.code === 'version_not_found'
|
|
58
|
+
? 404
|
|
59
|
+
: 409;
|
|
60
|
+
let auditRecorded = true;
|
|
61
|
+
try {
|
|
62
|
+
await deps.audit.emit({
|
|
63
|
+
eventType: result.ok
|
|
64
|
+
? route.kind === 'version'
|
|
65
|
+
? 'deployment.version.deleted'
|
|
66
|
+
: 'deployment.deleted'
|
|
67
|
+
: 'deployment.delete.rejected',
|
|
68
|
+
org: target.org,
|
|
69
|
+
app: target.app,
|
|
70
|
+
env: target.env,
|
|
71
|
+
...(route.kind === 'deployment' ? { deploymentId: route.deploymentId } : {}),
|
|
72
|
+
actorSubject: identity.subject,
|
|
73
|
+
...(identity.email === undefined ? {} : { actorEmail: identity.email }),
|
|
74
|
+
decision: result.ok ? 'allow' : 'deny',
|
|
75
|
+
status,
|
|
76
|
+
...(!result.ok ? { reasonCode: result.code } : {}),
|
|
77
|
+
details: {
|
|
78
|
+
scope: route.kind,
|
|
79
|
+
...(route.kind === 'version' ? { serverVersion: route.serverVersion ?? 'legacy' } : {}),
|
|
80
|
+
...(result.ok ? { deletedDeployments: result.deleted.length } : {}),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Deletion has already committed. An audit outage must not claim the records still exist.
|
|
86
|
+
auditRecorded = false;
|
|
87
|
+
}
|
|
88
|
+
if (!result.ok)
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
status: status,
|
|
92
|
+
code: result.code,
|
|
93
|
+
error: ERRORS[result.code],
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
view: {
|
|
98
|
+
ok: true,
|
|
99
|
+
target,
|
|
100
|
+
deletedDeploymentIds: result.deleted.map((record) => record.deploymentId),
|
|
101
|
+
auditRecorded,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=delete-deployment.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type DeploymentDeleteSelection = {
|
|
2
|
+
readonly kind: 'deployment';
|
|
3
|
+
readonly deploymentId: string;
|
|
4
|
+
} | {
|
|
5
|
+
readonly kind: 'version';
|
|
6
|
+
readonly serverVersion?: string;
|
|
7
|
+
readonly expectedDeploymentIds: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
export type DeploymentDeleteResult<Record extends {
|
|
10
|
+
readonly deploymentId: string;
|
|
11
|
+
} = {
|
|
12
|
+
readonly deploymentId: string;
|
|
13
|
+
}> = {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
readonly deleted: readonly Record[];
|
|
16
|
+
} | {
|
|
17
|
+
readonly ok: false;
|
|
18
|
+
readonly code: 'deployment_not_found' | 'version_not_found' | 'active_deployment' | 'deployment_locked' | 'app_archived' | 'deployment_delete_conflict';
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=deployment-deletion-contracts.d.ts.map
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { type AppPurgeReconciliationActor, type AppPurgeReconciliationConflictCode, AppPurgeReconciliationError, type AppPurgeReconciliationOperator, } from './app-purge-reconciliation-port.js';
|
|
2
2
|
export type { ActiveMcpSubdomainClaim, ChangeMcpSubdomainInput, ControlPlaneIdentity, CreateOrgWithOwnerInput, McpSubdomainMutationResult, McpSubdomainSetting, OrganizationStore, OrgDomainRecord, OrgInvitationRecord, OrgMemberRecord, OrgOpenAIAppsChallengeRecord, OrgRecord, OrgRole, PersonalWorkspaceProvisionInput, PersonalWorkspaceProvisionResult, SignupAllowlistKind, SignupAllowlistRecord, WelcomeEmailRecord, } from './contracts.js';
|
|
3
3
|
export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
|
|
4
|
+
export { type DeploymentDeleteOperationResult, type DeploymentDeleteScope, type DeploymentDeletionDependencies, type DeploymentDeletionRegistry, deleteDeploymentOperation, } from './delete-deployment.js';
|
|
4
5
|
export { allowAllGate, bearerToken, type ControlPlaneAuthResult, type DeployAuthGate, } from './deploy-auth.js';
|
|
5
6
|
export { CompositeControlPlaneGate, type ControlPlaneSignupMode, GoogleControlPlaneGate, type GoogleControlPlaneGateOptions, type GoogleIdTokenVerifier, GoogleWorkloadControlPlaneGate, type GoogleWorkloadControlPlaneGateOptions, NoodleOAuthControlPlaneGate, type NoodleOAuthControlPlaneGateOptions, type SignupAuthorizer, } from './deploy-gates.js';
|
|
7
|
+
export type { DeploymentDeleteResult, DeploymentDeleteSelection, } from './deployment-deletion-contracts.js';
|
|
6
8
|
export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
|
|
7
9
|
export { bindInMemoryOrganizationStore, type InMemoryOrganizationOperations, InMemoryOrganizationStore, } from './in-memory-organization-store.js';
|
|
8
10
|
export { InMemoryMcpSubdomainClaimStore, McpSubdomainCooldownError, McpSubdomainIdempotencyConflictError, McpSubdomainOwnerRequiredError, type McpSubdomainRouteRef, McpSubdomainUnavailableError, mcpSubdomainEndpointOptions, type ResolvedMcpTenantRef, resolveMcpSubdomainTenant, } from './mcp-subdomain-claims.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { AppPurgeReconciliationError, } from './app-purge-reconciliation-port.js';
|
|
2
2
|
export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
|
|
3
|
+
export { deleteDeploymentOperation, } from './delete-deployment.js';
|
|
3
4
|
export { allowAllGate, bearerToken, } from './deploy-auth.js';
|
|
4
5
|
export { CompositeControlPlaneGate, GoogleControlPlaneGate, GoogleWorkloadControlPlaneGate, NoodleOAuthControlPlaneGate, } from './deploy-gates.js';
|
|
5
6
|
export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { normalizeServerVersion } from '@noodle-borg/module';
|
|
2
|
+
import { sameTenantRecord } from './deployment-versioning.js';
|
|
3
|
+
import { validateTenantRef } from './store/validate.js';
|
|
4
|
+
/** Shared, side-effect-free decision made inside each adapter's mutation boundary. */
|
|
5
|
+
export function planDeploymentDeletion(records, ref, selection) {
|
|
6
|
+
const safe = validateTenantRef(ref);
|
|
7
|
+
const version = selection.kind === 'version' && selection.serverVersion !== undefined
|
|
8
|
+
? normalizeServerVersion(selection.serverVersion)
|
|
9
|
+
: undefined;
|
|
10
|
+
const deleted = records.filter((record) => sameTenantRecord(record, safe) &&
|
|
11
|
+
(selection.kind === 'deployment'
|
|
12
|
+
? record.deploymentId === selection.deploymentId
|
|
13
|
+
: record.serverVersion === version));
|
|
14
|
+
if (deleted.length === 0)
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
code: selection.kind === 'deployment' ? 'deployment_not_found' : 'version_not_found',
|
|
18
|
+
};
|
|
19
|
+
if (records.some((record) => record.orgSlug === safe.org &&
|
|
20
|
+
record.appSlug === safe.app &&
|
|
21
|
+
record.archivedAt !== undefined)) {
|
|
22
|
+
return { ok: false, code: 'app_archived' };
|
|
23
|
+
}
|
|
24
|
+
if (selection.kind === 'deployment') {
|
|
25
|
+
if (deleted.some((record) => record.active))
|
|
26
|
+
return { ok: false, code: 'active_deployment' };
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (deleted.some((record) => record.active && record.deploymentLock !== undefined))
|
|
30
|
+
return { ok: false, code: 'deployment_locked' };
|
|
31
|
+
const expected = new Set(selection.expectedDeploymentIds);
|
|
32
|
+
if (expected.size !== selection.expectedDeploymentIds.length ||
|
|
33
|
+
expected.size !== deleted.length ||
|
|
34
|
+
deleted.some((record) => !expected.has(record.deploymentId))) {
|
|
35
|
+
return { ok: false, code: 'deployment_delete_conflict' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, deleted };
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=deployment-deletion.js.map
|
|
@@ -9,6 +9,7 @@ import { ManagedConfigBroker } from './credential-broker.js';
|
|
|
9
9
|
import { deploymentCredentialBrokerOptions } from './credential-broker-options.js';
|
|
10
10
|
import { deploymentRecordVersionError } from './deployment-record-version.js';
|
|
11
11
|
import { normalizePersistedManifestForCompile } from './manifest-normalize.js';
|
|
12
|
+
import { registryRecordStillExists } from './registry-deletion.js';
|
|
12
13
|
import { missingCapabilityErrors, missingSecretErrors, missingVariableErrors, } from './registry-helpers.js';
|
|
13
14
|
import { servedTargetFor } from './registry-targets.js';
|
|
14
15
|
import { createDeploymentStateConnector, } from './state-connector-factory.js';
|
|
@@ -252,6 +253,11 @@ export async function loadPersistedRegistryTarget(source, context) {
|
|
|
252
253
|
if (record.active &&
|
|
253
254
|
(await context.hasCustomerAuthConflict(record, built.served.artifact.server.auth)))
|
|
254
255
|
return undefined;
|
|
256
|
+
const exists = state.store === undefined
|
|
257
|
+
? state.records.has(deploymentId)
|
|
258
|
+
: await registryRecordStillExists(state, deploymentId);
|
|
259
|
+
if (!exists)
|
|
260
|
+
return undefined;
|
|
255
261
|
const target = servedTargetFor(record, built.served, context.customerVerifierFactory);
|
|
256
262
|
state.servers.set(deploymentId, target);
|
|
257
263
|
state.records.set(deploymentId, record);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { planDeploymentDeletion } from './deployment-deletion.js';
|
|
2
|
+
import { targetForPersistedRecord } from './registry-targets.js';
|
|
3
|
+
import { validateTenantRef } from './store/validate.js';
|
|
4
|
+
export function evictDeletedDeployments(state, ids) {
|
|
5
|
+
for (const id of ids) {
|
|
6
|
+
state.records.delete(id);
|
|
7
|
+
state.servers.delete(id);
|
|
8
|
+
}
|
|
9
|
+
for (const [key, id] of state.activeTenants)
|
|
10
|
+
if (ids.has(id))
|
|
11
|
+
state.activeTenants.delete(key);
|
|
12
|
+
}
|
|
13
|
+
export async function deleteRegistryDeployments(state, ref, selection) {
|
|
14
|
+
const safe = validateTenantRef(ref);
|
|
15
|
+
const result = state.store === undefined
|
|
16
|
+
? planDeploymentDeletion([...state.records.values()], safe, selection)
|
|
17
|
+
: await state.store.deleteDeployments(safe, selection);
|
|
18
|
+
if (result.ok)
|
|
19
|
+
evictDeletedDeployments(state, new Set(result.deleted.map((record) => record.deploymentId)));
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
/** Async compilation must rejoin authority before publishing a result into the disposable cache. */
|
|
23
|
+
export async function registryRecordStillExists(state, deploymentId) {
|
|
24
|
+
const current = state.store === undefined
|
|
25
|
+
? state.records.get(deploymentId)
|
|
26
|
+
: await state.store.get(deploymentId);
|
|
27
|
+
if (current !== undefined && current.archivedAt === undefined)
|
|
28
|
+
return true;
|
|
29
|
+
evictDeletedDeployments(state, new Set([deploymentId]));
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** Refresh warmed cache entries from authority and coalesce compilation on a cache miss. */
|
|
33
|
+
export async function readRegistryTarget(state, deploymentId, inflight, targetForRecord, load) {
|
|
34
|
+
if (state.records.get(deploymentId)?.archivedAt !== undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (state.servers.has(deploymentId)) {
|
|
37
|
+
const record = state.store
|
|
38
|
+
? await state.store.get(deploymentId)
|
|
39
|
+
: state.records.get(deploymentId);
|
|
40
|
+
if (record === undefined)
|
|
41
|
+
evictDeletedDeployments(state, new Set([deploymentId]));
|
|
42
|
+
return record === undefined || record.archivedAt !== undefined
|
|
43
|
+
? undefined
|
|
44
|
+
: targetForRecord(record);
|
|
45
|
+
}
|
|
46
|
+
const pending = inflight.get(deploymentId);
|
|
47
|
+
if (pending)
|
|
48
|
+
return pending;
|
|
49
|
+
const promise = load(deploymentId).finally(() => {
|
|
50
|
+
inflight.delete(deploymentId);
|
|
51
|
+
});
|
|
52
|
+
inflight.set(deploymentId, promise);
|
|
53
|
+
return promise;
|
|
54
|
+
}
|
|
55
|
+
/** No-store records are authority, so stale reconciliation must never repopulate absent records. */
|
|
56
|
+
export async function reconcileRegistryTarget(state, record, context) {
|
|
57
|
+
let loaded = false;
|
|
58
|
+
const target = await targetForPersistedRecord(record, {
|
|
59
|
+
...context,
|
|
60
|
+
load: () => {
|
|
61
|
+
loaded = true;
|
|
62
|
+
return context.load();
|
|
63
|
+
},
|
|
64
|
+
records: state.records,
|
|
65
|
+
servers: state.servers,
|
|
66
|
+
recordStillPresent: () => state.store !== undefined || state.records.has(record.deploymentId),
|
|
67
|
+
});
|
|
68
|
+
// A cold load already checks store authority just before publication; warm reconciliation must too.
|
|
69
|
+
// No async presence snapshot in no-store mode: deletion and publication share one synchronous turn.
|
|
70
|
+
const exists = state.store === undefined
|
|
71
|
+
? state.records.has(record.deploymentId)
|
|
72
|
+
: loaded || (await registryRecordStillExists(state, record.deploymentId));
|
|
73
|
+
return exists ? target : undefined;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=registry-deletion.js.map
|
|
@@ -166,8 +166,10 @@ export async function setRegistryDeploymentLock(state, ref, serverVersion, expec
|
|
|
166
166
|
state.records.set(result.record.deploymentId, result.record);
|
|
167
167
|
return result;
|
|
168
168
|
}
|
|
169
|
-
const
|
|
170
|
-
|
|
169
|
+
const observed = await activeRecordVersion(state, safe, safeVersion);
|
|
170
|
+
// The lookup yields; a concurrent deletion or pointer move must win before any cache write.
|
|
171
|
+
const active = observed === undefined ? undefined : state.records.get(observed.deploymentId);
|
|
172
|
+
if (active === undefined || !active.active || active.archivedAt !== undefined)
|
|
171
173
|
return { ok: false, reason: 'no_active_deployment' };
|
|
172
174
|
if (active.deploymentId !== expectedDeploymentId)
|
|
173
175
|
return { ok: false, reason: 'conflict' };
|
|
@@ -110,6 +110,8 @@ function bindAppPackageSnapshot(served, value) {
|
|
|
110
110
|
}
|
|
111
111
|
/** Reconcile one persisted record with its cached served target without trusting stale auth projections. */
|
|
112
112
|
export async function targetForPersistedRecord(record, state) {
|
|
113
|
+
if (state.recordStillPresent?.() === false)
|
|
114
|
+
return undefined;
|
|
113
115
|
const cached = state.servers.get(record.deploymentId);
|
|
114
116
|
const cachedSchemaVersion = cached === undefined ? undefined : targetPolicyVersions.get(cached);
|
|
115
117
|
state.records.set(record.deploymentId, record);
|
|
@@ -127,6 +129,8 @@ export async function targetForPersistedRecord(record, state) {
|
|
|
127
129
|
(await state.hasCustomerAuthConflict(record, cached.served.artifact.server.auth))) {
|
|
128
130
|
return undefined;
|
|
129
131
|
}
|
|
132
|
+
if (state.recordStillPresent?.() === false)
|
|
133
|
+
return undefined;
|
|
130
134
|
if (cachedSchemaVersion === record.schemaVersion && servedTargetMatchesRecord(cached, record))
|
|
131
135
|
return cached;
|
|
132
136
|
const reconciled = servedTargetFor(record, cached.served, state.customerVerifierFactory);
|
|
@@ -9,6 +9,7 @@ import type { ServedTarget } from '@noodle-borg/transport-http';
|
|
|
9
9
|
import type { NativeRecordConnectorFactory } from './native-record-connector.js';
|
|
10
10
|
import { type ActiveDeployProvenance } from './registry-helpers.js';
|
|
11
11
|
import type { AccessUpdateOptions, AccessUpdateResult, DeployOptions, DeployPreflightResult, RecoverResult, RunDeployResult, ServerRegistryOptions } from './registry-types.js';
|
|
12
|
+
import type { DeploymentDeleteResult, DeploymentDeleteSelection } from './store.js';
|
|
12
13
|
import { type AppArchiveResult, type AppRestoreResult, type AppSummary, type ArtifactStore, type ConfigStore, type CustomerAuthRestoreProjection, type DeploymentListFilter, type DeploymentLock, type DeploymentLockUpdateResult, type DeploymentStatus, type DeploymentSummary, type DeployRecord, type EnvSummary, type ProductionEnvironmentChange, type TenantRef } from './store.js';
|
|
13
14
|
export type { RollbackResult } from '@noodle-borg/control-plane/portable';
|
|
14
15
|
export type { DeploymentPackage } from './registry-package.js';
|
|
@@ -29,6 +30,7 @@ export declare class ServerRegistry {
|
|
|
29
30
|
/** Read-only deploy validation used by the CLI before config writes, asset upload, or persistence. */
|
|
30
31
|
preflightDeploy(tenant: TenantRef, manifest: string, options?: DeployOptions): Promise<DeployPreflightResult>;
|
|
31
32
|
recover(): Promise<RecoverResult>;
|
|
33
|
+
deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
|
|
32
34
|
get(deploymentId: string): Promise<ServedTarget | undefined>;
|
|
33
35
|
/** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
|
|
34
36
|
getServing(deploymentId: string): Promise<ServedTarget | undefined>;
|
|
@@ -6,6 +6,7 @@ import { DeploymentPolicyChangedError, UnsupportedDeploymentRecordVersionError,
|
|
|
6
6
|
import { defaultActiveRecord } from './deployment-versioning.js';
|
|
7
7
|
import { updateRegistryAccess } from './registry-access.js';
|
|
8
8
|
import { compilePersistedRegistryRecord, compileRegistryTarget, loadPersistedRegistryTarget, } from './registry-compile.js';
|
|
9
|
+
import { deleteRegistryDeployments, readRegistryTarget, reconcileRegistryTarget, } from './registry-deletion.js';
|
|
9
10
|
import { createRegistryDeployRecord, deployedRecordResult, deploymentWriteVersionError, preflightRegistryDeploy, replacementDeploymentPolicy, resolveDeployAttempt, withDeploymentConfiguration, } from './registry-deploy-transaction.js';
|
|
10
11
|
import { deploymentOwnerSubject } from './registry-helpers.js';
|
|
11
12
|
import { registryDeploymentPackage, renderDeploymentPackageSnapshot } from './registry-package.js';
|
|
@@ -13,7 +14,7 @@ import { recoverRegistryRecords } from './registry-recover.js';
|
|
|
13
14
|
import { rollbackDeployment, rollbackWithMappedErrors } from './registry-rollback.js';
|
|
14
15
|
import { activeRecord, activeRecordVersion, persistDeployRecord, reconcilePlatformAccountResetRegistryCache, registryAppArchivedAt, registryArchiveApp, registryCustomerAuthRestoreProjections, registryGetApp, registryGetDeployment, registryGetEnvironment, registryListApps, registryListEnvironments, registryRestoreApp, registrySetProductionEnvironment, registrySweepArchived, setRegistryDeploymentLock, } from './registry-state.js';
|
|
15
16
|
import { deploymentStatusFor } from './registry-status.js';
|
|
16
|
-
import { deploymentSourceFor, rebindNativeRecords, servedTargetFor,
|
|
17
|
+
import { deploymentSourceFor, rebindNativeRecords, servedTargetFor, tenantDeploymentKey, tenantKey, } from './registry-targets.js';
|
|
17
18
|
import { withBuiltinStateCatalog } from './state-catalog.js';
|
|
18
19
|
import { deploymentSummary, matchesDeploymentFilter, validateDeploymentListFilter, } from './store/records.js';
|
|
19
20
|
import { InMemoryConfigStore, validateTenantRef, } from './store.js';
|
|
@@ -318,26 +319,11 @@ export class ServerRegistry {
|
|
|
318
319
|
#compilePersistedRecord(record) {
|
|
319
320
|
return compilePersistedRegistryRecord(record, this.#compileTarget.bind(this));
|
|
320
321
|
}
|
|
322
|
+
deleteDeployments(ref, selection) {
|
|
323
|
+
return deleteRegistryDeployments(this.#stateView(), ref, selection);
|
|
324
|
+
}
|
|
321
325
|
async get(deploymentId) {
|
|
322
|
-
|
|
323
|
-
return undefined;
|
|
324
|
-
const cached = this.#servers.get(deploymentId);
|
|
325
|
-
if (cached) {
|
|
326
|
-
const record = this.#store
|
|
327
|
-
? await this.#store.get(deploymentId)
|
|
328
|
-
: this.#records.get(deploymentId);
|
|
329
|
-
return record === undefined || record.archivedAt !== undefined
|
|
330
|
-
? undefined
|
|
331
|
-
: this.#targetForPersistedRecord(record);
|
|
332
|
-
}
|
|
333
|
-
const inflight = this.#inflight.get(deploymentId);
|
|
334
|
-
if (inflight)
|
|
335
|
-
return inflight;
|
|
336
|
-
const promise = this.#loadAndCompile(deploymentId).finally(() => {
|
|
337
|
-
this.#inflight.delete(deploymentId);
|
|
338
|
-
});
|
|
339
|
-
this.#inflight.set(deploymentId, promise);
|
|
340
|
-
return promise;
|
|
326
|
+
return readRegistryTarget(this.#stateView(), deploymentId, this.#inflight, (record) => this.#targetForPersistedRecord(record), (id) => this.#loadAndCompile(id));
|
|
341
327
|
}
|
|
342
328
|
/** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
|
|
343
329
|
async getServing(deploymentId) {
|
|
@@ -499,9 +485,7 @@ export class ServerRegistry {
|
|
|
499
485
|
return this.#knowledgeSearch;
|
|
500
486
|
}
|
|
501
487
|
async #targetForPersistedRecord(record) {
|
|
502
|
-
return
|
|
503
|
-
records: this.#records,
|
|
504
|
-
servers: this.#servers,
|
|
488
|
+
return reconcileRegistryTarget(this.#stateView(), record, {
|
|
505
489
|
...(this.#customerVerifierFactory
|
|
506
490
|
? { customerVerifierFactory: this.#customerVerifierFactory }
|
|
507
491
|
: {}),
|
|
@@ -2,9 +2,23 @@ import { applySecurityHeaders, enforceHttps, } from '@noodle-borg/transport-http
|
|
|
2
2
|
import { respondRouteError } from '../http-util.js';
|
|
3
3
|
import { handleDeploy } from './control-plane.js';
|
|
4
4
|
import { handleDeployPreflight } from './deploy-preflight.js';
|
|
5
|
+
import { handleDeploymentDelete, parseVersionDeletePath } from './deployment-delete.js';
|
|
5
6
|
import { handleDeploymentLockUpdate } from './deployment-lock.js';
|
|
6
|
-
import { parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
|
|
7
|
+
import { parseDeploymentItemPath, parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
|
|
7
8
|
export function dispatchDeployRoutes(req, res, url, deps) {
|
|
9
|
+
if (req.method === 'DELETE') {
|
|
10
|
+
const item = parseDeploymentItemPath(url.pathname);
|
|
11
|
+
const deletion = item
|
|
12
|
+
? { kind: 'deployment', ...item }
|
|
13
|
+
: parseVersionDeletePath(url.pathname);
|
|
14
|
+
if (deletion) {
|
|
15
|
+
applySecurityHeaders(res, deps.tls);
|
|
16
|
+
if (enforceHttps(req, res, deps.tls))
|
|
17
|
+
return true;
|
|
18
|
+
handleDeploymentDelete(req, res, deletion, { ...deps, maxBody: deps.maxBody.control }).catch((error) => respondRouteError(deps.logger, res, 'deployment-delete.error', error));
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
8
22
|
const deploymentLock = parseTenantDeploymentLockPath(url.pathname);
|
|
9
23
|
if (req.method === 'PATCH' && deploymentLock !== undefined) {
|
|
10
24
|
applySecurityHeaders(res, deps.tls);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { deleteDeploymentOperation, } from '@noodle-borg/control-plane/portable';
|
|
2
|
+
import { normalizeServerVersion } from '@noodle-borg/module';
|
|
3
|
+
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
4
|
+
import { deploymentDeleteResponseSchema, deploymentVersionDeleteRequestSchema, } from '@noodle-borg/wire-contracts';
|
|
5
|
+
import { validateTenantRef } from '../store.js';
|
|
6
|
+
import { authorizeControlPlane } from './control-plane.js';
|
|
7
|
+
/** An exact numeric version, or the legacy unversioned scope; never semantic-version equivalence. */
|
|
8
|
+
export function parseVersionDeletePath(pathname) {
|
|
9
|
+
const match = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/versions\/([^/]+)$/.exec(pathname);
|
|
10
|
+
if (!match)
|
|
11
|
+
return undefined;
|
|
12
|
+
try {
|
|
13
|
+
const target = validateTenantRef({
|
|
14
|
+
org: decodeURIComponent(match[1]),
|
|
15
|
+
app: decodeURIComponent(match[2]),
|
|
16
|
+
env: decodeURIComponent(match[3]),
|
|
17
|
+
});
|
|
18
|
+
const version = decodeURIComponent(match[4]);
|
|
19
|
+
return {
|
|
20
|
+
kind: 'version',
|
|
21
|
+
org: target.org,
|
|
22
|
+
target,
|
|
23
|
+
...(version === 'legacy' ? {} : { serverVersion: normalizeServerVersion(version) }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function handleDeploymentDelete(req, res, route, deps) {
|
|
31
|
+
const identity = await authorizeControlPlane(req, res, deps.gate, { requireIdentity: true });
|
|
32
|
+
if (identity === false)
|
|
33
|
+
return;
|
|
34
|
+
let expectedDeploymentIds;
|
|
35
|
+
if (route.kind === 'version') {
|
|
36
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
37
|
+
if (!body.ok)
|
|
38
|
+
return sendJson(res, body.status, { error: body.error });
|
|
39
|
+
const parsed = deploymentVersionDeleteRequestSchema.safeParse(body.value);
|
|
40
|
+
if (!parsed.success)
|
|
41
|
+
return sendJson(res, 400, { error: 'Invalid version deletion confirmation.' });
|
|
42
|
+
expectedDeploymentIds = parsed.data.expectedDeploymentIds;
|
|
43
|
+
}
|
|
44
|
+
const result = await deleteDeploymentOperation(deps, {
|
|
45
|
+
actor: identity,
|
|
46
|
+
scope: route,
|
|
47
|
+
...(expectedDeploymentIds === undefined ? {} : { expectedDeploymentIds }),
|
|
48
|
+
});
|
|
49
|
+
return result.ok
|
|
50
|
+
? sendJson(res, 200, deploymentDeleteResponseSchema.parse(result.view))
|
|
51
|
+
: sendJson(res, result.status, { code: result.code, error: result.error });
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=deployment-delete.js.map
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
|
|
1
|
+
import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentDeleteResult, DeploymentDeleteSelection, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
|
|
2
2
|
/** In-memory store: keeps records in a Map. Used by tests and as an explicit, non-persistent option. */
|
|
3
3
|
export declare class InMemoryArtifactStore implements ArtifactStore {
|
|
4
4
|
#private;
|
|
5
|
+
deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
|
|
5
6
|
append(record: DeployRecord, precondition?: DeploymentPolicyPrecondition): Promise<void>;
|
|
6
7
|
loadAll(): Promise<readonly DeployRecord[]>;
|
|
7
8
|
get(deploymentId: string): Promise<DeployRecord | undefined>;
|
|
@@ -2,6 +2,7 @@ import { normalizeServerVersion } from '@noodle-borg/module';
|
|
|
2
2
|
import { assertSameAppPackageSnapshot, sanitizeDeployRecordAppPackageSnapshot, } from '../app-package-snapshot.js';
|
|
3
3
|
import { assertCustomerAuthRestorePrecondition, assertUniqueActiveCustomerAuthAudienceBindings, findActiveCustomerAuthAudienceConflict, requiresCustomerAuthProjection, } from '../customer-auth-audience-binding.js';
|
|
4
4
|
import { matchesDeploymentActivation } from '../deployment-activation-precondition.js';
|
|
5
|
+
import { planDeploymentDeletion } from '../deployment-deletion.js';
|
|
5
6
|
import { assertDeploymentActivationUnlocked, assertDeploymentAppendUnlocked, } from '../deployment-lock.js';
|
|
6
7
|
import { assertDeploymentAppendPolicy, assertDeploymentAppendVersion, } from '../deployment-record-version.js';
|
|
7
8
|
import { defaultActiveRecord, sameDeploymentScope, sameTenantRecord, } from '../deployment-versioning.js';
|
|
@@ -11,6 +12,13 @@ import { validateSlug, validateTenantRef } from './validate.js';
|
|
|
11
12
|
export class InMemoryArtifactStore {
|
|
12
13
|
#records = new Map();
|
|
13
14
|
#productionEnvironments = new Map();
|
|
15
|
+
deleteDeployments(ref, selection) {
|
|
16
|
+
const result = planDeploymentDeletion([...this.#records.values()], ref, selection);
|
|
17
|
+
if (result.ok)
|
|
18
|
+
for (const record of result.deleted)
|
|
19
|
+
this.#records.delete(record.deploymentId);
|
|
20
|
+
return Promise.resolve(result);
|
|
21
|
+
}
|
|
14
22
|
async append(record, precondition) {
|
|
15
23
|
assertDeploymentAppendVersion([...this.#records.values()], record);
|
|
16
24
|
assertDeploymentAppendPolicy([...this.#records.values()], record, precondition);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { DEPLOYMENT_ID_PATTERN } from './validate.js';
|
|
5
|
+
const lifecycleTails = new Map();
|
|
6
|
+
/** All adapters for one local directory join the same process-local lifecycle ordering. */
|
|
7
|
+
export function serializeFileLifecycle(directory, operation) {
|
|
8
|
+
const key = resolve(directory);
|
|
9
|
+
const result = (lifecycleTails.get(key) ?? Promise.resolve()).then(operation, operation);
|
|
10
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
11
|
+
lifecycleTails.set(key, tail);
|
|
12
|
+
void tail.then(() => {
|
|
13
|
+
if (lifecycleTails.get(key) === tail)
|
|
14
|
+
lifecycleTails.delete(key);
|
|
15
|
+
});
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
/** Fail closed on an unreadable deletion journal; missing means no deletions have committed. */
|
|
19
|
+
export async function readDeletedDeploymentIds(directory) {
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
text = await readFile(join(directory, 'deleted-deployments.json'), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === 'ENOENT')
|
|
26
|
+
return new Set();
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const ids = JSON.parse(text);
|
|
30
|
+
if (!Array.isArray(ids) ||
|
|
31
|
+
ids.some((id) => typeof id !== 'string' || !DEPLOYMENT_ID_PATTERN.test(id))) {
|
|
32
|
+
throw new Error('Invalid deployment deletion journal');
|
|
33
|
+
}
|
|
34
|
+
return new Set(ids);
|
|
35
|
+
}
|
|
36
|
+
/** Rename is the logical commit; retained ID tombstones also reject retries that would resurrect records. */
|
|
37
|
+
export async function commitDeletedDeploymentIds(directory, ids) {
|
|
38
|
+
const deleted = new Set(await readDeletedDeploymentIds(directory));
|
|
39
|
+
for (const id of ids)
|
|
40
|
+
deleted.add(id);
|
|
41
|
+
await mkdir(directory, { recursive: true });
|
|
42
|
+
const path = join(directory, 'deleted-deployments.json');
|
|
43
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
45
|
+
try {
|
|
46
|
+
await file.writeFile(JSON.stringify([...deleted].sort()));
|
|
47
|
+
await file.sync();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await file.close();
|
|
51
|
+
}
|
|
52
|
+
await rename(temporary, path);
|
|
53
|
+
// Persist both the commit rename and the metadata directory before removing any deployment files.
|
|
54
|
+
for (const parent of [directory, dirname(directory)]) {
|
|
55
|
+
const handle = await open(parent, 'r');
|
|
56
|
+
try {
|
|
57
|
+
await handle.sync();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=json-file-deletion.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
|
|
1
|
+
import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentDeleteResult, DeploymentDeleteSelection, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
|
|
2
2
|
/**
|
|
3
3
|
* JSON-file store: one file per deployment at `<dataDir>/deployments/<deploymentId>.json`, written atomically
|
|
4
4
|
* (write a temp file, then `rename` over the target — no partial file is ever read). `deploymentId` is a
|
|
@@ -8,6 +8,7 @@ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition,
|
|
|
8
8
|
export declare class JsonFileArtifactStore implements ArtifactStore {
|
|
9
9
|
#private;
|
|
10
10
|
constructor(dataDir: string);
|
|
11
|
+
deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
|
|
11
12
|
append(record: DeployRecord, precondition?: DeploymentPolicyPrecondition): Promise<void>;
|
|
12
13
|
get(deploymentId: string): Promise<DeployRecord | undefined>;
|
|
13
14
|
getActiveByTenant(ref: TenantRef): Promise<DeployRecord | undefined>;
|