@miosa/cli 1.1.5 → 1.1.6
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 +69 -0
- package/dist/app-contract.d.ts +92 -0
- package/dist/app-contract.d.ts.map +1 -0
- package/dist/app-contract.js +219 -0
- package/dist/app-contract.js.map +1 -0
- package/dist/app-manifest.d.ts +50 -0
- package/dist/app-manifest.d.ts.map +1 -1
- package/dist/app-manifest.js +106 -0
- package/dist/app-manifest.js.map +1 -1
- package/dist/app-release.d.ts +33 -0
- package/dist/app-release.d.ts.map +1 -1
- package/dist/app-release.js +66 -1
- package/dist/app-release.js.map +1 -1
- package/dist/bin/miosa.js +8 -0
- package/dist/bin/miosa.js.map +1 -1
- package/dist/commands/app.d.ts +35 -0
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +22 -6
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/capabilities.d.ts.map +1 -1
- package/dist/commands/capabilities.js +58 -1
- package/dist/commands/capabilities.js.map +1 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +3 -1
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/docker-deploy.d.ts.map +1 -1
- package/dist/commands/docker-deploy.js +68 -0
- package/dist/commands/docker-deploy.js.map +1 -1
- package/dist/commands/operating-contract.d.ts +3 -0
- package/dist/commands/operating-contract.d.ts.map +1 -0
- package/dist/commands/operating-contract.js +651 -0
- package/dist/commands/operating-contract.js.map +1 -0
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +27 -5
- package/dist/config.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { approvePlan, assertPlanApplicable, contractFingerprint, createSavedPlan, detectContractDrift, loadPlan, resolveApplicationContract, savePlan, } from "../app-contract.js";
|
|
6
|
+
import { loadAppManifest, validateProjectManifest } from "../app-manifest.js";
|
|
7
|
+
import { MiosaClient } from "../client.js";
|
|
8
|
+
import { loadConfig } from "../config.js";
|
|
9
|
+
import { UserError } from "../errors.js";
|
|
10
|
+
import { activateLinkedRelease, getRelease, requireApplicationLink, } from "./app.js";
|
|
11
|
+
import { handleError, isJsonMode, printJson } from "./util.js";
|
|
12
|
+
function record(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
14
|
+
? value
|
|
15
|
+
: {};
|
|
16
|
+
}
|
|
17
|
+
function payload(value) {
|
|
18
|
+
const outer = record(value);
|
|
19
|
+
const data = record(outer["data"]);
|
|
20
|
+
return Object.keys(data).length > 0 ? data : outer;
|
|
21
|
+
}
|
|
22
|
+
function stringValue(value, key) {
|
|
23
|
+
const found = record(value)[key];
|
|
24
|
+
return typeof found === "string" && found.trim() ? found.trim() : undefined;
|
|
25
|
+
}
|
|
26
|
+
function output(opts, data, message) {
|
|
27
|
+
if (isJsonMode(opts)) {
|
|
28
|
+
printJson({ ok: true, data, error: null });
|
|
29
|
+
}
|
|
30
|
+
else if (message) {
|
|
31
|
+
console.log(message);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log(JSON.stringify(data, null, 2));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function releaseIdentity(release) {
|
|
38
|
+
const metadata = record(release["metadata"]);
|
|
39
|
+
const versionId = stringValue(release, "deployment_version_id") ??
|
|
40
|
+
stringValue(release, "version_id");
|
|
41
|
+
const digest = stringValue(release, "artifact_sha256") ??
|
|
42
|
+
stringValue(release, "archive_sha256") ??
|
|
43
|
+
stringValue(metadata, "artifact_sha256");
|
|
44
|
+
if (!versionId || !digest) {
|
|
45
|
+
throw new UserError("The candidate is missing an immutable version ID or artifact digest.");
|
|
46
|
+
}
|
|
47
|
+
return { versionId, digest };
|
|
48
|
+
}
|
|
49
|
+
function actorName(value) {
|
|
50
|
+
return (value ??
|
|
51
|
+
process.env["MIOSA_APPROVER"] ??
|
|
52
|
+
process.env["USER"] ??
|
|
53
|
+
os.userInfo().username);
|
|
54
|
+
}
|
|
55
|
+
function listJsonFiles(dir) {
|
|
56
|
+
if (!fs.existsSync(dir))
|
|
57
|
+
return [];
|
|
58
|
+
return fs
|
|
59
|
+
.readdirSync(dir)
|
|
60
|
+
.filter((file) => file.endsWith(".json"))
|
|
61
|
+
.sort();
|
|
62
|
+
}
|
|
63
|
+
function planPath(appDir, plan) {
|
|
64
|
+
return path.join(appDir, ".miosa", "plans", `${plan.plan_id}.json`);
|
|
65
|
+
}
|
|
66
|
+
async function assertPrePromotionGates(client, plan) {
|
|
67
|
+
const [release, deployment, environment] = await Promise.all([
|
|
68
|
+
getRelease(client, plan.scope.deployment_id, plan.release.id),
|
|
69
|
+
deploymentState(client, plan.scope.deployment_id),
|
|
70
|
+
client.apiGet(`/api/v1/deployments/${encodeURIComponent(plan.scope.deployment_id)}/env`),
|
|
71
|
+
]);
|
|
72
|
+
const identity = releaseIdentity(release);
|
|
73
|
+
const releaseMetadata = record(release["metadata"]);
|
|
74
|
+
const deploymentMetadata = record(deployment["metadata"]);
|
|
75
|
+
const failures = [];
|
|
76
|
+
if (identity.versionId !== plan.release.version_id ||
|
|
77
|
+
identity.digest !== plan.release.artifact_sha256) {
|
|
78
|
+
failures.push("candidate release identity no longer matches the saved plan");
|
|
79
|
+
}
|
|
80
|
+
const environmentNames = new Set((environment.data ?? [])
|
|
81
|
+
.map((item) => item.name)
|
|
82
|
+
.filter((name) => Boolean(name)));
|
|
83
|
+
for (const secret of plan.capabilities.secrets) {
|
|
84
|
+
if (secret.required !== false && !environmentNames.has(secret.name)) {
|
|
85
|
+
failures.push(`required secret ${secret.name} is not bound`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (plan.capabilities.database?.required &&
|
|
89
|
+
!environmentNames.has("DATABASE_URL") &&
|
|
90
|
+
!stringValue(deployment, "database_id") &&
|
|
91
|
+
!stringValue(deploymentMetadata, "database_id")) {
|
|
92
|
+
failures.push("required database is not attached");
|
|
93
|
+
}
|
|
94
|
+
if (plan.capabilities.database?.migration?.required &&
|
|
95
|
+
releaseMetadata["migration_verified"] !== true &&
|
|
96
|
+
stringValue(releaseMetadata, "migration_status") !== "succeeded") {
|
|
97
|
+
failures.push("required database migration evidence is missing");
|
|
98
|
+
}
|
|
99
|
+
for (const connector of plan.capabilities.connectors) {
|
|
100
|
+
if (connector.required === false)
|
|
101
|
+
continue;
|
|
102
|
+
const response = payload(await client
|
|
103
|
+
.apiPost(`/api/v1/deployments/${encodeURIComponent(plan.scope.deployment_id)}/connectors/preflight`, { connector: connector.id })
|
|
104
|
+
.catch(() => ({})));
|
|
105
|
+
if (record(response["status"])["bound"] !== true) {
|
|
106
|
+
failures.push(`required connector ${connector.id} is not bound`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const jobs = [
|
|
110
|
+
deployment["scheduled_jobs"],
|
|
111
|
+
deploymentMetadata["scheduled_jobs"],
|
|
112
|
+
].flatMap((value) => (Array.isArray(value) ? value : []));
|
|
113
|
+
const healthyJobs = new Set(jobs.flatMap((candidate) => {
|
|
114
|
+
const job = record(candidate);
|
|
115
|
+
const id = stringValue(job, "id") ?? stringValue(job, "name");
|
|
116
|
+
const healthy = job["enabled"] !== false &&
|
|
117
|
+
job["paused"] !== true &&
|
|
118
|
+
stringValue(job, "status") !== "failed" &&
|
|
119
|
+
stringValue(job, "last_run_status") !== "failed";
|
|
120
|
+
return id && healthy ? [id] : [];
|
|
121
|
+
}));
|
|
122
|
+
for (const job of plan.capabilities.jobs) {
|
|
123
|
+
if (job.required !== false && !healthyJobs.has(job.id)) {
|
|
124
|
+
failures.push(`required job ${job.id} is not registered and healthy`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const suppliedBusinessEvidence = new Set(Array.isArray(releaseMetadata["business_capability_evidence"])
|
|
128
|
+
? releaseMetadata["business_capability_evidence"].filter((value) => typeof value === "string")
|
|
129
|
+
: []);
|
|
130
|
+
for (const capability of plan.capabilities.business) {
|
|
131
|
+
if (capability.required !== false &&
|
|
132
|
+
!suppliedBusinessEvidence.has(capability.id)) {
|
|
133
|
+
failures.push(`business capability ${capability.id} lacks candidate evidence`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (failures.length > 0) {
|
|
137
|
+
throw new UserError(`Promotion gates blocked ${plan.release.id}: ${failures.join("; ")}.`, "Repair the declared binding or attach candidate evidence, then create a new immutable plan.");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function deploymentState(client, deploymentId) {
|
|
141
|
+
return payload(await client.apiGet(`/api/v1/deployments/${encodeURIComponent(deploymentId)}`));
|
|
142
|
+
}
|
|
143
|
+
export function register(program) {
|
|
144
|
+
const blueprint = program
|
|
145
|
+
.command("blueprint")
|
|
146
|
+
.description("Validate and inspect miosa.app.yml capability blueprints");
|
|
147
|
+
blueprint
|
|
148
|
+
.command("validate")
|
|
149
|
+
.argument("[path]", "Application directory", ".")
|
|
150
|
+
.option("--json", "Output stable JSON")
|
|
151
|
+
.action((inputPath, opts) => {
|
|
152
|
+
try {
|
|
153
|
+
const dir = path.resolve(inputPath);
|
|
154
|
+
const loaded = loadAppManifest(dir);
|
|
155
|
+
if (!loaded)
|
|
156
|
+
throw new UserError("miosa.app.yml was not found.");
|
|
157
|
+
const issues = validateProjectManifest(loaded.manifest);
|
|
158
|
+
const result = {
|
|
159
|
+
path: loaded.path,
|
|
160
|
+
valid: issues.length === 0,
|
|
161
|
+
issues,
|
|
162
|
+
capabilities: loaded.manifest.capabilities ?? null,
|
|
163
|
+
policy: loaded.manifest.policy ?? null,
|
|
164
|
+
};
|
|
165
|
+
if (issues.length > 0)
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
output(opts, result);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
handleError(error);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
blueprint
|
|
174
|
+
.command("show")
|
|
175
|
+
.argument("[path]", "Application directory", ".")
|
|
176
|
+
.option("--json", "Output stable JSON")
|
|
177
|
+
.action((inputPath, opts) => {
|
|
178
|
+
try {
|
|
179
|
+
const dir = path.resolve(inputPath);
|
|
180
|
+
const link = requireApplicationLink(dir);
|
|
181
|
+
output(opts, resolveApplicationContract(dir, link));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
handleError(error);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
const changes = program
|
|
188
|
+
.command("changes")
|
|
189
|
+
.description("Create, approve, inspect, and exactly apply immutable release plans");
|
|
190
|
+
changes
|
|
191
|
+
.command("plan <release-id>")
|
|
192
|
+
.argument("[path]", "Application directory", ".")
|
|
193
|
+
.option("--json", "Output stable JSON")
|
|
194
|
+
.action(async (releaseId, inputPath, opts) => {
|
|
195
|
+
try {
|
|
196
|
+
const dir = path.resolve(inputPath);
|
|
197
|
+
const link = requireApplicationLink(dir);
|
|
198
|
+
const contract = resolveApplicationContract(dir, link);
|
|
199
|
+
const client = new MiosaClient(loadConfig());
|
|
200
|
+
const [release, deployment] = await Promise.all([
|
|
201
|
+
getRelease(client, link.deploymentId, releaseId),
|
|
202
|
+
deploymentState(client, link.deploymentId),
|
|
203
|
+
]);
|
|
204
|
+
const identity = releaseIdentity(release);
|
|
205
|
+
let plan = createSavedPlan(dir, {
|
|
206
|
+
scope: contract.scope,
|
|
207
|
+
release: {
|
|
208
|
+
id: releaseId,
|
|
209
|
+
version_id: identity.versionId,
|
|
210
|
+
artifact_sha256: identity.digest,
|
|
211
|
+
rollback_version_id: stringValue(deployment, "active_version_id") ?? null,
|
|
212
|
+
},
|
|
213
|
+
desired_state: {
|
|
214
|
+
route: {
|
|
215
|
+
public_url: stringValue(deployment, "public_url") ??
|
|
216
|
+
stringValue(record(deployment["docker_deploy_app"]), "public_url") ??
|
|
217
|
+
stringValue(deployment, "auto_subdomain") ??
|
|
218
|
+
null,
|
|
219
|
+
},
|
|
220
|
+
archive: { artifact_sha256: identity.digest },
|
|
221
|
+
host: {
|
|
222
|
+
id: stringValue(deployment, "docker_deploy_host_id") ??
|
|
223
|
+
stringValue(record(deployment["docker_deploy_app"]), "docker_deploy_host_id") ??
|
|
224
|
+
null,
|
|
225
|
+
status: "active",
|
|
226
|
+
appliance_status: "healthy",
|
|
227
|
+
},
|
|
228
|
+
database: {
|
|
229
|
+
attached: contract.capabilities.database?.required === true,
|
|
230
|
+
},
|
|
231
|
+
connectors: {
|
|
232
|
+
ids: contract.capabilities.connectors
|
|
233
|
+
.filter((connector) => connector.required !== false)
|
|
234
|
+
.map((connector) => connector.id)
|
|
235
|
+
.sort(),
|
|
236
|
+
},
|
|
237
|
+
jobs: {
|
|
238
|
+
ids: contract.capabilities.jobs
|
|
239
|
+
.filter((job) => job.required !== false)
|
|
240
|
+
.map((job) => job.id)
|
|
241
|
+
.sort(),
|
|
242
|
+
},
|
|
243
|
+
policy: { fingerprint: contractFingerprint(contract.policy) },
|
|
244
|
+
},
|
|
245
|
+
capabilities: contract.capabilities,
|
|
246
|
+
policy: contract.policy,
|
|
247
|
+
});
|
|
248
|
+
const durable = payload(await client.apiPost(`/api/v1/deployments/${encodeURIComponent(link.deploymentId)}/plans`, { plan }, { "Idempotency-Key": `plan:${plan.fingerprint}` }));
|
|
249
|
+
const durableId = stringValue(durable, "id");
|
|
250
|
+
if (!durableId) {
|
|
251
|
+
throw new UserError("The control plane did not persist the immutable application plan.");
|
|
252
|
+
}
|
|
253
|
+
plan = { ...plan, control_plane_plan_id: durableId };
|
|
254
|
+
savePlan(dir, plan);
|
|
255
|
+
output(opts, { plan, plan_path: planPath(dir, plan) });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
handleError(error);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
changes
|
|
262
|
+
.command("approve <plan-id>")
|
|
263
|
+
.argument("[path]", "Application directory", ".")
|
|
264
|
+
.option("--actor <identity>", "Approval actor identity")
|
|
265
|
+
.option("--json", "Output stable JSON")
|
|
266
|
+
.action(async (planId, inputPath, opts) => {
|
|
267
|
+
try {
|
|
268
|
+
const dir = path.resolve(inputPath);
|
|
269
|
+
let plan = approvePlan(dir, loadPlan(dir, planId), actorName(opts.actor));
|
|
270
|
+
if (!plan.control_plane_plan_id) {
|
|
271
|
+
throw new UserError(`Saved plan ${plan.plan_id} has no durable control-plane record.`, "Create a new plan with `miosa changes plan`.");
|
|
272
|
+
}
|
|
273
|
+
const controlPlanePlanId = plan.control_plane_plan_id;
|
|
274
|
+
const client = new MiosaClient(loadConfig());
|
|
275
|
+
const durable = payload(await client.apiPost(`/api/v1/application-plans/${encodeURIComponent(controlPlanePlanId)}/approve`, {
|
|
276
|
+
fingerprint: plan.fingerprint,
|
|
277
|
+
actor: actorName(opts.actor),
|
|
278
|
+
}, { "Idempotency-Key": `approve:${plan.fingerprint}:${actorName(opts.actor)}` }));
|
|
279
|
+
plan = { ...plan, control_plane_plan_id: stringValue(durable, "id") ?? plan.control_plane_plan_id };
|
|
280
|
+
savePlan(dir, plan);
|
|
281
|
+
output(opts, { plan, plan_path: planPath(dir, plan) });
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
handleError(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
changes
|
|
288
|
+
.command("show <plan-id>")
|
|
289
|
+
.argument("[path]", "Application directory", ".")
|
|
290
|
+
.option("--json", "Output stable JSON")
|
|
291
|
+
.action((planId, inputPath, opts) => {
|
|
292
|
+
try {
|
|
293
|
+
output(opts, loadPlan(path.resolve(inputPath), planId));
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
handleError(error);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
changes
|
|
300
|
+
.command("apply <plan-id>")
|
|
301
|
+
.argument("[path]", "Application directory", ".")
|
|
302
|
+
.option("--timeout <seconds>", "Activation timeout", Number, 600)
|
|
303
|
+
.option("--json", "Output stable JSON")
|
|
304
|
+
.action(async (planId, inputPath, opts) => {
|
|
305
|
+
try {
|
|
306
|
+
const dir = path.resolve(inputPath);
|
|
307
|
+
const link = requireApplicationLink(dir);
|
|
308
|
+
const contract = resolveApplicationContract(dir, link);
|
|
309
|
+
let plan = loadPlan(dir, planId);
|
|
310
|
+
assertPlanApplicable(plan, contract.scope);
|
|
311
|
+
if (!plan.control_plane_plan_id) {
|
|
312
|
+
throw new UserError(`Saved plan ${plan.plan_id} has no durable control-plane record.`);
|
|
313
|
+
}
|
|
314
|
+
const controlPlanePlanId = plan.control_plane_plan_id;
|
|
315
|
+
const client = new MiosaClient(loadConfig());
|
|
316
|
+
await assertPrePromotionGates(client, plan);
|
|
317
|
+
await client.apiPost(`/api/v1/application-plans/${encodeURIComponent(controlPlanePlanId)}/apply`, { fingerprint: plan.fingerprint }, { "Idempotency-Key": `apply:${plan.fingerprint}` });
|
|
318
|
+
plan = { ...plan, state: "applying", updated_at: new Date().toISOString() };
|
|
319
|
+
savePlan(dir, plan);
|
|
320
|
+
try {
|
|
321
|
+
const result = await activateLinkedRelease({
|
|
322
|
+
action: "promote",
|
|
323
|
+
dir,
|
|
324
|
+
link,
|
|
325
|
+
releaseId: plan.release.id,
|
|
326
|
+
timeout: opts.timeout,
|
|
327
|
+
idempotencyKey: `plan:${plan.fingerprint}`,
|
|
328
|
+
verificationEvidence: {
|
|
329
|
+
migration_verified: true,
|
|
330
|
+
policy_verified: true,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
plan = {
|
|
334
|
+
...plan,
|
|
335
|
+
state: result.receipt.result === "verified" ? "verified" : "blocked",
|
|
336
|
+
updated_at: new Date().toISOString(),
|
|
337
|
+
};
|
|
338
|
+
savePlan(dir, plan);
|
|
339
|
+
await client.apiPost(`/api/v1/application-plans/${encodeURIComponent(controlPlanePlanId)}/complete`, {
|
|
340
|
+
result: result.receipt.result,
|
|
341
|
+
receipt: result.receipt,
|
|
342
|
+
}, { "Idempotency-Key": `complete:${plan.fingerprint}` });
|
|
343
|
+
output(opts, { plan, ...result });
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
savePlan(dir, {
|
|
347
|
+
...plan,
|
|
348
|
+
state: "failed",
|
|
349
|
+
updated_at: new Date().toISOString(),
|
|
350
|
+
});
|
|
351
|
+
await client
|
|
352
|
+
.apiPost(`/api/v1/application-plans/${encodeURIComponent(controlPlanePlanId)}/complete`, {
|
|
353
|
+
result: "failed",
|
|
354
|
+
receipt: {
|
|
355
|
+
error: error instanceof Error ? error.message : String(error),
|
|
356
|
+
},
|
|
357
|
+
}, { "Idempotency-Key": `complete:${plan.fingerprint}:failed` })
|
|
358
|
+
.catch(() => undefined);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
handleError(error);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
const placement = program
|
|
367
|
+
.command("placement")
|
|
368
|
+
.description("Inspect the exact runtime placement for a linked application");
|
|
369
|
+
placement
|
|
370
|
+
.command("show")
|
|
371
|
+
.argument("[path]", "Application directory", ".")
|
|
372
|
+
.option("--json", "Output stable JSON")
|
|
373
|
+
.action(async (inputPath, opts) => {
|
|
374
|
+
try {
|
|
375
|
+
const link = requireApplicationLink(path.resolve(inputPath));
|
|
376
|
+
const deployment = await deploymentState(new MiosaClient(loadConfig()), link.deploymentId);
|
|
377
|
+
output(opts, {
|
|
378
|
+
deployment_id: link.deploymentId,
|
|
379
|
+
product: stringValue(deployment, "deployment_product") ?? null,
|
|
380
|
+
host_id: stringValue(deployment, "docker_deploy_host_id") ?? null,
|
|
381
|
+
active_release_id: stringValue(deployment, "active_release_id") ?? null,
|
|
382
|
+
active_version_id: stringValue(deployment, "active_version_id") ?? null,
|
|
383
|
+
public_url: stringValue(deployment, "public_url") ?? null,
|
|
384
|
+
state: stringValue(deployment, "state") ?? "unknown",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
handleError(error);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
const drift = program
|
|
392
|
+
.command("drift")
|
|
393
|
+
.description("Detect and report drift from an approved immutable plan");
|
|
394
|
+
drift
|
|
395
|
+
.command("detect <plan-id>")
|
|
396
|
+
.argument("[path]", "Application directory", ".")
|
|
397
|
+
.option("--json", "Output stable JSON")
|
|
398
|
+
.action(async (planId, inputPath, opts) => {
|
|
399
|
+
try {
|
|
400
|
+
const dir = path.resolve(inputPath);
|
|
401
|
+
const plan = loadPlan(dir, planId);
|
|
402
|
+
const client = new MiosaClient(loadConfig());
|
|
403
|
+
const [deployment, environment] = await Promise.all([
|
|
404
|
+
deploymentState(client, plan.scope.deployment_id),
|
|
405
|
+
client.apiGet(`/api/v1/deployments/${encodeURIComponent(plan.scope.deployment_id)}/env`),
|
|
406
|
+
]);
|
|
407
|
+
const currentContract = resolveApplicationContract(dir, requireApplicationLink(dir));
|
|
408
|
+
const hostId = stringValue(deployment, "docker_deploy_host_id") ??
|
|
409
|
+
stringValue(record(deployment["docker_deploy_app"]), "docker_deploy_host_id");
|
|
410
|
+
const host = hostId
|
|
411
|
+
? payload(await client
|
|
412
|
+
.apiGet(`/api/v1/docker-deploy/hosts/${encodeURIComponent(hostId)}`)
|
|
413
|
+
.catch(() => ({})))
|
|
414
|
+
: {};
|
|
415
|
+
const connectorIds = (await Promise.all(plan.capabilities.connectors.map(async (connector) => {
|
|
416
|
+
const response = payload(await client
|
|
417
|
+
.apiPost(`/api/v1/deployments/${encodeURIComponent(plan.scope.deployment_id)}/connectors/preflight`, { connector: connector.id })
|
|
418
|
+
.catch(() => ({})));
|
|
419
|
+
return record(response["status"])["bound"] === true
|
|
420
|
+
? connector.id
|
|
421
|
+
: null;
|
|
422
|
+
})))
|
|
423
|
+
.filter((id) => Boolean(id))
|
|
424
|
+
.sort();
|
|
425
|
+
const deploymentMetadata = record(deployment["metadata"]);
|
|
426
|
+
const dockerApp = record(deployment["docker_deploy_app"]);
|
|
427
|
+
const runningDigest = stringValue(deployment, "running_artifact_sha256") ??
|
|
428
|
+
stringValue(dockerApp, "artifact_sha256") ??
|
|
429
|
+
stringValue(deploymentMetadata, "running_artifact_sha256") ??
|
|
430
|
+
null;
|
|
431
|
+
const actualJobs = [
|
|
432
|
+
deployment["scheduled_jobs"],
|
|
433
|
+
deploymentMetadata["scheduled_jobs"],
|
|
434
|
+
]
|
|
435
|
+
.flatMap((value) => (Array.isArray(value) ? value : []))
|
|
436
|
+
.flatMap((candidate) => {
|
|
437
|
+
const job = record(candidate);
|
|
438
|
+
const id = stringValue(job, "id") ?? stringValue(job, "name");
|
|
439
|
+
const healthy = job["enabled"] !== false &&
|
|
440
|
+
job["paused"] !== true &&
|
|
441
|
+
stringValue(job, "status") !== "failed" &&
|
|
442
|
+
stringValue(job, "last_run_status") !== "failed";
|
|
443
|
+
return id && healthy ? [id] : [];
|
|
444
|
+
})
|
|
445
|
+
.sort();
|
|
446
|
+
const publicUrl = stringValue(deployment, "public_url") ??
|
|
447
|
+
stringValue(record(deployment["docker_deploy_app"]), "public_url") ??
|
|
448
|
+
stringValue(deployment, "auto_subdomain") ??
|
|
449
|
+
null;
|
|
450
|
+
const envNames = new Set((environment.data ?? [])
|
|
451
|
+
.map((item) => item.name)
|
|
452
|
+
.filter((name) => Boolean(name)));
|
|
453
|
+
const actual = {
|
|
454
|
+
scope: currentContract.scope,
|
|
455
|
+
release: {
|
|
456
|
+
id: stringValue(deployment, "active_release_id") ?? null,
|
|
457
|
+
version_id: stringValue(deployment, "active_version_id") ?? null,
|
|
458
|
+
artifact_sha256: runningDigest,
|
|
459
|
+
},
|
|
460
|
+
desired_state: {
|
|
461
|
+
route: { public_url: publicUrl },
|
|
462
|
+
archive: {
|
|
463
|
+
artifact_sha256: runningDigest,
|
|
464
|
+
},
|
|
465
|
+
host: {
|
|
466
|
+
id: hostId ?? null,
|
|
467
|
+
status: stringValue(host, "status") ?? (hostId ? "unknown" : "active"),
|
|
468
|
+
appliance_status: stringValue(host, "appliance_status") ??
|
|
469
|
+
(hostId ? "unknown" : "healthy"),
|
|
470
|
+
},
|
|
471
|
+
database: {
|
|
472
|
+
attached: envNames.has("DATABASE_URL") ||
|
|
473
|
+
Boolean(stringValue(deployment, "database_id") ??
|
|
474
|
+
stringValue(deploymentMetadata, "database_id")),
|
|
475
|
+
},
|
|
476
|
+
connectors: { ids: connectorIds },
|
|
477
|
+
jobs: { ids: actualJobs },
|
|
478
|
+
policy: {
|
|
479
|
+
fingerprint: contractFingerprint(currentContract.policy),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
const expected = {
|
|
484
|
+
scope: plan.scope,
|
|
485
|
+
release: {
|
|
486
|
+
id: plan.release.id,
|
|
487
|
+
version_id: plan.release.version_id,
|
|
488
|
+
artifact_sha256: plan.release.artifact_sha256,
|
|
489
|
+
},
|
|
490
|
+
desired_state: plan.desired_state,
|
|
491
|
+
};
|
|
492
|
+
const items = detectContractDrift(expected, actual);
|
|
493
|
+
output(opts, {
|
|
494
|
+
plan_id: plan.plan_id,
|
|
495
|
+
drifted: items.length > 0,
|
|
496
|
+
items,
|
|
497
|
+
reconciliation: items.length === 0
|
|
498
|
+
? null
|
|
499
|
+
: `miosa changes apply ${plan.plan_id} . --json`,
|
|
500
|
+
});
|
|
501
|
+
if (items.length > 0)
|
|
502
|
+
process.exitCode = 2;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
handleError(error);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
drift
|
|
509
|
+
.command("reconcile <plan-id>")
|
|
510
|
+
.description("Print the exact approved reconciliation action")
|
|
511
|
+
.argument("[path]", "Application directory", ".")
|
|
512
|
+
.option("--json", "Output stable JSON")
|
|
513
|
+
.action((planId, inputPath, opts) => {
|
|
514
|
+
try {
|
|
515
|
+
const plan = loadPlan(path.resolve(inputPath), planId);
|
|
516
|
+
output(opts, {
|
|
517
|
+
automatic: false,
|
|
518
|
+
reason: "Reconciliation is a production mutation and requires exact apply.",
|
|
519
|
+
command: `miosa changes apply ${plan.plan_id} . --json`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
handleError(error);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
const policy = program
|
|
527
|
+
.command("policy")
|
|
528
|
+
.description("Evaluate capability and approval policy");
|
|
529
|
+
policy
|
|
530
|
+
.command("check")
|
|
531
|
+
.argument("[path]", "Application directory", ".")
|
|
532
|
+
.option("--json", "Output stable JSON")
|
|
533
|
+
.action((inputPath, opts) => {
|
|
534
|
+
try {
|
|
535
|
+
const dir = path.resolve(inputPath);
|
|
536
|
+
const contract = resolveApplicationContract(dir, requireApplicationLink(dir));
|
|
537
|
+
output(opts, {
|
|
538
|
+
allowed: contract.policy.allowed_environments.length === 0 ||
|
|
539
|
+
contract.policy.allowed_environments.includes(contract.scope.environment),
|
|
540
|
+
scope: contract.scope,
|
|
541
|
+
policy: contract.policy,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
handleError(error);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
const evidence = program
|
|
549
|
+
.command("evidence")
|
|
550
|
+
.description("Inspect durable release receipts and verification evidence");
|
|
551
|
+
evidence
|
|
552
|
+
.command("list")
|
|
553
|
+
.argument("[path]", "Application directory", ".")
|
|
554
|
+
.option("--json", "Output stable JSON")
|
|
555
|
+
.action((inputPath, opts) => {
|
|
556
|
+
const dir = path.join(path.resolve(inputPath), ".miosa", "receipts");
|
|
557
|
+
output(opts, { receipts: listJsonFiles(dir), path: dir });
|
|
558
|
+
});
|
|
559
|
+
evidence
|
|
560
|
+
.command("show <receipt-id>")
|
|
561
|
+
.argument("[path]", "Application directory", ".")
|
|
562
|
+
.option("--json", "Output stable JSON")
|
|
563
|
+
.action((receiptId, inputPath, opts) => {
|
|
564
|
+
try {
|
|
565
|
+
const file = path.join(path.resolve(inputPath), ".miosa", "receipts", `${receiptId}.json`);
|
|
566
|
+
if (!fs.existsSync(file))
|
|
567
|
+
throw new UserError(`Receipt not found: ${receiptId}`);
|
|
568
|
+
output(opts, JSON.parse(fs.readFileSync(file, "utf8")));
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
handleError(error);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
const incidents = program
|
|
575
|
+
.command("incidents")
|
|
576
|
+
.description("Inspect failed or blocked local deployment operations");
|
|
577
|
+
incidents
|
|
578
|
+
.command("list")
|
|
579
|
+
.argument("[path]", "Application directory", ".")
|
|
580
|
+
.option("--json", "Output stable JSON")
|
|
581
|
+
.action((inputPath, opts) => {
|
|
582
|
+
const dir = path.join(path.resolve(inputPath), ".miosa", "operations");
|
|
583
|
+
const incidents = listJsonFiles(dir)
|
|
584
|
+
.map((file) => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8")))
|
|
585
|
+
.filter((item) => item.state === "failed" || item.state === "blocked");
|
|
586
|
+
output(opts, { incidents, count: incidents.length });
|
|
587
|
+
});
|
|
588
|
+
const wait = program
|
|
589
|
+
.command("wait")
|
|
590
|
+
.description("Wait for a durable server operation to reach a terminal state");
|
|
591
|
+
wait
|
|
592
|
+
.command("operation <operation-id>")
|
|
593
|
+
.option("--timeout <seconds>", "Wait timeout", Number, 600)
|
|
594
|
+
.option("--json", "Output stable JSON")
|
|
595
|
+
.action(async (operationId, opts) => {
|
|
596
|
+
try {
|
|
597
|
+
const client = new MiosaClient(loadConfig());
|
|
598
|
+
const deadline = Date.now() + opts.timeout * 1_000;
|
|
599
|
+
let operation = {};
|
|
600
|
+
do {
|
|
601
|
+
operation = payload(await client.apiGet(`/api/v1/operations/${encodeURIComponent(operationId)}`));
|
|
602
|
+
const status = stringValue(operation, "status");
|
|
603
|
+
if (["succeeded", "failed", "canceled"].includes(status ?? "")) {
|
|
604
|
+
output(opts, operation);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
608
|
+
} while (Date.now() < deadline);
|
|
609
|
+
throw new UserError(`Timed out waiting for operation ${operationId}.`);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
handleError(error);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
const memory = program
|
|
616
|
+
.command("memory")
|
|
617
|
+
.description("Record and inspect local operator decisions for an application");
|
|
618
|
+
memory
|
|
619
|
+
.command("record <message>")
|
|
620
|
+
.argument("[path]", "Application directory", ".")
|
|
621
|
+
.option("--json", "Output stable JSON")
|
|
622
|
+
.action((message, inputPath, opts) => {
|
|
623
|
+
const dir = path.join(path.resolve(inputPath), ".miosa");
|
|
624
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
625
|
+
const file = path.join(dir, "memory.jsonl");
|
|
626
|
+
const entry = {
|
|
627
|
+
recorded_at: new Date().toISOString(),
|
|
628
|
+
actor: actorName(),
|
|
629
|
+
message,
|
|
630
|
+
};
|
|
631
|
+
fs.appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
632
|
+
output(opts, entry, chalk.green("Decision recorded."));
|
|
633
|
+
});
|
|
634
|
+
memory
|
|
635
|
+
.command("list")
|
|
636
|
+
.argument("[path]", "Application directory", ".")
|
|
637
|
+
.option("--json", "Output stable JSON")
|
|
638
|
+
.action((inputPath, opts) => {
|
|
639
|
+
const file = path.join(path.resolve(inputPath), ".miosa", "memory.jsonl");
|
|
640
|
+
const entries = fs.existsSync(file)
|
|
641
|
+
? fs
|
|
642
|
+
.readFileSync(file, "utf8")
|
|
643
|
+
.trim()
|
|
644
|
+
.split("\n")
|
|
645
|
+
.filter(Boolean)
|
|
646
|
+
.map((line) => JSON.parse(line))
|
|
647
|
+
: [];
|
|
648
|
+
output(opts, { entries });
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
//# sourceMappingURL=operating-contract.js.map
|