rampway 0.5.0 → 0.5.2

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.
@@ -0,0 +1,292 @@
1
+ // @ts-check
2
+ import { execFile } from "node:child_process";
3
+ import { releaseIdFromDate } from "../core/release-id.js";
4
+ import { redactText } from "../core/redaction.js";
5
+ import { Reporter } from "../core/reporter.js";
6
+ import { remoteGitRepoLabel } from "../core/git-source.js";
7
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
8
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
9
+ const REQUEST_TIMEOUT_MS = 30000;
10
+ const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/u;
11
+ const TERMINAL_STATUSES = ["succeeded", "failed", "interrupted", "reconciliation_required"];
12
+ /**
13
+ * @name DeploymentRun
14
+ * @description Public representation of a control-plane deployment run.
15
+ * @typedef {object} DeploymentRun
16
+ * @property {string} id
17
+ * @property {string} status
18
+ * @property {Record<string, any>} [result]
19
+ * @property {{message?: string, recovery?: any}} [error]
20
+ */
21
+ /**
22
+ * Control-plane failure that must not trigger a transport fallback: an
23
+ * authenticated admission decision (401/403/409/422), a 5xx that already
24
+ * admitted a run, or an interruption after a run was admitted.
25
+ */
26
+ export class ControlPlaneError extends Error {
27
+ /**
28
+ * @param {string} message
29
+ * @param {{status?: number, cause?: unknown, runId?: string|null}} [opts]
30
+ */
31
+ constructor(message, { status, cause, runId = null } = {}) {
32
+ super(message, cause === undefined ? undefined : { cause });
33
+ /** @type {number|null} */
34
+ this.status = status ?? null;
35
+ /** @type {string|null} */
36
+ this.runId = runId;
37
+ }
38
+ }
39
+ /**
40
+ * Control-plane unavailability that may fall back to the direct transport:
41
+ * network-level failure, a not-deployed endpoint/target (404), or a 5xx
42
+ * without any admitted run.
43
+ */
44
+ export class ControlPlaneUnavailableError extends ControlPlaneError {
45
+ /**
46
+ * @param {string} message
47
+ * @param {{status?: number, cause?: unknown}} [opts]
48
+ */
49
+ constructor(message, { status, cause } = {}) {
50
+ super(message, { status, cause });
51
+ }
52
+ }
53
+ /**
54
+ * Whether a control-plane failure is eligible for the SSH/direct fallback.
55
+ *
56
+ * The control plane is tried first only while it is unavailable or not
57
+ * deployed; authentication and validation decisions (401/403/409/422) and any
58
+ * failure after a run was admitted are always surfaced instead.
59
+ *
60
+ * @param {unknown} error
61
+ * @returns {boolean}
62
+ */
63
+ export function isFallbackEligible(error) {
64
+ return error instanceof ControlPlaneUnavailableError;
65
+ }
66
+ /**
67
+ * @param {unknown} value
68
+ * @returns {value is string} Whether the value is a full 40-character commit SHA.
69
+ */
70
+ export function isFullCommitSha(value) {
71
+ return typeof value === "string" && FULL_SHA_PATTERN.test(value);
72
+ }
73
+ /**
74
+ * Resolves the immutable revision a control-plane deploy requires. A full
75
+ * 40-character SHA is used as-is; otherwise the configured release branch is
76
+ * resolved to its current tip with `git ls-remote`.
77
+ *
78
+ * @param {{repo?: string, revision?: string, branch?: string}} args
79
+ * @returns {Promise<string>} Full commit SHA.
80
+ */
81
+ export async function resolveControlPlaneRevision({ repo, revision, branch = "master" }) {
82
+ if (isFullCommitSha(revision))
83
+ return revision;
84
+ if (revision)
85
+ throw new Error(`Control-plane deploys require a full 40-character Git revision, got '${revision}'. Pass the full commit SHA or use --transport=ssh.`);
86
+ if (!repo)
87
+ throw new Error("Control-plane deploys require stage.repo to resolve the release branch to a commit SHA");
88
+ let output;
89
+ try {
90
+ output = (await runGit(["ls-remote", "--exit-code", repo, `refs/heads/${branch}`])).output;
91
+ }
92
+ catch (error) {
93
+ throw new Error(`Could not resolve branch '${branch}' of ${remoteGitRepoLabel(repo)} to a commit SHA`, { cause: error });
94
+ }
95
+ const sha = output.trim().split(/\s+/u)[0] ?? "";
96
+ if (!isFullCommitSha(sha))
97
+ throw new Error(`Could not resolve branch '${branch}' of ${remoteGitRepoLabel(repo)} to a commit SHA`);
98
+ return sha;
99
+ }
100
+ /**
101
+ * Creates a deployment run on the control plane and classifies the outcome.
102
+ *
103
+ * @param {{endpoint: string, accessToken: string, project: string, stage: string, revision: string, idempotencyKey: string, fetchImpl?: typeof fetch}} args
104
+ * @returns {Promise<{run: DeploymentRun, replayed: boolean}>}
105
+ */
106
+ export async function createDeploymentRun({ endpoint, accessToken, project, stage, revision, idempotencyKey, fetchImpl = fetch }) {
107
+ const url = `${stripTrailingSlash(endpoint)}/runs`;
108
+ let response;
109
+ try {
110
+ response = await fetchImpl(url, {
111
+ method: "POST",
112
+ headers: {
113
+ Authorization: `Bearer ${accessToken}`,
114
+ "Content-Type": "application/json"
115
+ },
116
+ body: JSON.stringify({ project, stage, revision, idempotencyKey }),
117
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
118
+ });
119
+ }
120
+ catch (error) {
121
+ throw new ControlPlaneUnavailableError(`Control plane unavailable at ${redactText(url, [accessToken])}: ${errorMessage(error)}`, { cause: error });
122
+ }
123
+ const body = await readJsonResponse(response).catch(() => null);
124
+ if (response.status === 200 || response.status === 202) {
125
+ const run = body?.run;
126
+ if (!run || typeof run.id !== "string" || run.id.length === 0) {
127
+ throw new ControlPlaneError(`Control plane returned HTTP ${response.status} without a run`, { status: response.status });
128
+ }
129
+ return { run: /** @type {DeploymentRun} */ (run), replayed: response.status === 200 };
130
+ }
131
+ if (response.status === 404) {
132
+ throw new ControlPlaneUnavailableError("Control plane endpoint or target is not deployed (HTTP 404)", { status: response.status });
133
+ }
134
+ if (response.status >= 500 && !body?.run) {
135
+ throw new ControlPlaneUnavailableError(`Control plane server unavailable (HTTP ${response.status})`, { status: response.status });
136
+ }
137
+ const reason = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`;
138
+ throw new ControlPlaneError(redactText(`Control plane rejected the deployment request: ${reason}`, [accessToken]), { status: response.status });
139
+ }
140
+ /**
141
+ * Polls a control-plane run until it reaches a terminal status. Any failure
142
+ * after admission (including lost contact) is a hard error: the run exists on
143
+ * the control plane, so there is nothing left to fall back to.
144
+ *
145
+ * @param {{endpoint: string, accessToken: string, runId: string, pollIntervalMs?: number, timeoutMs?: number, fetchImpl?: typeof fetch, now?: () => number, reporter?: Reporter}} args
146
+ * @returns {Promise<DeploymentRun>}
147
+ */
148
+ export async function pollDeploymentRun({ endpoint, accessToken, runId, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch, now = Date.now, reporter = new Reporter({ lifecycle: false }) }) {
149
+ const url = `${stripTrailingSlash(endpoint)}/runs/${encodeURIComponent(runId)}`;
150
+ const startedAt = now();
151
+ /** @type {string|undefined} */
152
+ let lastStatus;
153
+ for (;;) {
154
+ if (now() - startedAt > timeoutMs) {
155
+ throw new ControlPlaneError(`Control plane run ${runId} did not reach a terminal status within ${Math.round(timeoutMs / 1000)}s (last status '${lastStatus ?? "pending"}')`);
156
+ }
157
+ let response;
158
+ try {
159
+ response = await fetchImpl(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
160
+ }
161
+ catch (error) {
162
+ throw new ControlPlaneError(`Lost contact with control plane while waiting for run ${runId}: ${errorMessage(error)}`, { cause: error, runId });
163
+ }
164
+ const body = await readJsonResponse(response).catch(() => null);
165
+ const run = body?.run;
166
+ if (response.status === 404 || !run) {
167
+ throw new ControlPlaneError(`Control plane no longer reports run ${runId} (HTTP ${response.status})`, { runId, status: response.status });
168
+ }
169
+ if (run.status !== lastStatus) {
170
+ lastStatus = run.status;
171
+ reporter.line(` * Control-plane run ${runId} status: ${run.status}`);
172
+ }
173
+ if (TERMINAL_STATUSES.includes(run.status))
174
+ return /** @type {DeploymentRun} */ (run);
175
+ await delay(pollIntervalMs);
176
+ }
177
+ }
178
+ /**
179
+ * Deploys a stage through the mounted control plane: resolves the immutable
180
+ * revision, creates an idempotent run, polls it to completion, and returns the
181
+ * control-plane deploy report annotated with the run identifier.
182
+ *
183
+ * @param {{stage: import('#types').StageConfig, httpConfig: import('#types').HttpTransportConfig, revision?: string, reporter?: Reporter, fetchImpl?: typeof fetch}} args
184
+ * @returns {Promise<import('#types').DeployReport>}
185
+ */
186
+ export async function deployViaControlPlane({ stage, httpConfig, revision, reporter = new Reporter({ lifecycle: false }), fetchImpl = fetch }) {
187
+ const endpoint = httpConfig.endpoint;
188
+ const accessToken = await resolveAccessToken(httpConfig);
189
+ const project = httpConfig.project ?? stage.application;
190
+ const releaseRevision = await resolveControlPlaneRevision({ repo: stage.repo, revision, branch: stage.branch });
191
+ const idempotencyKey = controlPlaneIdempotencyKey(project, stage.stageName, releaseRevision);
192
+ reporter.line(` * Requesting control-plane deploy of ${project}/${stage.stageName} revision ${releaseRevision}`);
193
+ const { run, replayed } = await createDeploymentRun({ endpoint, accessToken, fetchImpl, project, stage: stage.stageName, revision: releaseRevision, idempotencyKey });
194
+ reporter.line(replayed
195
+ ? ` * Control plane replayed run ${run.id} for idempotency key ${idempotencyKey}`
196
+ : ` * Control plane admitted run ${run.id}`);
197
+ const finalRun = await pollDeploymentRun({ endpoint, accessToken, fetchImpl, runId: run.id, pollIntervalMs: httpConfig.pollIntervalMs, timeoutMs: httpConfig.timeoutMs, reporter });
198
+ if (finalRun.status !== "succeeded") {
199
+ const detail = finalRun.error && typeof finalRun.error === "object" && typeof finalRun.error.message === "string"
200
+ ? finalRun.error.message
201
+ : "no failure detail recorded";
202
+ throw new Error(redactText(`Control-plane run ${finalRun.id} ended with status '${finalRun.status}': ${detail}`, [accessToken]));
203
+ }
204
+ const result = finalRun.result && typeof finalRun.result === "object" ? finalRun.result : {};
205
+ reporter.line(` * Control-plane run ${finalRun.id} succeeded`);
206
+ return {
207
+ ...result,
208
+ application: result.application ?? stage.application,
209
+ stage: result.stage ?? stage.stageName,
210
+ releaseId: result.releaseId ?? null,
211
+ revision: result.revision ?? releaseRevision,
212
+ event: result.event ?? "deploy",
213
+ runId: finalRun.id,
214
+ transport: "http"
215
+ };
216
+ }
217
+ /**
218
+ * Builds a client-generated idempotency key that is stable per invocation, so
219
+ * retrying the same command replays an existing admitted run instead of
220
+ * creating a duplicate.
221
+ *
222
+ * @param {string} project
223
+ * @param {string} stage
224
+ * @param {string} revision
225
+ * @returns {string}
226
+ */
227
+ export function controlPlaneIdempotencyKey(project, stage, revision) {
228
+ return `${project}-${stage}-${revision}-${releaseIdFromDate()}`;
229
+ }
230
+ /**
231
+ * @param {import('#types').HttpTransportConfig} httpConfig
232
+ * @returns {string} Bearer token.
233
+ */
234
+ function resolveAccessToken(httpConfig) {
235
+ if (httpConfig.accessToken)
236
+ return httpConfig.accessToken;
237
+ if (httpConfig.accessTokenEnv) {
238
+ const value = process.env[httpConfig.accessTokenEnv];
239
+ if (typeof value !== "string" || value.length === 0) {
240
+ throw new Error(`stage.http.accessTokenEnv '${httpConfig.accessTokenEnv}' is not set in the environment`);
241
+ }
242
+ return value;
243
+ }
244
+ throw new Error("stage.http requires accessToken or accessTokenEnv");
245
+ }
246
+ /**
247
+ * @param {string[]} args
248
+ * @returns {Promise<{output: string}>}
249
+ */
250
+ function runGit(args) {
251
+ return new Promise((resolve, reject) => {
252
+ execFile("git", args, { timeout: REQUEST_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
253
+ if (error) {
254
+ const detail = String(stderr || error.message).trim();
255
+ reject(new Error(`git ${args[0]} failed: ${redactText(detail)}`));
256
+ return;
257
+ }
258
+ resolve({ output: String(stdout) });
259
+ });
260
+ });
261
+ }
262
+ /**
263
+ * @param {string} endpoint
264
+ * @returns {string}
265
+ */
266
+ function stripTrailingSlash(endpoint) {
267
+ return endpoint.replace(/\/+$/u, "");
268
+ }
269
+ /**
270
+ * @param {Response} response
271
+ * @returns {Promise<Record<string, any>|null>}
272
+ */
273
+ async function readJsonResponse(response) {
274
+ const text = await response.text();
275
+ return text ? JSON.parse(text) : null;
276
+ }
277
+ /**
278
+ * @param {unknown} error
279
+ * @returns {string}
280
+ */
281
+ function errorMessage(error) {
282
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : null;
283
+ return cause ?? (error instanceof Error ? error.message : String(error));
284
+ }
285
+ /**
286
+ * @param {number} ms
287
+ * @returns {Promise<void>}
288
+ */
289
+ function delay(ms) {
290
+ return new Promise(resolve => setTimeout(resolve, ms));
291
+ }
292
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/velocious/client.js"],"names":[],"mappings":"AAAA,YAAY;AAEZ,OAAO,EAAC,QAAQ,EAAC,MAAM,oBAAoB,CAAA;AAE3C,OAAO,EAAC,iBAAiB,EAAC,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAC,UAAU,EAAC,MAAM,sBAAsB,CAAA;AAC/C,OAAO,EAAC,QAAQ,EAAC,MAAM,qBAAqB,CAAA;AAC5C,OAAO,EAAC,kBAAkB,EAAC,MAAM,uBAAuB,CAAA;AAExD,MAAM,wBAAwB,GAAG,IAAI,CAAA;AACrC,MAAM,kBAAkB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AACzC,MAAM,kBAAkB,GAAG,KAAK,CAAA;AAChC,MAAM,gBAAgB,GAAG,iBAAiB,CAAA;AAC1C,MAAM,iBAAiB,GAAG,CAAC,WAAW,EAAE,QAAQ,EAAE,aAAa,EAAE,yBAAyB,CAAC,CAAA;AAE3F;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAC1C;;;OAGG;IACH,YAAY,OAAO,EAAE,EAAC,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI,EAAC,GAAG,EAAE;QACrD,KAAK,CAAC,OAAO,EAAE,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAC,KAAK,EAAC,CAAC,CAAA;QACzD,0BAA0B;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,IAAI,IAAI,CAAA;QAC5B,0BAA0B;QAC1B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;IACpB,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,4BAA6B,SAAQ,iBAAiB;IACjE;;;OAGG;IACH,YAAY,OAAO,EAAE,EAAC,MAAM,EAAE,KAAK,EAAC,GAAG,EAAE;QACvC,KAAK,CAAC,OAAO,EAAE,EAAC,MAAM,EAAE,KAAK,EAAC,CAAC,CAAA;IACjC,CAAC;CACF;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAK;IACtC,OAAO,KAAK,YAAY,4BAA4B,CAAA;AACtD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,KAAK;IACnC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAClE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAAC,EAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,EAAC;IACnF,IAAI,eAAe,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAA;IAC9C,IAAI,QAAQ;QAAE,MAAM,IAAI,KAAK,CAAC,wEAAwE,QAAQ,qDAAqD,CAAC,CAAA;IACpK,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,wFAAwF,CAAC,CAAA;IACpH,IAAI,MAAM,CAAA;IACV,IAAI,CAAC;QACH,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,CAAC,WAAW,EAAE,aAAa,EAAE,IAAI,EAAE,cAAc,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAA;IAC5F,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,6BAA6B,MAAM,QAAQ,kBAAkB,CAAC,IAAI,CAAC,kBAAkB,EAAE,EAAC,KAAK,EAAE,KAAK,EAAC,CAAC,CAAA;IACxH,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IAChD,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,MAAM,QAAQ,kBAAkB,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAA;IACjI,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,SAAS,GAAG,KAAK,EAAC;IAC5H,MAAM,GAAG,GAAG,GAAG,kBAAkB,CAAC,QAAQ,CAAC,OAAO,CAAA;IAClD,IAAI,QAAQ,CAAA;IACZ,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE;YAC9B,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,aAAa,EAAE,UAAU,WAAW,EAAE;gBACtC,cAAc,EAAE,kBAAkB;aACnC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAC,CAAC;YAChE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,kBAAkB,CAAC;SAChD,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,4BAA4B,CAAC,gCAAgC,UAAU,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,CAAC,KAAK,YAAY,CAAC,KAAK,CAAC,EAAE,EAAE,EAAC,KAAK,EAAE,KAAK,EAAC,CAAC,CAAA;IAClJ,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;IAC/D,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QACvD,MAAM,GAAG,GAAG,IAAI,EAAE,GAAG,CAAA;QACrB,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ,IAAI,GAAG,CAAC,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9D,MAAM,IAAI,iBAAiB,CAAC,+BAA+B,QAAQ,CAAC,MAAM,gBAAgB,EAAE,EAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAC,CAAC,CAAA;QACxH,CAAC;QACD,OAAO,EAAC,GAAG,EAAE,4BAA4B,CAAC,CAAC,GAAG,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAC,CAAA;IACrF,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QAC5B,MAAM,IAAI,4BAA4B,CAAC,6DAA6D,EAAE,EAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAC,CAAC,CAAA;IAClI,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC;QACzC,MAAM,IAAI,4BAA4B,CAAC,0CAA0C,QAAQ,CAAC,MAAM,GAAG,EAAE,EAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAC,CAAC,CAAA;IACjI,CAAC;IACD,MAAM,MAAM,GAAG,OAAO,IAAI,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAA;IACvF,MAAM,IAAI,iBAAiB,CAAC,UAAU,CAAC,kDAAkD,MAAM,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,EAAE,EAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAC,CAAC,CAAA;AAC/I,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,EAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,cAAc,GAAG,wBAAwB,EAAE,SAAS,GAAG,kBAAkB,EAAE,SAAS,GAAG,KAAK,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,QAAQ,CAAC,EAAC,SAAS,EAAE,KAAK,EAAC,CAAC,EAAC;IAC/N,MAAM,GAAG,GAAG,GAAG,kBAAkB,CAAC,QAAQ,CAAC,SAAS,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAA;IAC/E,MAAM,SAAS,GAAG,GAAG,EAAE,CAAA;IACvB,+BAA+B;IAC/B,IAAI,UAAU,CAAA;IACd,SAAS,CAAC;QACR,IAAI,GAAG,EAAE,GAAG,SAAS,GAAG,SAAS,EAAE,CAAC;YAClC,MAAM,IAAI,iBAAiB,CAAC,qBAAqB,KAAK,2CAA2C,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,mBAAmB,UAAU,IAAI,SAAS,IAAI,CAAC,CAAA;QAC9K,CAAC;QACD,IAAI,QAAQ,CAAA;QACZ,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,EAAC,OAAO,EAAE,EAAC,aAAa,EAAE,UAAU,WAAW,EAAE,EAAC,EAAE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,kBAAkB,CAAC,EAAC,CAAC,CAAA;QACvI,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,iBAAiB,CAAC,yDAAyD,KAAK,KAAK,YAAY,CAAC,KAAK,CAAC,EAAE,EAAE,EAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAC,CAAC,CAAA;QAC9I,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QAC/D,MAAM,GAAG,GAAG,IAAI,EAAE,GAAG,CAAA;QACrB,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACpC,MAAM,IAAI,iBAAiB,CAAC,uCAAuC,KAAK,UAAU,QAAQ,CAAC,MAAM,GAAG,EAAE,EAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAC,CAAC,CAAA;QACzI,CAAC;QACD,IAAI,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAC9B,UAAU,GAAG,GAAG,CAAC,MAAM,CAAA;YACvB,QAAQ,CAAC,IAAI,CAAC,yBAAyB,KAAK,YAAY,GAAG,CAAC,MAAM,EAAE,CAAC,CAAA;QACvE,CAAC;QACD,IAAI,iBAAiB,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAO,4BAA4B,CAAC,CAAC,GAAG,CAAC,CAAA;QACrF,MAAM,KAAK,CAAC,cAAc,CAAC,CAAA;IAC7B,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,EAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,QAAQ,CAAC,EAAC,SAAS,EAAE,KAAK,EAAC,CAAC,EAAE,SAAS,GAAG,KAAK,EAAC;IACvI,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,CAAA;IACpC,MAAM,WAAW,GAAG,MAAM,kBAAkB,CAAC,UAAU,CAAC,CAAA;IACxD,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,IAAI,KAAK,CAAC,WAAW,CAAA;IACvD,MAAM,eAAe,GAAG,MAAM,2BAA2B,CAAC,EAAC,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAC,CAAC,CAAA;IAC7G,MAAM,cAAc,GAAG,0BAA0B,CAAC,OAAO,EAAE,KAAK,CAAC,SAAS,EAAE,eAAe,CAAC,CAAA;IAC5F,QAAQ,CAAC,IAAI,CAAC,0CAA0C,OAAO,IAAI,KAAK,CAAC,SAAS,aAAa,eAAe,EAAE,CAAC,CAAA;IACjH,MAAM,EAAC,GAAG,EAAE,QAAQ,EAAC,GAAG,MAAM,mBAAmB,CAAC,EAAC,QAAQ,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE,cAAc,EAAC,CAAC,CAAA;IACjK,QAAQ,CAAC,IAAI,CAAC,QAAQ;QACpB,CAAC,CAAC,kCAAkC,GAAG,CAAC,EAAE,wBAAwB,cAAc,EAAE;QAClF,CAAC,CAAC,kCAAkC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAA;IAC/C,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,EAAC,QAAQ,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,CAAC,EAAE,EAAE,cAAc,EAAE,UAAU,CAAC,cAAc,EAAE,SAAS,EAAE,UAAU,CAAC,SAAS,EAAE,QAAQ,EAAC,CAAC,CAAA;IACjL,IAAI,QAAQ,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;QACpC,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,IAAI,OAAO,QAAQ,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,QAAQ,CAAC,KAAK,CAAC,OAAO,KAAK,QAAQ;YAC/G,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,OAAO;YACxB,CAAC,CAAC,4BAA4B,CAAA;QAChC,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC,qBAAqB,QAAQ,CAAC,EAAE,uBAAuB,QAAQ,CAAC,MAAM,MAAM,MAAM,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,CAAA;IAClI,CAAC;IACD,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,IAAI,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAA;IAC5F,QAAQ,CAAC,IAAI,CAAC,yBAAyB,QAAQ,CAAC,EAAE,YAAY,CAAC,CAAA;IAC/D,OAAO;QACL,GAAG,MAAM;QACT,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,KAAK,CAAC,WAAW;QACpD,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,KAAK,CAAC,SAAS;QACtC,SAAS,EAAE,MAAM,CAAC,SAAS,IAAI,IAAI;QACnC,QAAQ,EAAE,MAAM,CAAC,QAAQ,IAAI,eAAe;QAC5C,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,QAAQ;QAC/B,KAAK,EAAE,QAAQ,CAAC,EAAE;QAClB,SAAS,EAAE,MAAM;KAClB,CAAA;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,0BAA0B,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ;IACjE,OAAO,GAAG,OAAO,IAAI,KAAK,IAAI,QAAQ,IAAI,iBAAiB,EAAE,EAAE,CAAA;AACjE,CAAC;AAED;;;GAGG;AACH,SAAS,kBAAkB,CAAC,UAAU;IACpC,IAAI,UAAU,CAAC,WAAW;QAAE,OAAO,UAAU,CAAC,WAAW,CAAA;IACzD,IAAI,UAAU,CAAC,cAAc,EAAE,CAAC;QAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,cAAc,CAAC,CAAA;QACpD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACpD,MAAM,IAAI,KAAK,CAAC,8BAA8B,UAAU,CAAC,cAAc,iCAAiC,CAAC,CAAA;QAC3G,CAAC;QACD,OAAO,KAAK,CAAA;IACd,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAA;AACtE,CAAC;AAED;;;GAGG;AACH,SAAS,MAAM,CAAC,IAAI;IAClB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,EAAC,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,IAAI,GAAG,IAAI,EAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACrG,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAA;gBACrD,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,YAAY,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAA;gBACjE,OAAM;YACR,CAAC;YACD,OAAO,CAAC,EAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAC,CAAC,CAAA;QACnC,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,SAAS,kBAAkB,CAAC,QAAQ;IAClC,OAAO,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;AACtC,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,gBAAgB,CAAC,QAAQ;IACtC,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AACvC,CAAC;AAED;;;GAGG;AACH,SAAS,YAAY,CAAC,KAAK;IACzB,MAAM,KAAK,GAAG,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAA;IACjG,OAAO,KAAK,IAAI,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;AAC1E,CAAC;AAED;;;GAGG;AACH,SAAS,KAAK,CAAC,EAAE;IACf,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAA;AACxD,CAAC"}
@@ -34,6 +34,7 @@ Each stage under `stages` supports:
34
34
  | `strategy` | string | no | Transport-dependent | Deploy strategy: `"local-copy"`, `"remote-git"`, or reserved `"archive-upload"`. Defaults to `"local-copy"` for local transport and `"remote-git"` for SSH transport. `archive-upload` is recognized but not yet deployable. |
35
35
  | `transport` | object | no | `{type: "ssh"}` | Transport configuration. See Transport below. |
36
36
  | `hosts` | array | no | `[]` | For SSH transport: list of hosts with `host`, `user`, and `roles`. |
37
+ | `http` | object | no | — | Optional control-plane transport config. Enables `rampway <stage> deploy --transport=http|auto`. See HTTP control plane below. |
37
38
  | `keepReleases` | number | no | `5` | Number of old releases to keep after cleanup. |
38
39
  | `linkedFiles` | array | no | `[]` | Files symlinked from `shared/` into each release. See Linked files below. |
39
40
  | `linkedDirs` | array | no | `[]` | Directories symlinked from `shared/` into each release. |
@@ -78,6 +79,75 @@ transport: {type: "ssh"},
78
79
  hosts: [{host: "server.example", user: "dev", roles: ["app", "web"]}]
79
80
  ```
80
81
 
82
+ ### http (control plane)
83
+
84
+ Optional transport that runs the deploy through the authenticated Velocious
85
+ deployment API mounted by the application. When `http` is configured,
86
+ `rampway <stage> deploy` defaults to `--transport=auto`; see Deploy transports
87
+ below.
88
+
89
+ | Field | Type | Required | Default | Description |
90
+ |-------|------|----------|---------|-------------|
91
+ | `endpoint` | string | yes | — | Base URL where the API is mounted, e.g. `https://app.example/rampway/deployments`. Must be an `http(s)` URL. |
92
+ | `accessToken` | string | no | — | Bearer token to authenticate the client. Exactly one of `accessToken` or `accessTokenEnv` is required. |
93
+ | `accessTokenEnv` | string | no | — | Name of an environment variable holding the bearer token. Exactly one of `accessToken` or `accessTokenEnv` is required. The token value is never printed. |
94
+ | `project` | string | no | `stage.application` | Project the control plane is allowlisted for. |
95
+ | `pollIntervalMs` | positive integer | no | `2000` | How often to poll the run while it is pending/running. |
96
+ | `timeoutMs` | positive integer | no | `1800000` | How long to keep polling before failing the deploy. |
97
+
98
+ ```js
99
+ http: {
100
+ endpoint: "https://app.example/rampway/deployments",
101
+ accessTokenEnv: "RAMPWAY_DEPLOY_TOKEN",
102
+ project: "my-app",
103
+ pollIntervalMs: 2000,
104
+ timeoutMs: 1800000
105
+ }
106
+ ```
107
+
108
+ The CLI resolves the release revision as a full 40-character SHA; a branch name
109
+ is resolved with `git ls-remote` against `stage.repo`. The control plane only
110
+ accepts full SHAs reachable from the stage's approved release branch. Malformed
111
+ `http` config fails loudly: a missing or non-`http(s)` endpoint, an absent or
112
+ invalid token source, a non-string `project`, or non-positive interval/timeout
113
+ values are all rejected.
114
+
115
+ ### Deploy transports
116
+
117
+ `rampway <stage> deploy --transport=http|ssh|auto` selects how the deploy is
118
+ executed:
119
+
120
+ - `http` — requires `stage.http`; runs entirely through the control plane and
121
+ fails if the stage has no `http` config.
122
+ - `ssh` — runs directly over the stage's `transport` (ssh or local),
123
+ bypassing the control plane.
124
+ - `auto` (default) — uses the control plane when `stage.http` is configured,
125
+ and falls back to the direct transport only when the control plane is
126
+ unreachable (network failure), not deployed for the project/stage (`404`),
127
+ or unavailable (`5xx` without an admitted run). It never falls back on
128
+ authentication or validation errors (`401`, `403`, `409`, `422`), on a
129
+ `5xx` that carries an admitted run, or once a run has been admitted and is
130
+ being polled.
131
+
132
+ Dry runs (`--dry-run`) always print a direct-transport plan and never contact
133
+ the control plane.
134
+
135
+ ### Structural deploy plans
136
+
137
+ `rampway <stage> plan [revision]` renders the deploy structure after loading
138
+ and validating configuration. With no positional revision, selection remains
139
+ `--revision`, then `--branch`, then `stage.revision`, then `stage.branch`. A
140
+ positional revision takes precedence over all four sources and must contain 7
141
+ to 64 hexadecimal characters. The selected value appears in both human and
142
+ JSON output.
143
+
144
+ Planning is structural only: it does not prove that a SHA exists, check branch
145
+ or remote-git reachability, or obtain HTTP control-plane approval. It performs
146
+ no SSH, Git fetch/clone, local-copy, temporary checkout, control-plane request,
147
+ deploy lock, release-directory, runtime, filesystem deployment, or transport
148
+ operation. `local-copy` and `remote-git` steps are descriptions of what a later
149
+ deploy would do, not actions or admission checks.
150
+
81
151
  ## Linked files and directories
82
152
 
83
153
  Linked files and directories are symlinked from `shared/` into every release before tasks run.
@@ -338,6 +408,42 @@ deploy without `config` retains its existing forward-deploy behavior, but
338
408
  runtime-complete rollback fails closed until a successful deploy with explicit
339
409
  stable config has recorded known-good state.
340
410
 
411
+ ### compose
412
+
413
+ Compose runtime adapter for a pre-existing, trusted Docker Compose deployment.
414
+ Rampway runs only the two literal commands configured here from a fixed absolute
415
+ Compose root; it does not construct Compose arguments from release IDs, hooks,
416
+ or command-line input.
417
+
418
+ | Field | Type | Required | Description |
419
+ |-------|------|----------|-------------|
420
+ | `type` | string | yes | `"compose"` |
421
+ | `composeRoot` | absolute path | yes | Fixed directory used as the working directory for both commands. |
422
+ | `handoffCommand` | string | yes | Static trusted command that selects/starts the candidate runtime. |
423
+ | `healthCommand` | string | yes | Static trusted command that performs the runtime health check after handoff. |
424
+
425
+ ```js
426
+ runtime: {
427
+ type: "compose",
428
+ composeRoot: "/srv/myapp/compose",
429
+ handoffCommand: "docker compose up -d --wait web",
430
+ healthCommand: "docker compose exec -T web node scripts/healthcheck.mjs"
431
+ }
432
+ ```
433
+
434
+ The adapter passes the immutable candidate to both commands through the trusted
435
+ `RAMPWAY_RELEASE_ID` and `RAMPWAY_RELEASE_PATH` environment variables, then runs
436
+ the candidate handoff followed by the configured health command before Rampway runs stage health checks or publishes `current`. Its
437
+ snapshot stores only the fixed Compose root and a contract digest: it never
438
+ stores command text or secrets. Configuration is intentionally conservative:
439
+ all three fields are required, `composeRoot` must be absolute, and no runtime
440
+ `env` or extra adapter fields are accepted. Put sensitive values in the trusted
441
+ Compose environment/secret mechanism, not in these commands.
442
+
443
+ A failed candidate handoff or health check, and a failed explicit rollback,
444
+ reactivate and health-check the previous Compose runtime through the same
445
+ runtime deploy API before returning the original failure.
446
+
341
447
  ## Rollback and retained releases
342
448
 
343
449
  `rampway <stage> rollback` selects the latest other safe 14-digit release, or
@@ -128,6 +128,43 @@ const runResponse = await fetch(`${baseUrl}/runs/${encodeURIComponent(created.ru
128
128
  const {run, current} = await runResponse.json()
129
129
  ```
130
130
 
131
+ ## Deploying from the Rampway CLI
132
+
133
+ A stage that configures `stage.http` can deploy through this API directly from
134
+ the CLI:
135
+
136
+ ```sh
137
+ RAMPWAY_DEPLOY_TOKEN=... rampway production deploy --transport=http
138
+ RAMPWAY_DEPLOY_TOKEN=... rampway production deploy --transport=auto
139
+ ```
140
+
141
+ `--transport=auto` (the default) tries the control plane first and falls back
142
+ to the stage's direct SSH/local transport only when the control plane is
143
+ unreachable, the project/stage is not allowlisted (`404`), or it returns `5xx`
144
+ without having admitted a run. It never falls back on `401`/`403`/`409`/`422`,
145
+ on a `5xx` that carries an admitted run, or once a run has been admitted and is
146
+ being polled. `--transport=ssh` bypasses the control plane entirely;
147
+ `--transport=http` requires `stage.http` and fails if it is missing. Dry runs
148
+ always print a direct-transport plan.
149
+
150
+ The separate `rampway <stage> plan [revision]` command is purely structural.
151
+ Even for a stage configured with `http`, it never contacts this control plane
152
+ and does not resolve a branch, prove that a supplied SHA exists or is reachable
153
+ from the approved branch, or request deployment admission. Those full-SHA and
154
+ reachability checks remain part of an actual HTTP deployment.
155
+
156
+ The client posts the stage's project/application name, an immutable full commit
157
+ SHA (resolving `stage.branch` with `git ls-remote` against `stage.repo` when no
158
+ revision is pinned), and an idempotency key derived from the project, stage,
159
+ revision, and release timestamp. It then polls the run until it reaches a
160
+ terminal status. A `succeeded` run is reported as a normal deploy; a
161
+ `failed`/`interrupted`/`reconciliation_required` run fails the deploy with the
162
+ control plane's sanitized error, and Rampway records whether previous live
163
+ state was restored. Polling is bounded by `http.pollIntervalMs` (default
164
+ `2000`) and `http.timeoutMs` (default 30 minutes); losing contact with the
165
+ control plane while a run is admitted fails the deploy rather than retrying
166
+ elsewhere.
167
+
131
168
  Authentication is accepted only from the `Authorization` header. Never place
132
169
  the token in a URL or command argument that is logged; clients should obtain it
133
170
  from their own secret store and configure the HTTP library's header directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rampway",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "JS-native Capistrano-style release-directory deployments with Rollbridge integration.",
5
5
  "type": "module",
6
6
  "private": false,