openship 0.2.3 → 0.4.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/README.md +104 -61
- package/dist/bare-ITM45QBX.js +18 -0
- package/dist/bowser-MFQA6OS4.js +2821 -0
- package/dist/chunk-3U4SPZD5.js +241 -0
- package/dist/chunk-46YJFP4S.js +1771 -0
- package/dist/chunk-4CQWT2WL.js +116 -0
- package/dist/chunk-4FABQGQP.js +23 -0
- package/dist/chunk-4LEL3H2Z.js +84 -0
- package/dist/chunk-4MG3BVHE.js +326 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-5N7VUGL6.js +2094 -0
- package/dist/chunk-5ZCKQ2MI.js +1360 -0
- package/dist/chunk-6ERIMTBC.js +1412 -0
- package/dist/chunk-7UOB5GGQ.js +132 -0
- package/dist/chunk-BUBSSKO6.js +1384 -0
- package/dist/chunk-E6W2JMMU.js +341 -0
- package/dist/chunk-E77LJWJK.js +1535 -0
- package/dist/chunk-F4DFVG6Q.js +573 -0
- package/dist/chunk-HFYDYK4D.js +1092 -0
- package/dist/chunk-JR2OCMJA.js +46 -0
- package/dist/chunk-JUJBBF5U.js +746 -0
- package/dist/chunk-JZLNHGLW.js +19145 -0
- package/dist/chunk-KD7VO4LG.js +2617 -0
- package/dist/chunk-LBFH7THC.js +435 -0
- package/dist/chunk-NB2VEIWG.js +129 -0
- package/dist/chunk-OJE6YWPQ.js +2866 -0
- package/dist/chunk-QKFRZNKN.js +4941 -0
- package/dist/chunk-RBZPFTHV.js +13354 -0
- package/dist/chunk-S6DSRLNE.js +121 -0
- package/dist/chunk-TEYJYSMT.js +409 -0
- package/dist/chunk-U2DPZSXA.js +112 -0
- package/dist/chunk-WJPCBU7Z.js +3147 -0
- package/dist/chunk-XIFRMKYT.js +13 -0
- package/dist/chunk-XVSAUO2I.js +7946 -0
- package/dist/chunk-Y37KHU7L.js +31 -0
- package/dist/chunk-YL67EWYZ.js +416 -0
- package/dist/chunk-ZKJOFWBJ.js +1879 -0
- package/dist/cloud-JMPHO3CW.js +7 -0
- package/dist/cloud-W5G6YXAY.js +24 -0
- package/dist/dist-L3MPOWCA.js +65 -0
- package/dist/dist-es-4C322O5D.js +22 -0
- package/dist/dist-es-GTAMF674.js +87 -0
- package/dist/dist-es-KE56MWYO.js +169 -0
- package/dist/dist-es-NRZIQY5B.js +484 -0
- package/dist/dist-es-WZCR7DHQ.js +317 -0
- package/dist/dist-es-XQCOTGC7.js +376 -0
- package/dist/dist-es-ZW3HFQUM.js +69 -0
- package/dist/docker-3FVJMFC3.js +12 -0
- package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
- package/dist/edge-import-53M5DGWZ.js +39 -0
- package/dist/event-streams-7LGDL56D.js +42 -0
- package/dist/executor-J7BHW5HF.js +26 -0
- package/dist/index.js +2459 -3461
- package/dist/loadSso-N273C5I7.js +577 -0
- package/dist/nginx-YZWKYKQU.js +16 -0
- package/dist/noop-RRNCIUI5.js +7 -0
- package/dist/openresty-lua-HAO4EUDY.js +32 -0
- package/dist/server/index.js +217916 -205055
- package/dist/server/migrations/0057_complete_prism.sql +18 -0
- package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
- package/dist/server/migrations/0059_legal_fixer.sql +18 -0
- package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
- package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
- package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
- package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
- package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
- package/dist/server/migrations/0065_custom_app_template.sql +14 -0
- package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
- package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
- package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
- package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
- package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
- package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
- package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
- package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
- package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
- package/dist/server/migrations/meta/_journal.json +99 -1
- package/dist/setup-5X3WEEHA.js +14 -0
- package/dist/signin-DR462ERO.js +663 -0
- package/dist/sso-oidc-VBZLJUK5.js +830 -0
- package/dist/sts-EJLZ4Z77.js +1422 -0
- package/package.json +6 -2
|
@@ -0,0 +1,3147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkGit,
|
|
3
|
+
installGit
|
|
4
|
+
} from "./chunk-46YJFP4S.js";
|
|
5
|
+
import {
|
|
6
|
+
createDockerBuildContext,
|
|
7
|
+
normalizeDockerRelativePath,
|
|
8
|
+
resolveDockerfileCandidates
|
|
9
|
+
} from "./chunk-HFYDYK4D.js";
|
|
10
|
+
import {
|
|
11
|
+
prepareStackOutput,
|
|
12
|
+
resolveProjectDir,
|
|
13
|
+
resolveStaticOutputPath,
|
|
14
|
+
runLocalBuild
|
|
15
|
+
} from "./chunk-NB2VEIWG.js";
|
|
16
|
+
import {
|
|
17
|
+
BuildLogger,
|
|
18
|
+
injectGitToken,
|
|
19
|
+
runBuildPipeline,
|
|
20
|
+
sq,
|
|
21
|
+
transferLocalDirectory
|
|
22
|
+
} from "./chunk-YL67EWYZ.js";
|
|
23
|
+
import {
|
|
24
|
+
isRuntimeNotFoundError
|
|
25
|
+
} from "./chunk-4LEL3H2Z.js";
|
|
26
|
+
import {
|
|
27
|
+
STACKS,
|
|
28
|
+
SYSTEM,
|
|
29
|
+
TRANSFER_EXCLUDES,
|
|
30
|
+
buildOutputTransferExcludes,
|
|
31
|
+
missingOutputDirectoryMessage,
|
|
32
|
+
packageManagerEnsureCommand,
|
|
33
|
+
safeErrorMessage
|
|
34
|
+
} from "./chunk-JZLNHGLW.js";
|
|
35
|
+
|
|
36
|
+
// ../../packages/adapters/src/runtime/cloud.ts
|
|
37
|
+
import { readFile } from "fs/promises";
|
|
38
|
+
import { join, posix } from "path";
|
|
39
|
+
|
|
40
|
+
// ../../packages/adapters/src/types.ts
|
|
41
|
+
var DEFAULT_RESOURCE_CONFIG = {
|
|
42
|
+
cpuCores: 0.5,
|
|
43
|
+
memoryMb: 512,
|
|
44
|
+
diskMb: 5120
|
|
45
|
+
};
|
|
46
|
+
function cloudCpus(cpuCores) {
|
|
47
|
+
return Math.max(0.25, cpuCores);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ../../packages/adapters/src/runtime/cloud.ts
|
|
51
|
+
import { PassThrough, Writable } from "stream";
|
|
52
|
+
|
|
53
|
+
// ../../packages/adapters/src/dockerfile/parser.ts
|
|
54
|
+
var KNOWN_KEYWORDS = /* @__PURE__ */ new Set([
|
|
55
|
+
"ADD",
|
|
56
|
+
"ARG",
|
|
57
|
+
"CMD",
|
|
58
|
+
"COPY",
|
|
59
|
+
"ENTRYPOINT",
|
|
60
|
+
"ENV",
|
|
61
|
+
"EXPOSE",
|
|
62
|
+
"FROM",
|
|
63
|
+
"HEALTHCHECK",
|
|
64
|
+
"LABEL",
|
|
65
|
+
"RUN",
|
|
66
|
+
"SHELL",
|
|
67
|
+
"STOPSIGNAL",
|
|
68
|
+
"USER",
|
|
69
|
+
"VOLUME",
|
|
70
|
+
"WORKDIR",
|
|
71
|
+
"ONBUILD",
|
|
72
|
+
"MAINTAINER"
|
|
73
|
+
]);
|
|
74
|
+
var FLAGGED_KEYWORDS = /* @__PURE__ */ new Set([
|
|
75
|
+
"ADD",
|
|
76
|
+
"COPY",
|
|
77
|
+
"FROM",
|
|
78
|
+
"RUN"
|
|
79
|
+
]);
|
|
80
|
+
function isCommentLine(line) {
|
|
81
|
+
return line.trimStart().startsWith("#");
|
|
82
|
+
}
|
|
83
|
+
function endsWithContinuation(line) {
|
|
84
|
+
let slashCount = 0;
|
|
85
|
+
for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i--) {
|
|
86
|
+
slashCount++;
|
|
87
|
+
}
|
|
88
|
+
return slashCount % 2 === 1;
|
|
89
|
+
}
|
|
90
|
+
function joinLogicalLines(source) {
|
|
91
|
+
const result = [];
|
|
92
|
+
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
|
93
|
+
let current = "";
|
|
94
|
+
let startLine = 0;
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const lineNo = i + 1;
|
|
97
|
+
const stripped = lines[i].trimEnd();
|
|
98
|
+
if (!current && (!stripped.trim() || isCommentLine(stripped))) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (current && isCommentLine(stripped)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!current) {
|
|
105
|
+
startLine = lineNo;
|
|
106
|
+
}
|
|
107
|
+
if (endsWithContinuation(stripped)) {
|
|
108
|
+
current += `${stripped.slice(0, -1).trimEnd()} `;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
current += stripped;
|
|
112
|
+
const raw = current.trim();
|
|
113
|
+
if (raw) {
|
|
114
|
+
result.push({ line: startLine, raw });
|
|
115
|
+
}
|
|
116
|
+
current = "";
|
|
117
|
+
}
|
|
118
|
+
if (current.trim()) {
|
|
119
|
+
result.push({ line: startLine, raw: current.trim() });
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
function parseLeadingFlags(value) {
|
|
124
|
+
const flags = {};
|
|
125
|
+
let rest = value.trimStart();
|
|
126
|
+
while (rest.startsWith("--")) {
|
|
127
|
+
const match = rest.match(/^--([A-Za-z0-9_.-]+)(?:=("[^"]*"|'[^']*'|\S+))?(?=\s|$)/);
|
|
128
|
+
if (!match) break;
|
|
129
|
+
const [, name, rawValue] = match;
|
|
130
|
+
flags[name] = rawValue === void 0 ? true : rawValue.replace(/^["']|["']$/g, "");
|
|
131
|
+
rest = rest.slice(match[0].length).trimStart();
|
|
132
|
+
}
|
|
133
|
+
return { flags, rest };
|
|
134
|
+
}
|
|
135
|
+
function toKeyword(value) {
|
|
136
|
+
const upper = value.toUpperCase();
|
|
137
|
+
return KNOWN_KEYWORDS.has(upper) ? upper : "OTHER";
|
|
138
|
+
}
|
|
139
|
+
function parseDockerfile(source) {
|
|
140
|
+
const diagnostics = [];
|
|
141
|
+
const instructions = [];
|
|
142
|
+
for (const logical of joinLogicalLines(source)) {
|
|
143
|
+
const match = logical.raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*(.*)$/);
|
|
144
|
+
if (!match) {
|
|
145
|
+
diagnostics.push({
|
|
146
|
+
severity: "warning",
|
|
147
|
+
line: logical.line,
|
|
148
|
+
message: `Could not parse Dockerfile instruction: ${logical.raw}`
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const originalKeyword = match[1];
|
|
153
|
+
const keyword = toKeyword(originalKeyword);
|
|
154
|
+
const rawValue = match[2] ?? "";
|
|
155
|
+
const parsed = FLAGGED_KEYWORDS.has(keyword) ? parseLeadingFlags(rawValue) : { flags: {}, rest: rawValue.trimStart() };
|
|
156
|
+
instructions.push({
|
|
157
|
+
keyword,
|
|
158
|
+
originalKeyword,
|
|
159
|
+
value: parsed.rest,
|
|
160
|
+
flags: parsed.flags,
|
|
161
|
+
line: logical.line,
|
|
162
|
+
raw: logical.raw
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { instructions, warnings: diagnostics };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ../../packages/adapters/src/dockerfile/compiler.ts
|
|
169
|
+
var DEFAULT_WORKDIR = "/";
|
|
170
|
+
var PATHLIKE_RE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
171
|
+
function diagnostic(instruction, severity, message) {
|
|
172
|
+
return {
|
|
173
|
+
severity,
|
|
174
|
+
instruction: instruction.keyword,
|
|
175
|
+
line: instruction.line,
|
|
176
|
+
message
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function splitFirstEquals(value) {
|
|
180
|
+
const index = value.indexOf("=");
|
|
181
|
+
if (index === -1) {
|
|
182
|
+
return [value, null];
|
|
183
|
+
}
|
|
184
|
+
return [value.slice(0, index), value.slice(index + 1)];
|
|
185
|
+
}
|
|
186
|
+
function parseJsonArray(value) {
|
|
187
|
+
const trimmed = value.trim();
|
|
188
|
+
if (!trimmed.startsWith("[")) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(trimmed);
|
|
193
|
+
if (!Array.isArray(parsed)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return parsed.map((part) => String(part));
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function splitWords(value) {
|
|
202
|
+
const words = [];
|
|
203
|
+
let current = "";
|
|
204
|
+
let inSingle = false;
|
|
205
|
+
let inDouble = false;
|
|
206
|
+
let escaped = false;
|
|
207
|
+
for (const char of value.trim()) {
|
|
208
|
+
if (escaped) {
|
|
209
|
+
current += char;
|
|
210
|
+
escaped = false;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (char === "\\") {
|
|
214
|
+
escaped = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (char === "'" && !inDouble) {
|
|
218
|
+
inSingle = !inSingle;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === '"' && !inSingle) {
|
|
222
|
+
inDouble = !inDouble;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (/\s/.test(char) && !inSingle && !inDouble) {
|
|
226
|
+
if (current) {
|
|
227
|
+
words.push(current);
|
|
228
|
+
current = "";
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
current += char;
|
|
233
|
+
}
|
|
234
|
+
if (escaped) {
|
|
235
|
+
current += "\\";
|
|
236
|
+
}
|
|
237
|
+
if (current) {
|
|
238
|
+
words.push(current);
|
|
239
|
+
}
|
|
240
|
+
return words;
|
|
241
|
+
}
|
|
242
|
+
function shellQuote(value) {
|
|
243
|
+
if (PATHLIKE_RE.test(value)) {
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
247
|
+
}
|
|
248
|
+
function commandToShell(command) {
|
|
249
|
+
if (command.form === "exec") {
|
|
250
|
+
return command.value.map(shellQuote).join(" ");
|
|
251
|
+
}
|
|
252
|
+
return command.value;
|
|
253
|
+
}
|
|
254
|
+
function toWorkspaceCommand(instruction) {
|
|
255
|
+
const exec = parseJsonArray(instruction.value);
|
|
256
|
+
if (exec) {
|
|
257
|
+
return {
|
|
258
|
+
form: "exec",
|
|
259
|
+
value: exec,
|
|
260
|
+
raw: instruction.value
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
form: "shell",
|
|
265
|
+
value: instruction.value.trim(),
|
|
266
|
+
raw: instruction.value
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function combineStartCommand(entrypoint, cmd) {
|
|
270
|
+
if (!entrypoint && !cmd) {
|
|
271
|
+
return void 0;
|
|
272
|
+
}
|
|
273
|
+
if (!entrypoint) {
|
|
274
|
+
return commandToShell(cmd);
|
|
275
|
+
}
|
|
276
|
+
if (!cmd) {
|
|
277
|
+
return commandToShell(entrypoint);
|
|
278
|
+
}
|
|
279
|
+
if (entrypoint.form === "shell") {
|
|
280
|
+
return commandToShell(entrypoint);
|
|
281
|
+
}
|
|
282
|
+
const entrypointShell = commandToShell(entrypoint);
|
|
283
|
+
const cmdShell = commandToShell(cmd);
|
|
284
|
+
return cmdShell ? `${entrypointShell} ${cmdShell}` : entrypointShell;
|
|
285
|
+
}
|
|
286
|
+
function normalizeWorkdir(current, next) {
|
|
287
|
+
const value = next.trim();
|
|
288
|
+
if (!value) {
|
|
289
|
+
return current;
|
|
290
|
+
}
|
|
291
|
+
const raw = value.startsWith("/") ? value : `${current.replace(/\/+$/g, "")}/${value}`;
|
|
292
|
+
const parts = [];
|
|
293
|
+
for (const part of raw.split("/")) {
|
|
294
|
+
if (!part || part === ".") {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (part === "..") {
|
|
298
|
+
parts.pop();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
parts.push(part);
|
|
302
|
+
}
|
|
303
|
+
return `/${parts.join("/")}`;
|
|
304
|
+
}
|
|
305
|
+
function parseKeyValueList(value) {
|
|
306
|
+
const tokens = splitWords(value);
|
|
307
|
+
const result = {};
|
|
308
|
+
if (tokens.length === 0) {
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
if (!tokens[0].includes("=") && tokens.length >= 2) {
|
|
312
|
+
result[tokens[0]] = tokens.slice(1).join(" ");
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
for (const token of tokens) {
|
|
316
|
+
const [key, tokenValue] = splitFirstEquals(token);
|
|
317
|
+
if (!key || tokenValue === null) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
result[key] = tokenValue;
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
function parseFromInstruction(instruction) {
|
|
325
|
+
const parts = splitWords(instruction.value);
|
|
326
|
+
const platformFlag = instruction.flags.platform;
|
|
327
|
+
const platform = typeof platformFlag === "string" ? platformFlag : void 0;
|
|
328
|
+
if (parts.length === 0) {
|
|
329
|
+
return {
|
|
330
|
+
baseImage: "",
|
|
331
|
+
platform,
|
|
332
|
+
diagnostic: diagnostic(instruction, "error", "FROM is missing a base image.")
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const baseImage = parts[0];
|
|
336
|
+
let name;
|
|
337
|
+
let warning;
|
|
338
|
+
if (parts.length >= 3 && parts[1]?.toUpperCase() === "AS") {
|
|
339
|
+
name = parts[2];
|
|
340
|
+
if (parts.length > 3) {
|
|
341
|
+
warning = diagnostic(instruction, "warning", "Ignoring extra tokens after FROM stage alias.");
|
|
342
|
+
}
|
|
343
|
+
} else if (parts.length > 1) {
|
|
344
|
+
warning = diagnostic(
|
|
345
|
+
instruction,
|
|
346
|
+
"warning",
|
|
347
|
+
"FROM contains extra tokens that are not part of a stage alias."
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return { baseImage, name, platform, diagnostic: warning };
|
|
351
|
+
}
|
|
352
|
+
function createStage(index, from, defaultWorkdir) {
|
|
353
|
+
return {
|
|
354
|
+
index,
|
|
355
|
+
name: from.name,
|
|
356
|
+
baseImage: from.baseImage,
|
|
357
|
+
platform: from.platform,
|
|
358
|
+
workdir: defaultWorkdir,
|
|
359
|
+
env: {},
|
|
360
|
+
args: {},
|
|
361
|
+
labels: {},
|
|
362
|
+
copies: [],
|
|
363
|
+
runs: [],
|
|
364
|
+
steps: [],
|
|
365
|
+
exposedPorts: [],
|
|
366
|
+
unsupported: []
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function parseCopyLikeInstruction(instruction, kind, workdir) {
|
|
370
|
+
const diagnostics = [];
|
|
371
|
+
const parts = parseJsonArray(instruction.value) ?? splitWords(instruction.value);
|
|
372
|
+
if (parts.length < 2) {
|
|
373
|
+
return {
|
|
374
|
+
diagnostics: [
|
|
375
|
+
diagnostic(
|
|
376
|
+
instruction,
|
|
377
|
+
"error",
|
|
378
|
+
`${instruction.keyword} needs at least one source and one destination.`
|
|
379
|
+
)
|
|
380
|
+
]
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const from = typeof instruction.flags.from === "string" ? instruction.flags.from : void 0;
|
|
384
|
+
const sources = parts.slice(0, -1);
|
|
385
|
+
const destination = parts[parts.length - 1] ?? "";
|
|
386
|
+
if (kind === "add") {
|
|
387
|
+
diagnostics.push(
|
|
388
|
+
diagnostic(
|
|
389
|
+
instruction,
|
|
390
|
+
"warning",
|
|
391
|
+
"ADD is recorded, but remote URLs and archive extraction need explicit workspace execution support."
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
for (const flag of ["chown", "chmod", "link"]) {
|
|
396
|
+
if (instruction.flags[flag] !== void 0) {
|
|
397
|
+
diagnostics.push(
|
|
398
|
+
diagnostic(
|
|
399
|
+
instruction,
|
|
400
|
+
"warning",
|
|
401
|
+
`${instruction.keyword} --${flag} is Docker-specific metadata and needs explicit workspace handling.`
|
|
402
|
+
)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
step: {
|
|
408
|
+
kind,
|
|
409
|
+
sources,
|
|
410
|
+
destination,
|
|
411
|
+
from,
|
|
412
|
+
workdir,
|
|
413
|
+
flags: instruction.flags,
|
|
414
|
+
line: instruction.line,
|
|
415
|
+
raw: instruction.value
|
|
416
|
+
},
|
|
417
|
+
diagnostics
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function parseExposeInstruction(instruction) {
|
|
421
|
+
const ports = [];
|
|
422
|
+
const diagnostics = [];
|
|
423
|
+
for (const token of splitWords(instruction.value)) {
|
|
424
|
+
const match = token.match(/^(\d+)(?:\/(tcp|udp))?$/i);
|
|
425
|
+
if (!match) {
|
|
426
|
+
diagnostics.push(
|
|
427
|
+
diagnostic(instruction, "warning", `Could not parse EXPOSE value "${token}".`)
|
|
428
|
+
);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
ports.push({
|
|
432
|
+
port: Number(match[1]),
|
|
433
|
+
protocol: match[2]?.toLowerCase() ?? "tcp"
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return { ports, diagnostics };
|
|
437
|
+
}
|
|
438
|
+
function parseArgInstruction(instruction, buildArgs) {
|
|
439
|
+
const [rawKey, rawDefault] = splitFirstEquals(instruction.value.trim());
|
|
440
|
+
const key = rawKey.trim();
|
|
441
|
+
if (!key) {
|
|
442
|
+
return {
|
|
443
|
+
diagnostic: diagnostic(instruction, "warning", "ARG is missing a name.")
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
key,
|
|
448
|
+
value: buildArgs[key] ?? rawDefault
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function currentOrDiagnostic(currentStage, instruction) {
|
|
452
|
+
if (currentStage) {
|
|
453
|
+
return { stage: currentStage };
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
diagnostic: diagnostic(
|
|
457
|
+
instruction,
|
|
458
|
+
instruction.keyword === "ARG" ? "warning" : "error",
|
|
459
|
+
`${instruction.keyword} appears before the first FROM stage.`
|
|
460
|
+
)
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function selectFinalStage(stages, targetStage) {
|
|
464
|
+
if (stages.length === 0) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
if (!targetStage) {
|
|
468
|
+
return stages[stages.length - 1] ?? null;
|
|
469
|
+
}
|
|
470
|
+
return stages.find((stage) => stage.name === targetStage || String(stage.index) === targetStage) ?? null;
|
|
471
|
+
}
|
|
472
|
+
function isPreviousStageReference(stages, currentStage, ref) {
|
|
473
|
+
const numeric = Number(ref);
|
|
474
|
+
if (Number.isInteger(numeric) && String(numeric) === ref) {
|
|
475
|
+
return numeric >= 0 && numeric < currentStage.index;
|
|
476
|
+
}
|
|
477
|
+
return stages.some((stage) => stage.index < currentStage.index && stage.name === ref);
|
|
478
|
+
}
|
|
479
|
+
function findPreviousStageReference(stages, ref) {
|
|
480
|
+
const numeric = Number(ref);
|
|
481
|
+
if (Number.isInteger(numeric) && String(numeric) === ref) {
|
|
482
|
+
return stages[numeric] ?? null;
|
|
483
|
+
}
|
|
484
|
+
return stages.find((stage) => stage.name === ref) ?? null;
|
|
485
|
+
}
|
|
486
|
+
function inheritStageImageConfig(target, base) {
|
|
487
|
+
target.workdir = base.workdir;
|
|
488
|
+
target.env = { ...base.env };
|
|
489
|
+
target.labels = { ...base.labels };
|
|
490
|
+
target.exposedPorts = [...base.exposedPorts];
|
|
491
|
+
target.cmd = base.cmd;
|
|
492
|
+
target.entrypoint = base.entrypoint;
|
|
493
|
+
target.startCommand = base.startCommand;
|
|
494
|
+
target.user = base.user;
|
|
495
|
+
target.shell = base.shell ? [...base.shell] : void 0;
|
|
496
|
+
}
|
|
497
|
+
function validateStageArtifactCopies(stages) {
|
|
498
|
+
const diagnostics = [];
|
|
499
|
+
for (const stage of stages) {
|
|
500
|
+
for (const copy of stage.copies) {
|
|
501
|
+
if (!copy.from) continue;
|
|
502
|
+
if (isPreviousStageReference(stages, stage, copy.from)) continue;
|
|
503
|
+
diagnostics.push({
|
|
504
|
+
severity: "unsupported",
|
|
505
|
+
instruction: copy.kind === "copy" ? "COPY" : "ADD",
|
|
506
|
+
line: copy.line,
|
|
507
|
+
message: `${copy.kind.toUpperCase()} --from=${copy.from} references an external image, current stage, or later stage. Cloud Dockerfile builds currently support --from only for previous stages in the same Dockerfile.`
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return diagnostics;
|
|
512
|
+
}
|
|
513
|
+
function compileDockerfileParseResult(parseResult, options = {}) {
|
|
514
|
+
const defaultWorkdir = normalizeWorkdir("/", options.defaultWorkdir ?? DEFAULT_WORKDIR);
|
|
515
|
+
const buildArgs = options.buildArgs ?? {};
|
|
516
|
+
const diagnostics = [...parseResult.warnings];
|
|
517
|
+
const globalArgs = {};
|
|
518
|
+
const stages = [];
|
|
519
|
+
let currentStage = null;
|
|
520
|
+
for (const instruction of parseResult.instructions) {
|
|
521
|
+
if (instruction.keyword === "ARG" && !currentStage) {
|
|
522
|
+
const parsedArg = parseArgInstruction(instruction, buildArgs);
|
|
523
|
+
if (parsedArg.diagnostic) {
|
|
524
|
+
diagnostics.push(parsedArg.diagnostic);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (parsedArg.key) {
|
|
528
|
+
globalArgs[parsedArg.key] = parsedArg.value ?? null;
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (instruction.keyword === "FROM") {
|
|
533
|
+
const parsedFrom = parseFromInstruction(instruction);
|
|
534
|
+
if (parsedFrom.diagnostic) {
|
|
535
|
+
diagnostics.push(parsedFrom.diagnostic);
|
|
536
|
+
}
|
|
537
|
+
currentStage = createStage(stages.length, parsedFrom, defaultWorkdir);
|
|
538
|
+
const baseStage = findPreviousStageReference(stages, parsedFrom.baseImage);
|
|
539
|
+
if (baseStage) {
|
|
540
|
+
inheritStageImageConfig(currentStage, baseStage);
|
|
541
|
+
}
|
|
542
|
+
stages.push(currentStage);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const stageResult = currentOrDiagnostic(currentStage, instruction);
|
|
546
|
+
if (!stageResult.stage) {
|
|
547
|
+
if (stageResult.diagnostic) {
|
|
548
|
+
diagnostics.push(stageResult.diagnostic);
|
|
549
|
+
}
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const stage = stageResult.stage;
|
|
553
|
+
switch (instruction.keyword) {
|
|
554
|
+
case "ARG": {
|
|
555
|
+
const parsedArg = parseArgInstruction(instruction, buildArgs);
|
|
556
|
+
if (parsedArg.diagnostic) {
|
|
557
|
+
diagnostics.push(parsedArg.diagnostic);
|
|
558
|
+
} else if (parsedArg.key) {
|
|
559
|
+
stage.args[parsedArg.key] = parsedArg.value ?? null;
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case "ENV":
|
|
564
|
+
Object.assign(stage.env, parseKeyValueList(instruction.value));
|
|
565
|
+
break;
|
|
566
|
+
case "LABEL":
|
|
567
|
+
Object.assign(stage.labels, parseKeyValueList(instruction.value));
|
|
568
|
+
break;
|
|
569
|
+
case "WORKDIR":
|
|
570
|
+
stage.workdir = normalizeWorkdir(stage.workdir, instruction.value);
|
|
571
|
+
break;
|
|
572
|
+
case "COPY":
|
|
573
|
+
case "ADD": {
|
|
574
|
+
const parsedCopy = parseCopyLikeInstruction(
|
|
575
|
+
instruction,
|
|
576
|
+
instruction.keyword === "COPY" ? "copy" : "add",
|
|
577
|
+
stage.workdir
|
|
578
|
+
);
|
|
579
|
+
if (parsedCopy.step) {
|
|
580
|
+
stage.copies.push(parsedCopy.step);
|
|
581
|
+
stage.steps.push({ type: "copy", copy: parsedCopy.step });
|
|
582
|
+
}
|
|
583
|
+
diagnostics.push(...parsedCopy.diagnostics);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case "RUN": {
|
|
587
|
+
const command = toWorkspaceCommand(instruction);
|
|
588
|
+
const runStep = {
|
|
589
|
+
command: commandToShell(command),
|
|
590
|
+
form: command.form,
|
|
591
|
+
workdir: stage.workdir,
|
|
592
|
+
env: { ...stage.env },
|
|
593
|
+
args: { ...stage.args },
|
|
594
|
+
flags: instruction.flags,
|
|
595
|
+
line: instruction.line,
|
|
596
|
+
raw: instruction.value
|
|
597
|
+
};
|
|
598
|
+
if (command.form === "exec") {
|
|
599
|
+
runStep.exec = command.value;
|
|
600
|
+
}
|
|
601
|
+
stage.runs.push(runStep);
|
|
602
|
+
stage.steps.push({ type: "run", run: runStep });
|
|
603
|
+
for (const flag of Object.keys(instruction.flags)) {
|
|
604
|
+
diagnostics.push(
|
|
605
|
+
diagnostic(
|
|
606
|
+
instruction,
|
|
607
|
+
"warning",
|
|
608
|
+
`RUN --${flag} is a Docker BuildKit feature and needs explicit workspace support.`
|
|
609
|
+
)
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case "EXPOSE": {
|
|
615
|
+
const exposed = parseExposeInstruction(instruction);
|
|
616
|
+
stage.exposedPorts.push(...exposed.ports);
|
|
617
|
+
diagnostics.push(...exposed.diagnostics);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case "CMD":
|
|
621
|
+
stage.cmd = toWorkspaceCommand(instruction);
|
|
622
|
+
stage.startCommand = combineStartCommand(stage.entrypoint, stage.cmd);
|
|
623
|
+
break;
|
|
624
|
+
case "ENTRYPOINT":
|
|
625
|
+
stage.entrypoint = toWorkspaceCommand(instruction);
|
|
626
|
+
stage.startCommand = combineStartCommand(stage.entrypoint, stage.cmd);
|
|
627
|
+
break;
|
|
628
|
+
case "USER":
|
|
629
|
+
stage.user = instruction.value.trim();
|
|
630
|
+
break;
|
|
631
|
+
case "SHELL": {
|
|
632
|
+
const shell = parseJsonArray(instruction.value);
|
|
633
|
+
if (shell) {
|
|
634
|
+
stage.shell = shell;
|
|
635
|
+
diagnostics.push(
|
|
636
|
+
diagnostic(
|
|
637
|
+
instruction,
|
|
638
|
+
"warning",
|
|
639
|
+
"SHELL changes how later shell-form RUN/CMD instructions execute; the plan records it but does not emulate shell behavior."
|
|
640
|
+
)
|
|
641
|
+
);
|
|
642
|
+
} else {
|
|
643
|
+
const item = diagnostic(
|
|
644
|
+
instruction,
|
|
645
|
+
"unsupported",
|
|
646
|
+
"SHELL must use JSON array form to be planned."
|
|
647
|
+
);
|
|
648
|
+
diagnostics.push(item);
|
|
649
|
+
stage.unsupported.push(item);
|
|
650
|
+
}
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case "HEALTHCHECK":
|
|
654
|
+
case "ONBUILD":
|
|
655
|
+
case "STOPSIGNAL":
|
|
656
|
+
case "VOLUME": {
|
|
657
|
+
const item = diagnostic(
|
|
658
|
+
instruction,
|
|
659
|
+
"unsupported",
|
|
660
|
+
`${instruction.keyword} is runtime/image metadata and cannot be represented as workspace build commands yet.`
|
|
661
|
+
);
|
|
662
|
+
diagnostics.push(item);
|
|
663
|
+
stage.unsupported.push(item);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "MAINTAINER":
|
|
667
|
+
diagnostics.push(
|
|
668
|
+
diagnostic(
|
|
669
|
+
instruction,
|
|
670
|
+
"warning",
|
|
671
|
+
"MAINTAINER is deprecated and ignored by the workspace plan."
|
|
672
|
+
)
|
|
673
|
+
);
|
|
674
|
+
break;
|
|
675
|
+
case "OTHER": {
|
|
676
|
+
const item = diagnostic(
|
|
677
|
+
instruction,
|
|
678
|
+
"unsupported",
|
|
679
|
+
`Unsupported Dockerfile instruction "${instruction.originalKeyword}".`
|
|
680
|
+
);
|
|
681
|
+
diagnostics.push(item);
|
|
682
|
+
stage.unsupported.push(item);
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (stages.length === 0) {
|
|
688
|
+
diagnostics.push({
|
|
689
|
+
severity: "error",
|
|
690
|
+
message: "Dockerfile does not contain a FROM instruction."
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
const finalStage = selectFinalStage(stages, options.targetStage);
|
|
694
|
+
if (options.targetStage && !finalStage) {
|
|
695
|
+
diagnostics.push({
|
|
696
|
+
severity: "error",
|
|
697
|
+
message: `Target Dockerfile stage "${options.targetStage}" was not found.`
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
diagnostics.push(...validateStageArtifactCopies(stages));
|
|
701
|
+
const runtime = finalStage ? {
|
|
702
|
+
baseImage: finalStage.baseImage,
|
|
703
|
+
workdir: finalStage.workdir,
|
|
704
|
+
env: { ...finalStage.env },
|
|
705
|
+
exposedPort: finalStage.exposedPorts[0]?.port,
|
|
706
|
+
exposedPorts: [...finalStage.exposedPorts],
|
|
707
|
+
startCommand: finalStage.startCommand,
|
|
708
|
+
user: finalStage.user
|
|
709
|
+
} : null;
|
|
710
|
+
const hasStageArtifactCopies = stages.some((stage) => stage.copies.some((copy) => copy.from));
|
|
711
|
+
const requiresDockerSemantics = diagnostics.some((item) => item.severity === "unsupported") || stages.length > 1 || hasStageArtifactCopies;
|
|
712
|
+
return {
|
|
713
|
+
source: "dockerfile",
|
|
714
|
+
globalArgs,
|
|
715
|
+
stages,
|
|
716
|
+
finalStage,
|
|
717
|
+
runtime,
|
|
718
|
+
diagnostics,
|
|
719
|
+
isMultiStage: stages.length > 1,
|
|
720
|
+
requiresDockerSemantics
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function compileDockerfileToWorkspacePlan(source, options) {
|
|
724
|
+
return compileDockerfileParseResult(parseDockerfile(source), options);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ../../packages/adapters/src/runtime/cloud/compose.ts
|
|
728
|
+
function now() {
|
|
729
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
730
|
+
}
|
|
731
|
+
function toEnvArray(env) {
|
|
732
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
733
|
+
}
|
|
734
|
+
function firstContainerPort(portSpecs) {
|
|
735
|
+
for (const spec of portSpecs) {
|
|
736
|
+
const clean = spec.trim();
|
|
737
|
+
if (!clean) continue;
|
|
738
|
+
const parts = clean.split(":");
|
|
739
|
+
const raw = parts.length >= 2 ? parts[parts.length - 1] : parts[0];
|
|
740
|
+
const match = raw?.match(/^(\d+)(?:\/(?:tcp|udp))?$/i);
|
|
741
|
+
if (match) return Number(match[1]);
|
|
742
|
+
}
|
|
743
|
+
return void 0;
|
|
744
|
+
}
|
|
745
|
+
function restartPolicyForWorkload(policy) {
|
|
746
|
+
const base = policy?.split(":")[0];
|
|
747
|
+
if (base === "no" || base === "never") return "never";
|
|
748
|
+
if (base === "on-failure") return "on-failure";
|
|
749
|
+
return "always";
|
|
750
|
+
}
|
|
751
|
+
function exposeTarget(port, serviceName, slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
|
|
752
|
+
const service = `service "${serviceName}" on port ${port}`;
|
|
753
|
+
return slug ? `${service} for slug "${slug}" (${slug}.${domain})` : service;
|
|
754
|
+
}
|
|
755
|
+
function errorMessage(err) {
|
|
756
|
+
return safeErrorMessage(err);
|
|
757
|
+
}
|
|
758
|
+
async function withCloudOperationTimeout(operation, label, timeoutMs = 3e5) {
|
|
759
|
+
let timer;
|
|
760
|
+
try {
|
|
761
|
+
return await Promise.race([
|
|
762
|
+
operation,
|
|
763
|
+
new Promise((_, reject) => {
|
|
764
|
+
timer = setTimeout(() => {
|
|
765
|
+
reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1e3)}s`));
|
|
766
|
+
}, timeoutMs);
|
|
767
|
+
})
|
|
768
|
+
]);
|
|
769
|
+
} finally {
|
|
770
|
+
if (timer) clearTimeout(timer);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
var CloudComposeSupport = class {
|
|
774
|
+
constructor(deps) {
|
|
775
|
+
this.deps = deps;
|
|
776
|
+
}
|
|
777
|
+
groups = /* @__PURE__ */ new Map();
|
|
778
|
+
async ensureServiceGroup(config) {
|
|
779
|
+
const id = `cloud-compose:${config.deploymentId}`;
|
|
780
|
+
if (!this.groups.has(id)) {
|
|
781
|
+
this.groups.set(id, {
|
|
782
|
+
id,
|
|
783
|
+
resources: config.resources,
|
|
784
|
+
services: /* @__PURE__ */ new Map()
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return { id };
|
|
788
|
+
}
|
|
789
|
+
/** Seed an already-running peer into the group WITHOUT deploying it, so the
|
|
790
|
+
* next finalizeGroup rewrites the full mesh (/etc/hosts + private links)
|
|
791
|
+
* including it. Used by the decoupled single-service add — only the new
|
|
792
|
+
* service is deployed, but its peers must remain reachable by name. */
|
|
793
|
+
registerExistingWorkload(group, service) {
|
|
794
|
+
const groupState = this.groups.get(group.id);
|
|
795
|
+
if (!groupState) return;
|
|
796
|
+
if (groupState.services.has(service.serviceName)) return;
|
|
797
|
+
const ports = [
|
|
798
|
+
...new Set(
|
|
799
|
+
(service.portSpecs ?? []).map((item) => firstContainerPort([item])).filter((item) => typeof item === "number")
|
|
800
|
+
)
|
|
801
|
+
];
|
|
802
|
+
groupState.services.set(service.serviceName, {
|
|
803
|
+
serviceName: service.serviceName,
|
|
804
|
+
workspaceId: service.workspaceId,
|
|
805
|
+
ip: service.ip,
|
|
806
|
+
ports
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async deployServiceWorkload(group, config, onLog) {
|
|
810
|
+
const log = onLog ?? (() => {
|
|
811
|
+
});
|
|
812
|
+
const groupState = this.groups.get(group.id) ?? {
|
|
813
|
+
id: group.id,
|
|
814
|
+
services: /* @__PURE__ */ new Map()
|
|
815
|
+
};
|
|
816
|
+
this.groups.set(group.id, groupState);
|
|
817
|
+
const builtArtifact = this.deps.builtArtifacts.get(config.image);
|
|
818
|
+
let workspaceId;
|
|
819
|
+
try {
|
|
820
|
+
workspaceId = builtArtifact?.workspaceId ?? await this.createImageServiceWorkspace(config, log);
|
|
821
|
+
const ws = this.deps.workspace(workspaceId);
|
|
822
|
+
if (builtArtifact?.workspaceId) {
|
|
823
|
+
await this.shrinkToRuntimeTier(ws, config, log);
|
|
824
|
+
}
|
|
825
|
+
await withCloudOperationTimeout(
|
|
826
|
+
ws.lifecycle.makePermanent(),
|
|
827
|
+
`Making cloud service "${config.serviceName}" permanent`
|
|
828
|
+
).catch((err) => {
|
|
829
|
+
throw new Error(`Failed to make service workspace permanent: ${errorMessage(err)}`);
|
|
830
|
+
});
|
|
831
|
+
const runtimeEnv = {
|
|
832
|
+
...builtArtifact?.runtime.env ?? {},
|
|
833
|
+
...config.environment
|
|
834
|
+
};
|
|
835
|
+
const port = config.publicPort ?? firstContainerPort(config.ports) ?? builtArtifact?.runtime.exposedPort;
|
|
836
|
+
const workdir = builtArtifact?.runtime.workdir ?? "/";
|
|
837
|
+
const startCommand = config.command ?? builtArtifact?.runtime.startCommand;
|
|
838
|
+
log({
|
|
839
|
+
timestamp: now(),
|
|
840
|
+
message: `Deploying cloud service "${config.serviceName}" in workspace ${workspaceId}...
|
|
841
|
+
`,
|
|
842
|
+
level: "info"
|
|
843
|
+
});
|
|
844
|
+
if (startCommand) {
|
|
845
|
+
log({
|
|
846
|
+
timestamp: now(),
|
|
847
|
+
message: `Creating workload for service "${config.serviceName}"...
|
|
848
|
+
`,
|
|
849
|
+
level: "info"
|
|
850
|
+
});
|
|
851
|
+
await withCloudOperationTimeout(
|
|
852
|
+
ws.workloads.delete("app").catch(() => {
|
|
853
|
+
}),
|
|
854
|
+
`Replacing workload for service "${config.serviceName}"`
|
|
855
|
+
);
|
|
856
|
+
await withCloudOperationTimeout(
|
|
857
|
+
ws.workloads.create({
|
|
858
|
+
id: "app",
|
|
859
|
+
name: "app",
|
|
860
|
+
cmd: ["sh", "-c", `cd ${sq(workdir)} && ${startCommand}`],
|
|
861
|
+
working_dir: workdir,
|
|
862
|
+
env: [...toEnvArray(runtimeEnv), ...port ? [`PORT=${port}`] : []],
|
|
863
|
+
restart_policy: restartPolicyForWorkload(config.restart),
|
|
864
|
+
max_restarts: 10
|
|
865
|
+
}),
|
|
866
|
+
`Creating workload for service "${config.serviceName}"`
|
|
867
|
+
);
|
|
868
|
+
log({
|
|
869
|
+
timestamp: now(),
|
|
870
|
+
message: `Workload for service "${config.serviceName}" is ready.
|
|
871
|
+
`,
|
|
872
|
+
level: "info"
|
|
873
|
+
});
|
|
874
|
+
} else {
|
|
875
|
+
log({
|
|
876
|
+
timestamp: now(),
|
|
877
|
+
message: `No command configured for "${config.serviceName}". Using the workspace image default process.
|
|
878
|
+
`,
|
|
879
|
+
level: "warn"
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
if (config.expose && port) {
|
|
883
|
+
if (config.customDomain) {
|
|
884
|
+
log({
|
|
885
|
+
timestamp: now(),
|
|
886
|
+
message: `Opening ${exposeTarget(port, config.serviceName, config.publicSlug)}...
|
|
887
|
+
`,
|
|
888
|
+
level: "info"
|
|
889
|
+
});
|
|
890
|
+
await withCloudOperationTimeout(
|
|
891
|
+
ws.network.update({ ingress_ports: [port] }),
|
|
892
|
+
`Opening network port for service "${config.serviceName}"`
|
|
893
|
+
).catch((err) => {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`Failed to open ${exposeTarget(port, config.serviceName, config.publicSlug)}: ${errorMessage(err)}`
|
|
896
|
+
);
|
|
897
|
+
});
|
|
898
|
+
log({
|
|
899
|
+
timestamp: now(),
|
|
900
|
+
message: `Opened ${exposeTarget(port, config.serviceName, config.publicSlug)}.
|
|
901
|
+
`,
|
|
902
|
+
level: "info"
|
|
903
|
+
});
|
|
904
|
+
log({
|
|
905
|
+
timestamp: now(),
|
|
906
|
+
message: `Connecting custom domain "${config.customDomain}" for service "${config.serviceName}"...
|
|
907
|
+
`,
|
|
908
|
+
level: "info"
|
|
909
|
+
});
|
|
910
|
+
await withCloudOperationTimeout(
|
|
911
|
+
ws.domains.connect({ domain: config.customDomain, port }),
|
|
912
|
+
`Connecting custom domain for service "${config.serviceName}"`
|
|
913
|
+
).catch((err) => {
|
|
914
|
+
throw new Error(
|
|
915
|
+
`Failed to connect custom domain "${config.customDomain}" for service "${config.serviceName}" on port ${port}: ${errorMessage(err)}`
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
log({
|
|
919
|
+
timestamp: now(),
|
|
920
|
+
message: `Custom domain "${config.customDomain}" connected for service "${config.serviceName}".
|
|
921
|
+
`,
|
|
922
|
+
level: "info"
|
|
923
|
+
});
|
|
924
|
+
} else if (config.publicSlug) {
|
|
925
|
+
log({
|
|
926
|
+
timestamp: now(),
|
|
927
|
+
message: `Exposing ${exposeTarget(port, config.serviceName, config.publicSlug)}...
|
|
928
|
+
`,
|
|
929
|
+
level: "info"
|
|
930
|
+
});
|
|
931
|
+
await withCloudOperationTimeout(
|
|
932
|
+
ws.publicAccess.expose({
|
|
933
|
+
port,
|
|
934
|
+
domain: SYSTEM.DOMAINS.CLOUD_DOMAIN,
|
|
935
|
+
slug: config.publicSlug
|
|
936
|
+
}),
|
|
937
|
+
`Exposing public access for service "${config.serviceName}"`
|
|
938
|
+
).catch((err) => {
|
|
939
|
+
throw new Error(
|
|
940
|
+
`Failed to expose ${exposeTarget(port, config.serviceName, config.publicSlug)}: ${errorMessage(err)}`
|
|
941
|
+
);
|
|
942
|
+
});
|
|
943
|
+
log({
|
|
944
|
+
timestamp: now(),
|
|
945
|
+
message: `Exposed ${exposeTarget(port, config.serviceName, config.publicSlug)}.
|
|
946
|
+
`,
|
|
947
|
+
level: "info"
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const ip = await this.resolveWorkspaceIpWithRetry(ws);
|
|
952
|
+
const ports = [
|
|
953
|
+
.../* @__PURE__ */ new Set([
|
|
954
|
+
...config.ports.map((item) => firstContainerPort([item])).filter((item) => typeof item === "number"),
|
|
955
|
+
...port ? [port] : []
|
|
956
|
+
])
|
|
957
|
+
];
|
|
958
|
+
groupState.services.set(config.serviceName, {
|
|
959
|
+
serviceName: config.serviceName,
|
|
960
|
+
workspaceId,
|
|
961
|
+
ip: ip ?? void 0,
|
|
962
|
+
ports
|
|
963
|
+
});
|
|
964
|
+
await this.syncServiceDiscovery(groupState, log);
|
|
965
|
+
log({
|
|
966
|
+
timestamp: now(),
|
|
967
|
+
message: `Cloud service "${config.serviceName}" started${ip ? ` at ${ip}` : ""}.
|
|
968
|
+
`,
|
|
969
|
+
level: "info"
|
|
970
|
+
});
|
|
971
|
+
return {
|
|
972
|
+
containerId: workspaceId,
|
|
973
|
+
status: "running",
|
|
974
|
+
ip: ip ?? void 0,
|
|
975
|
+
hostPort: port
|
|
976
|
+
};
|
|
977
|
+
} catch (err) {
|
|
978
|
+
if (workspaceId) {
|
|
979
|
+
groupState.services.delete(config.serviceName);
|
|
980
|
+
await this.deps.workspace(workspaceId).delete().catch(() => {
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
throw err;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Shrink a source-built server's workspace from the BUILD tier (4cpu/8GB/10GB)
|
|
988
|
+
* down to its production/runtime tier. The build workspace is reused as the
|
|
989
|
+
* runtime, so without this every deployed app holds build-sized resources
|
|
990
|
+
* permanently and saturates the cloud pool — new builds then fail to place
|
|
991
|
+
* (CREATE_FAILED).
|
|
992
|
+
*
|
|
993
|
+
* Mandatory by design: a resize failure THROWS and fails the deploy. Leaving a
|
|
994
|
+
* service oversized is not an acceptable escape hatch — it silently poisons the
|
|
995
|
+
* shared pool. (cpu + memory are the pool/cost constraint; disk isn't in the
|
|
996
|
+
* deploy config and shrinking a data volume is unreliable, so it's left alone.)
|
|
997
|
+
*/
|
|
998
|
+
async shrinkToRuntimeTier(ws, config, log) {
|
|
999
|
+
const cpus = cloudCpus(config.resources?.cpuCores ?? DEFAULT_RESOURCE_CONFIG.cpuCores);
|
|
1000
|
+
const memory_mb = config.resources?.memoryMb ?? DEFAULT_RESOURCE_CONFIG.memoryMb;
|
|
1001
|
+
try {
|
|
1002
|
+
await withCloudOperationTimeout(
|
|
1003
|
+
ws.resources.update({ cpus, memory_mb, apply: true }),
|
|
1004
|
+
`Resizing service "${config.serviceName}" to its runtime tier`
|
|
1005
|
+
);
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
throw new Error(
|
|
1008
|
+
`Failed to shrink service "${config.serviceName}" from the build tier to its runtime tier: ${errorMessage(err)}`
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
log({
|
|
1012
|
+
timestamp: now(),
|
|
1013
|
+
message: `Sized "${config.serviceName}" to its runtime tier (${cpus} vCPU \xB7 ${memory_mb} MB).
|
|
1014
|
+
`,
|
|
1015
|
+
level: "info"
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
async createImageServiceWorkspace(config, onLog) {
|
|
1019
|
+
if (config.previousWorkspaceId) {
|
|
1020
|
+
try {
|
|
1021
|
+
const existing = this.deps.workspace(config.previousWorkspaceId);
|
|
1022
|
+
await existing.get();
|
|
1023
|
+
onLog({
|
|
1024
|
+
timestamp: now(),
|
|
1025
|
+
message: `Reusing existing workspace ${config.previousWorkspaceId} for service "${config.serviceName}" (preserves its disk/data).
|
|
1026
|
+
`,
|
|
1027
|
+
level: "info"
|
|
1028
|
+
});
|
|
1029
|
+
return config.previousWorkspaceId;
|
|
1030
|
+
} catch {
|
|
1031
|
+
onLog({
|
|
1032
|
+
timestamp: now(),
|
|
1033
|
+
message: `Previous workspace for "${config.serviceName}" is gone \u2014 creating a fresh one (its prior data is not recoverable).
|
|
1034
|
+
`,
|
|
1035
|
+
level: "warn"
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const diskMb = config.volumes.length ? Math.max(DEFAULT_RESOURCE_CONFIG.diskMb, 20480) : DEFAULT_RESOURCE_CONFIG.diskMb;
|
|
1040
|
+
const resources = {
|
|
1041
|
+
cpuCores: config.resources?.cpuCores ?? DEFAULT_RESOURCE_CONFIG.cpuCores,
|
|
1042
|
+
memoryMb: config.resources?.memoryMb ?? DEFAULT_RESOURCE_CONFIG.memoryMb,
|
|
1043
|
+
diskMb
|
|
1044
|
+
};
|
|
1045
|
+
const sharedOrBind = config.volumes.filter((v) => v.includes(":/") && v.startsWith("/"));
|
|
1046
|
+
if (sharedOrBind.length) {
|
|
1047
|
+
onLog({
|
|
1048
|
+
timestamp: now(),
|
|
1049
|
+
message: `Warning: bind-mount volumes are not supported on cloud and were ignored for "${config.serviceName}": ${sharedOrBind.join(", ")}
|
|
1050
|
+
`,
|
|
1051
|
+
level: "warn"
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
onLog({
|
|
1055
|
+
timestamp: now(),
|
|
1056
|
+
message: `Creating cloud service "${config.serviceName}" from image "${config.image}"...
|
|
1057
|
+
`,
|
|
1058
|
+
level: "info"
|
|
1059
|
+
});
|
|
1060
|
+
let wsData;
|
|
1061
|
+
try {
|
|
1062
|
+
wsData = await this.deps.client.workspaces.create({
|
|
1063
|
+
name: `${config.slug}-${config.serviceName}`.slice(0, 60),
|
|
1064
|
+
image: config.image,
|
|
1065
|
+
mode: "permanent",
|
|
1066
|
+
config: {
|
|
1067
|
+
cpus: cloudCpus(resources.cpuCores),
|
|
1068
|
+
memory_mb: resources.memoryMb,
|
|
1069
|
+
disk_size_mb: resources.diskMb,
|
|
1070
|
+
env: toEnvArray(config.environment)
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
const message = safeErrorMessage(err);
|
|
1075
|
+
onLog({
|
|
1076
|
+
timestamp: now(),
|
|
1077
|
+
message: `Failed to create cloud service "${config.serviceName}" from image "${config.image}": ${message}
|
|
1078
|
+
`,
|
|
1079
|
+
level: "error"
|
|
1080
|
+
});
|
|
1081
|
+
throw err;
|
|
1082
|
+
}
|
|
1083
|
+
return wsData.id;
|
|
1084
|
+
}
|
|
1085
|
+
async resolveWorkspaceIp(ws) {
|
|
1086
|
+
try {
|
|
1087
|
+
const raw = await ws.apiAccess.rawToken();
|
|
1088
|
+
if (raw?.ip) return raw.ip;
|
|
1089
|
+
} catch {
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
const network = await ws.network.get();
|
|
1093
|
+
if (network.ip) return network.ip;
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const data = await ws.get();
|
|
1098
|
+
return data.ip ?? null;
|
|
1099
|
+
} catch {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Oblien may assign the private IP a beat after the workspace starts. Poll
|
|
1105
|
+
* (bounded) so a transient null never permanently drops a service from the
|
|
1106
|
+
* mesh — the original bug was resolving once with no retry.
|
|
1107
|
+
*/
|
|
1108
|
+
async resolveWorkspaceIpWithRetry(ws, attempts = 10, delayMs = 1e3) {
|
|
1109
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1110
|
+
const ip = await this.resolveWorkspaceIp(ws);
|
|
1111
|
+
if (ip) return ip;
|
|
1112
|
+
if (attempt < attempts - 1) {
|
|
1113
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
async syncServiceDiscovery(group, onLog) {
|
|
1119
|
+
const services = [...group.services.values()].filter((service) => service.ip);
|
|
1120
|
+
if (services.length === 0) return;
|
|
1121
|
+
const workspaceIds = [...new Set(services.map((service) => service.workspaceId))];
|
|
1122
|
+
const hostsLines = services.map(
|
|
1123
|
+
(service) => `${service.ip} ${service.serviceName} # openship-compose:${group.id}`
|
|
1124
|
+
);
|
|
1125
|
+
const hostsBlock = hostsLines.join("\n");
|
|
1126
|
+
for (const service of services) {
|
|
1127
|
+
const ws = this.deps.workspace(service.workspaceId);
|
|
1128
|
+
const privateLinks = workspaceIds.filter(
|
|
1129
|
+
(workspaceId) => workspaceId !== service.workspaceId
|
|
1130
|
+
);
|
|
1131
|
+
const currentNetwork = await ws.network.get().catch(() => null);
|
|
1132
|
+
const currentIngress = Array.isArray(
|
|
1133
|
+
currentNetwork?.ingress_ports
|
|
1134
|
+
) ? currentNetwork.ingress_ports : [];
|
|
1135
|
+
const ingressPorts = [.../* @__PURE__ */ new Set([...currentIngress, ...service.ports])];
|
|
1136
|
+
if (ingressPorts.length > 0 || privateLinks.length > 0) {
|
|
1137
|
+
await ws.network.update({
|
|
1138
|
+
...privateLinks.length ? { private_link_ids: privateLinks } : {},
|
|
1139
|
+
...ingressPorts.length ? { ingress_ports: ingressPorts } : {}
|
|
1140
|
+
}).catch((err) => {
|
|
1141
|
+
onLog({
|
|
1142
|
+
timestamp: now(),
|
|
1143
|
+
message: `Warning: failed to link private network for "${service.serviceName}": ${err instanceof Error ? err.message : err}
|
|
1144
|
+
`,
|
|
1145
|
+
level: "warn"
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
const rt = await ws.runtime();
|
|
1151
|
+
const script = `set -e
|
|
1152
|
+
tmp=$(mktemp)
|
|
1153
|
+
grep -v ' # openship-compose:${group.id}' /etc/hosts > "$tmp" || true
|
|
1154
|
+
cat >> "$tmp" <<'EOF'
|
|
1155
|
+
${hostsBlock}
|
|
1156
|
+
EOF
|
|
1157
|
+
cat "$tmp" > /etc/hosts
|
|
1158
|
+
rm -f "$tmp"`;
|
|
1159
|
+
await this.deps.execAndStream(rt, ["sh", "-c", script], onLog);
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
onLog({
|
|
1162
|
+
timestamp: now(),
|
|
1163
|
+
message: `Warning: failed to update service discovery for "${service.serviceName}": ${err instanceof Error ? err.message : err}
|
|
1164
|
+
`,
|
|
1165
|
+
level: "warn"
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Final convergence pass — run ONCE after every service in the group has
|
|
1172
|
+
* deployed. The per-service `syncServiceDiscovery` is incremental and only
|
|
1173
|
+
* ever sees the IPs known at that moment; a service whose private IP hadn't
|
|
1174
|
+
* been assigned during its own deploy would otherwise stay permanently absent
|
|
1175
|
+
* from the mesh. This re-resolves any still-missing IP (bounded retry) and
|
|
1176
|
+
* rewrites the FULL /etc/hosts + private links + ingress across the whole
|
|
1177
|
+
* group, so the finished stack has every peer reachable by name.
|
|
1178
|
+
*
|
|
1179
|
+
* Note: we deliberately do NOT blanket-restart workloads here — that would
|
|
1180
|
+
* take stateful services (Postgres, …) down needlessly, and standard clients
|
|
1181
|
+
* resolve DNS per connection, so they read the completed /etc/hosts without a
|
|
1182
|
+
* restart. (A boot-time-cache app is the rare exception.)
|
|
1183
|
+
*/
|
|
1184
|
+
async finalizeGroup(groupId, onLog) {
|
|
1185
|
+
const log = onLog ?? (() => {
|
|
1186
|
+
});
|
|
1187
|
+
const group = this.groups.get(groupId);
|
|
1188
|
+
if (!group) return;
|
|
1189
|
+
for (const service of group.services.values()) {
|
|
1190
|
+
if (service.ip) continue;
|
|
1191
|
+
const ip = await this.resolveWorkspaceIpWithRetry(
|
|
1192
|
+
this.deps.workspace(service.workspaceId)
|
|
1193
|
+
);
|
|
1194
|
+
if (ip) {
|
|
1195
|
+
service.ip = ip;
|
|
1196
|
+
} else {
|
|
1197
|
+
log({
|
|
1198
|
+
timestamp: now(),
|
|
1199
|
+
message: `Warning: service "${service.serviceName}" never received a private IP \u2014 peers cannot reach it by name.
|
|
1200
|
+
`,
|
|
1201
|
+
level: "warn"
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
await this.syncServiceDiscovery(group, log);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// ../../packages/adapters/src/runtime/cloud.ts
|
|
1210
|
+
var DOCKERFILE_SOURCE_IMAGE = "node:22";
|
|
1211
|
+
var WORKSPACE_STREAM_EXEC_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
1212
|
+
function formatCloudError(err) {
|
|
1213
|
+
const base = safeErrorMessage(err);
|
|
1214
|
+
if (!err || typeof err !== "object") return base;
|
|
1215
|
+
const e = err;
|
|
1216
|
+
const tags = [];
|
|
1217
|
+
const codePart = [e.code, typeof e.status === "number" ? `HTTP ${e.status}` : null].filter(Boolean).join(" ");
|
|
1218
|
+
if (codePart) tags.push(codePart);
|
|
1219
|
+
if (e.requestId) tags.push(`requestId=${e.requestId}`);
|
|
1220
|
+
let detail = "";
|
|
1221
|
+
if (e.details != null) {
|
|
1222
|
+
try {
|
|
1223
|
+
detail = typeof e.details === "string" ? e.details : JSON.stringify(e.details);
|
|
1224
|
+
} catch {
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return `${base}${tags.length ? ` [${tags.join(", ")}]` : ""}${detail ? ` \u2014 ${detail.slice(0, 800)}` : ""}`;
|
|
1228
|
+
}
|
|
1229
|
+
function now2() {
|
|
1230
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1231
|
+
}
|
|
1232
|
+
function applyTail(entries, tail) {
|
|
1233
|
+
if (typeof tail !== "number") return entries;
|
|
1234
|
+
if (tail <= 0) return [];
|
|
1235
|
+
return entries.slice(-tail);
|
|
1236
|
+
}
|
|
1237
|
+
function summarizeCommandOutput(output) {
|
|
1238
|
+
const lines = output.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1239
|
+
const lastLine = lines.at(-1) ?? "";
|
|
1240
|
+
return lastLine.length > 300 ? `${lastLine.slice(0, 297)}...` : lastLine;
|
|
1241
|
+
}
|
|
1242
|
+
function exposeTarget2(port, slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
|
|
1243
|
+
return slug ? `port ${port} for slug "${slug}" (${slug}.${domain})` : `port ${port}`;
|
|
1244
|
+
}
|
|
1245
|
+
function primaryPublicEndpoint(config) {
|
|
1246
|
+
return config.publicEndpoints?.[0];
|
|
1247
|
+
}
|
|
1248
|
+
function endpointSlug(endpoint) {
|
|
1249
|
+
const slug = endpoint?.domain?.trim();
|
|
1250
|
+
return endpoint?.domainType === "free" && slug ? slug : void 0;
|
|
1251
|
+
}
|
|
1252
|
+
function endpointCustomDomain(endpoint) {
|
|
1253
|
+
const domain = endpoint?.customDomain?.trim();
|
|
1254
|
+
return endpoint?.domainType === "custom" && domain ? domain : void 0;
|
|
1255
|
+
}
|
|
1256
|
+
function fallbackRuntimeName(config) {
|
|
1257
|
+
const raw = config.runtimeName ?? `${config.projectId.slice(0, 20)}-${config.deploymentId.slice(0, 8)}`;
|
|
1258
|
+
const normalized = raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
1259
|
+
return normalized || `deploy-${config.deploymentId.slice(0, 8)}`;
|
|
1260
|
+
}
|
|
1261
|
+
function toEnvArray2(env) {
|
|
1262
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
1263
|
+
}
|
|
1264
|
+
function validEnvKey(key) {
|
|
1265
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
|
|
1266
|
+
}
|
|
1267
|
+
function envExportPrefix(env) {
|
|
1268
|
+
const parts = Object.entries(env).filter(([key]) => validEnvKey(key)).map(([key, value]) => `export ${key}=${sq(value ?? "")}`);
|
|
1269
|
+
return parts.length ? `${parts.join(" && ")} && ` : "";
|
|
1270
|
+
}
|
|
1271
|
+
function joinWorkspacePath(base, ...parts) {
|
|
1272
|
+
return posix.normalize(posix.join(base, ...parts.filter(Boolean)));
|
|
1273
|
+
}
|
|
1274
|
+
function resolveVmPath(workdir, target) {
|
|
1275
|
+
if (!target || target === ".") return workdir || "/";
|
|
1276
|
+
if (target.startsWith("/")) return posix.normalize(target);
|
|
1277
|
+
return posix.normalize(posix.join(workdir || "/", target));
|
|
1278
|
+
}
|
|
1279
|
+
function hasGlob(value) {
|
|
1280
|
+
return /[*?[\]]/.test(value);
|
|
1281
|
+
}
|
|
1282
|
+
function sourceExpression(baseDir, source) {
|
|
1283
|
+
const normalized = source === "." ? "." : source.replace(/^\/+/, "");
|
|
1284
|
+
const full = normalized === "." ? `${baseDir}/.` : `${baseDir}/${normalized}`;
|
|
1285
|
+
if (hasGlob(normalized)) {
|
|
1286
|
+
if (!/^[A-Za-z0-9_@%+=:,./*?[\]-]+$/.test(normalized)) {
|
|
1287
|
+
throw new Error(`Unsafe glob source in Dockerfile COPY: ${source}`);
|
|
1288
|
+
}
|
|
1289
|
+
return full;
|
|
1290
|
+
}
|
|
1291
|
+
return sq(full);
|
|
1292
|
+
}
|
|
1293
|
+
function dockerCopyShellCommands(opts) {
|
|
1294
|
+
const { sources, destination, destinationIsDir, destinationTarget } = opts;
|
|
1295
|
+
const destinationExpr = sq(destination);
|
|
1296
|
+
const destinationTargetExpr = sq(destinationTarget);
|
|
1297
|
+
return sources.flatMap((source) => [
|
|
1298
|
+
`for src in ${source}; do`,
|
|
1299
|
+
' if [ ! -e "$src" ]; then',
|
|
1300
|
+
' echo "COPY source not found: $src" >&2',
|
|
1301
|
+
" exit 1",
|
|
1302
|
+
" fi",
|
|
1303
|
+
' if [ -d "$src" ]; then',
|
|
1304
|
+
destinationIsDir ? ` mkdir -p ${destinationExpr}` : ` rm -rf ${destinationExpr} && mkdir -p ${destinationExpr}`,
|
|
1305
|
+
// Docker COPY copies directory contents, not the directory wrapper.
|
|
1306
|
+
` cp -a "$src/." ${destinationExpr}/`,
|
|
1307
|
+
" else",
|
|
1308
|
+
` mkdir -p ${destinationTargetExpr}`,
|
|
1309
|
+
` cp -a "$src" ${destinationExpr}`,
|
|
1310
|
+
" fi",
|
|
1311
|
+
"done"
|
|
1312
|
+
]);
|
|
1313
|
+
}
|
|
1314
|
+
function stageArtifactDownloadPath(source) {
|
|
1315
|
+
const normalized = source.trim();
|
|
1316
|
+
if (!normalized || normalized === ".") {
|
|
1317
|
+
return "/";
|
|
1318
|
+
}
|
|
1319
|
+
if (normalized.startsWith("/")) {
|
|
1320
|
+
return posix.normalize(normalized);
|
|
1321
|
+
}
|
|
1322
|
+
return posix.normalize(`/${normalized}`);
|
|
1323
|
+
}
|
|
1324
|
+
function substituteDockerArgs(value, args) {
|
|
1325
|
+
return value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (match, key) => {
|
|
1326
|
+
const replacement = args[key];
|
|
1327
|
+
return replacement === void 0 || replacement === null ? match : replacement;
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
function stageBaseLabel(stageBaseImage, requestedBaseImage, stageAlias) {
|
|
1331
|
+
if (stageAlias) {
|
|
1332
|
+
return `${requestedBaseImage} (stage alias -> ${stageBaseImage})`;
|
|
1333
|
+
}
|
|
1334
|
+
return stageBaseImage === requestedBaseImage ? requestedBaseImage : `${requestedBaseImage} (resolved to ${stageBaseImage})`;
|
|
1335
|
+
}
|
|
1336
|
+
function resolveCopyDestinationPath(copy, sourcePath, destination, destinationIsDir) {
|
|
1337
|
+
if (!destinationIsDir) {
|
|
1338
|
+
return destination;
|
|
1339
|
+
}
|
|
1340
|
+
const basename = posix.basename(sourcePath.replace(/\/+$/g, "")) || ".";
|
|
1341
|
+
return posix.normalize(posix.join(destination, basename));
|
|
1342
|
+
}
|
|
1343
|
+
var PAGE_CONTAINER_PREFIX = "page:";
|
|
1344
|
+
async function provisionCloudWorkspace(client, config, logger) {
|
|
1345
|
+
logger?.log(`Creating workspace from image "${config.image}"...
|
|
1346
|
+
`);
|
|
1347
|
+
let wsData;
|
|
1348
|
+
try {
|
|
1349
|
+
wsData = await client.workspaces.create({
|
|
1350
|
+
name: config.name,
|
|
1351
|
+
image: config.image,
|
|
1352
|
+
mode: config.mode,
|
|
1353
|
+
config: {
|
|
1354
|
+
cpus: cloudCpus(config.resources.cpuCores),
|
|
1355
|
+
memory_mb: config.resources.memoryMb,
|
|
1356
|
+
disk_size_mb: config.resources.diskMb,
|
|
1357
|
+
env: toEnvArray2(config.env ?? {})
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
logger?.log(`Failed to create workspace from image "${config.image}": ${formatCloudError(err)}
|
|
1362
|
+
`, "error");
|
|
1363
|
+
throw err;
|
|
1364
|
+
}
|
|
1365
|
+
const ws = client.workspace(wsData.id);
|
|
1366
|
+
try {
|
|
1367
|
+
if (config.mode === "temporary" && config.ttl) {
|
|
1368
|
+
try {
|
|
1369
|
+
await ws.lifecycle.makeTemporary({ ttl: config.ttl, ttl_action: "remove", remove_on_exit: true });
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
logger?.log("Connecting to build environment...\n");
|
|
1374
|
+
let rt;
|
|
1375
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1376
|
+
try {
|
|
1377
|
+
rt = await ws.runtime();
|
|
1378
|
+
break;
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
if (attempt < 2) await new Promise((r) => setTimeout(r, 2e3));
|
|
1381
|
+
else throw err;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (!rt) throw new Error("Failed to connect to build environment");
|
|
1385
|
+
logger?.log("Build environment ready\n");
|
|
1386
|
+
return { workspaceId: wsData.id, runtime: rt };
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
await ws.delete().catch(() => {
|
|
1389
|
+
});
|
|
1390
|
+
logger?.log(`Failed to prepare workspace "${wsData.id}": ${formatCloudError(err)}
|
|
1391
|
+
`, "error");
|
|
1392
|
+
throw err;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
var CloudRuntime = class {
|
|
1396
|
+
name = "cloud";
|
|
1397
|
+
capabilities = /* @__PURE__ */ new Set([
|
|
1398
|
+
"build",
|
|
1399
|
+
"deploy",
|
|
1400
|
+
"multiServiceDeploy",
|
|
1401
|
+
"stop",
|
|
1402
|
+
"start",
|
|
1403
|
+
"restart",
|
|
1404
|
+
"destroy",
|
|
1405
|
+
"containerInfo",
|
|
1406
|
+
"runtimeLogs",
|
|
1407
|
+
"streamLogs",
|
|
1408
|
+
"usage",
|
|
1409
|
+
"containerIp",
|
|
1410
|
+
"rollback",
|
|
1411
|
+
"serviceShell",
|
|
1412
|
+
"inContainerExec"
|
|
1413
|
+
]);
|
|
1414
|
+
/**
|
|
1415
|
+
* Services map to Oblien workspaces, not Docker containers, so host-level
|
|
1416
|
+
* compose extras have no equivalent. `healthcheck` is dropped-with-a-warning
|
|
1417
|
+
* here (A1); host-level keys — caps/devices/privileged/sysctls/ulimits/
|
|
1418
|
+
* logging/dns — join this set as they're added in later phases.
|
|
1419
|
+
*/
|
|
1420
|
+
unsupportedComposeKeys = /* @__PURE__ */ new Set([
|
|
1421
|
+
"healthcheck"
|
|
1422
|
+
]);
|
|
1423
|
+
client;
|
|
1424
|
+
// Optional admin-scoped proxy. Set when this runtime is constructed
|
|
1425
|
+
// by a local/desktop instance whose `client` is a namespace token —
|
|
1426
|
+
// admin-scoped operations (e.g. creating pages on shared `.opsh.io`)
|
|
1427
|
+
// get handed off to the SaaS through this callback. SaaS instances
|
|
1428
|
+
// construct CloudRuntime with master creds and leave it null;
|
|
1429
|
+
// `this.client` already has the needed scope there.
|
|
1430
|
+
adminProxy;
|
|
1431
|
+
builtArtifacts = /* @__PURE__ */ new Map();
|
|
1432
|
+
activeBuilds = /* @__PURE__ */ new Map();
|
|
1433
|
+
compose;
|
|
1434
|
+
constructor(client, opts) {
|
|
1435
|
+
this.client = client;
|
|
1436
|
+
this.adminProxy = opts?.adminProxy;
|
|
1437
|
+
this.compose = new CloudComposeSupport({
|
|
1438
|
+
client,
|
|
1439
|
+
builtArtifacts: this.builtArtifacts,
|
|
1440
|
+
workspace: (workspaceId) => this.ws(workspaceId),
|
|
1441
|
+
provisionWorkspace: (config, logger) => this.provisionWorkspace(config, logger),
|
|
1442
|
+
execAndStream: (runtime, command, onLog, timeoutSeconds) => this.execAndStream(runtime, command, onLog, timeoutSeconds)
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
supports(cap) {
|
|
1446
|
+
return this.capabilities.has(cap);
|
|
1447
|
+
}
|
|
1448
|
+
async dispose() {
|
|
1449
|
+
}
|
|
1450
|
+
/** Get a scoped workspace handle. */
|
|
1451
|
+
ws(workspaceId) {
|
|
1452
|
+
return this.client.workspace(workspaceId);
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Connect a custom domain to `workspaceId`, REASSIGNING it from a previous
|
|
1456
|
+
* deployment if needed.
|
|
1457
|
+
*
|
|
1458
|
+
* Each cloud deploy is a fresh workspace, but the custom domain is still
|
|
1459
|
+
* bound to the PREVIOUS (still-live) deployment's workspace during cutover —
|
|
1460
|
+
* so a plain connect returns "Domain is unavailable". That's a false
|
|
1461
|
+
* conflict when the project is re-pointing its OWN domain. We look up the
|
|
1462
|
+
* domain's current owner: if it's already this workspace, we're done
|
|
1463
|
+
* (idempotent); if it's another workspace we own (the prior deploy), we
|
|
1464
|
+
* disconnect it there and retry. A domain owned by anything else (another
|
|
1465
|
+
* account's route, a page, etc.) still errors — no silent takeover.
|
|
1466
|
+
*/
|
|
1467
|
+
async connectCustomDomainReassigning(workspaceId, domain, port, log) {
|
|
1468
|
+
const ws = this.ws(workspaceId);
|
|
1469
|
+
try {
|
|
1470
|
+
await ws.domains.connect({ domain, port });
|
|
1471
|
+
return;
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
const owner = await this.findDomainOwner(domain).catch(() => null);
|
|
1474
|
+
if (owner && owner.owner_id === workspaceId) return;
|
|
1475
|
+
if (!owner || owner.owner_type !== "workspace") throw err;
|
|
1476
|
+
log?.({
|
|
1477
|
+
timestamp: now2(),
|
|
1478
|
+
level: "info",
|
|
1479
|
+
message: `Reassigning ${domain} from the previous deployment...
|
|
1480
|
+
`
|
|
1481
|
+
});
|
|
1482
|
+
await this.ws(owner.owner_id).domains.disconnect().catch(() => {
|
|
1483
|
+
});
|
|
1484
|
+
await ws.domains.connect({ domain, port });
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/** Find the current owner route for a hostname among the caller's routes. */
|
|
1488
|
+
async findDomainOwner(domain) {
|
|
1489
|
+
const target = domain.trim().toLowerCase();
|
|
1490
|
+
const res = await this.client.domain.routes();
|
|
1491
|
+
const routes = res?.data ?? [];
|
|
1492
|
+
const match = routes.find(
|
|
1493
|
+
(r) => (r.hostname ?? "").toLowerCase() === target || (r.domain ?? "").toLowerCase() === target
|
|
1494
|
+
);
|
|
1495
|
+
return match ? { owner_id: match.owner_id, owner_type: match.owner_type } : null;
|
|
1496
|
+
}
|
|
1497
|
+
createActiveBuild(sessionId) {
|
|
1498
|
+
const activeBuild = {
|
|
1499
|
+
abort: new AbortController(),
|
|
1500
|
+
workspaceIds: /* @__PURE__ */ new Set()
|
|
1501
|
+
};
|
|
1502
|
+
this.activeBuilds.set(sessionId, activeBuild);
|
|
1503
|
+
return activeBuild;
|
|
1504
|
+
}
|
|
1505
|
+
trackActiveBuildWorkspace(sessionId, workspaceId) {
|
|
1506
|
+
this.activeBuilds.get(sessionId)?.workspaceIds.add(workspaceId);
|
|
1507
|
+
}
|
|
1508
|
+
untrackActiveBuildWorkspace(sessionId, workspaceId) {
|
|
1509
|
+
this.activeBuilds.get(sessionId)?.workspaceIds.delete(workspaceId);
|
|
1510
|
+
}
|
|
1511
|
+
// ── Build lifecycle ────────────────────────────────────────────────────
|
|
1512
|
+
async build(config, logger) {
|
|
1513
|
+
const log = logger ?? new BuildLogger();
|
|
1514
|
+
const activeBuild = this.createActiveBuild(config.sessionId);
|
|
1515
|
+
try {
|
|
1516
|
+
if (config.stack === "docker" || config.dockerfilePath) {
|
|
1517
|
+
const result2 = await this.buildDockerfileWorkspace(config, log);
|
|
1518
|
+
return activeBuild.abort.signal.aborted ? { ...result2, status: "cancelled", errorMessage: void 0 } : result2;
|
|
1519
|
+
}
|
|
1520
|
+
const buildLocally = config.buildStrategy === "local";
|
|
1521
|
+
let wsId;
|
|
1522
|
+
let rt;
|
|
1523
|
+
try {
|
|
1524
|
+
if (config.cloudWorkspaceId) {
|
|
1525
|
+
log.log("Attaching to uploaded workspace...\n");
|
|
1526
|
+
wsId = config.cloudWorkspaceId;
|
|
1527
|
+
rt = await this.adoptWorkspaceRuntime(wsId);
|
|
1528
|
+
this.trackActiveBuildWorkspace(config.sessionId, wsId);
|
|
1529
|
+
log.log("Build environment ready\n");
|
|
1530
|
+
} else {
|
|
1531
|
+
const provisioned = await this.provisionBuildWorkspace(config, log);
|
|
1532
|
+
wsId = provisioned.workspaceId;
|
|
1533
|
+
rt = provisioned.runtime;
|
|
1534
|
+
this.trackActiveBuildWorkspace(config.sessionId, wsId);
|
|
1535
|
+
}
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
const msg = formatCloudError(err);
|
|
1538
|
+
log.log(`Failed to provision build environment: ${msg}`, "error");
|
|
1539
|
+
return {
|
|
1540
|
+
sessionId: config.sessionId,
|
|
1541
|
+
status: activeBuild.abort.signal.aborted ? "cancelled" : "failed",
|
|
1542
|
+
durationMs: 0,
|
|
1543
|
+
errorMessage: activeBuild.abort.signal.aborted ? void 0 : `Failed to provision build environment: ${msg}`
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
if (buildLocally) {
|
|
1547
|
+
log.log("Build strategy: local (build on API host, upload to cloud)\n");
|
|
1548
|
+
const stackDef = STACKS[config.stack];
|
|
1549
|
+
let standaloneStartCommand;
|
|
1550
|
+
let result2;
|
|
1551
|
+
try {
|
|
1552
|
+
result2 = await runLocalBuild({
|
|
1553
|
+
config,
|
|
1554
|
+
logger: log,
|
|
1555
|
+
abort: activeBuild.abort.signal,
|
|
1556
|
+
transferOutput: async (buildDir) => {
|
|
1557
|
+
if (activeBuild.abort.signal.aborted) {
|
|
1558
|
+
throw new Error("Build cancelled");
|
|
1559
|
+
}
|
|
1560
|
+
const selfContained = await prepareStackOutput(
|
|
1561
|
+
config.stack,
|
|
1562
|
+
resolveProjectDir(buildDir, config.rootDirectory)
|
|
1563
|
+
);
|
|
1564
|
+
if (selfContained) {
|
|
1565
|
+
log.log("Detected self-contained build output \u2014 uploading the bundle (no install on cloud).\n");
|
|
1566
|
+
await transferLocalDirectory(
|
|
1567
|
+
selfContained.bundleDir,
|
|
1568
|
+
{ kind: "cloud-runtime", runtime: rt, path: "/app" },
|
|
1569
|
+
log,
|
|
1570
|
+
{ excludes: [] }
|
|
1571
|
+
);
|
|
1572
|
+
standaloneStartCommand = selfContained.startCommand;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (stackDef?.productionPaths?.length) {
|
|
1576
|
+
log.log(`Transferring production paths: ${stackDef.productionPaths.join(", ")}
|
|
1577
|
+
`);
|
|
1578
|
+
await transferLocalDirectory(
|
|
1579
|
+
buildDir,
|
|
1580
|
+
{ kind: "cloud-runtime", runtime: rt, path: "/app" },
|
|
1581
|
+
log,
|
|
1582
|
+
{ includes: [...stackDef.productionPaths] }
|
|
1583
|
+
);
|
|
1584
|
+
} else {
|
|
1585
|
+
await transferLocalDirectory(
|
|
1586
|
+
buildDir,
|
|
1587
|
+
{ kind: "cloud-runtime", runtime: rt, path: "/app" },
|
|
1588
|
+
log,
|
|
1589
|
+
{
|
|
1590
|
+
excludes: buildOutputTransferExcludes(stackDef),
|
|
1591
|
+
alsoInclude: stackDef?.outputDirectory ? [stackDef.outputDirectory] : void 0
|
|
1592
|
+
}
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
if (activeBuild.abort.signal.aborted) {
|
|
1596
|
+
throw new Error("Build cancelled");
|
|
1597
|
+
}
|
|
1598
|
+
const installCmd = config.installCommand?.trim();
|
|
1599
|
+
if (installCmd) {
|
|
1600
|
+
log.log("Installing production dependencies on cloud...\n");
|
|
1601
|
+
const pmEnsure = packageManagerEnsureCommand(config.packageManager);
|
|
1602
|
+
const fullInstall = pmEnsure ? `${pmEnsure} && ${installCmd}` : installCmd;
|
|
1603
|
+
await this.execAndStream(rt, ["sh", "-c", `cd /app && ${fullInstall}`], log.callback);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
const msg = safeErrorMessage(err);
|
|
1609
|
+
log.log(`Failed to upload local build output: ${msg}`, "error");
|
|
1610
|
+
return {
|
|
1611
|
+
sessionId: config.sessionId,
|
|
1612
|
+
status: activeBuild.abort.signal.aborted ? "cancelled" : "failed",
|
|
1613
|
+
imageRef: wsId,
|
|
1614
|
+
errorMessage: activeBuild.abort.signal.aborted ? void 0 : `Failed to upload local build output: ${msg}`
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
return {
|
|
1618
|
+
sessionId: config.sessionId,
|
|
1619
|
+
status: activeBuild.abort.signal.aborted ? "cancelled" : result2.status,
|
|
1620
|
+
imageRef: wsId,
|
|
1621
|
+
durationMs: result2.durationMs,
|
|
1622
|
+
errorMessage: activeBuild.abort.signal.aborted ? void 0 : result2.errorMessage,
|
|
1623
|
+
startCommand: standaloneStartCommand
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
log.log("Build strategy: server (build in cloud workspace)\n");
|
|
1627
|
+
const buildEnv = {
|
|
1628
|
+
projectDir: "/app",
|
|
1629
|
+
// A provisioned workspace gets env baked in at create time (native), so
|
|
1630
|
+
// the pipeline skips the shell export prefix. An ADOPTED upload
|
|
1631
|
+
// workspace was created before the deploy's env vars were known, so it
|
|
1632
|
+
// has none — the pipeline must inject them inline instead.
|
|
1633
|
+
hasNativeEnv: !config.cloudWorkspaceId,
|
|
1634
|
+
exec: async (command, logCb) => {
|
|
1635
|
+
if (activeBuild.abort.signal.aborted) {
|
|
1636
|
+
throw new Error("Build cancelled");
|
|
1637
|
+
}
|
|
1638
|
+
await this.execAndStream(rt, ["sh", "-c", command], logCb);
|
|
1639
|
+
if (activeBuild.abort.signal.aborted) {
|
|
1640
|
+
throw new Error("Build cancelled");
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
preflight: async (cfg, plog) => {
|
|
1644
|
+
if (activeBuild.abort.signal.aborted) {
|
|
1645
|
+
throw new Error("Build cancelled");
|
|
1646
|
+
}
|
|
1647
|
+
if (cfg.sourceStaged) {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (!cfg.localPath) {
|
|
1651
|
+
await this.ensureWorkspaceGit(rt, plog, "build workspace");
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
await transferLocalDirectory(
|
|
1655
|
+
cfg.localPath,
|
|
1656
|
+
{
|
|
1657
|
+
kind: "cloud-runtime",
|
|
1658
|
+
runtime: rt,
|
|
1659
|
+
path: "/app"
|
|
1660
|
+
},
|
|
1661
|
+
plog
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
const result = await runBuildPipeline(buildEnv, config, log);
|
|
1666
|
+
return {
|
|
1667
|
+
sessionId: config.sessionId,
|
|
1668
|
+
status: activeBuild.abort.signal.aborted ? "cancelled" : result.status,
|
|
1669
|
+
imageRef: wsId,
|
|
1670
|
+
durationMs: result.durationMs,
|
|
1671
|
+
errorMessage: activeBuild.abort.signal.aborted ? void 0 : result.errorMessage
|
|
1672
|
+
};
|
|
1673
|
+
} finally {
|
|
1674
|
+
this.activeBuilds.delete(config.sessionId);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
async buildDockerfileWorkspace(config, log) {
|
|
1678
|
+
const startedAt = Date.now();
|
|
1679
|
+
const createdWorkspaceIds = [];
|
|
1680
|
+
let finalWorkspaceId;
|
|
1681
|
+
let source;
|
|
1682
|
+
try {
|
|
1683
|
+
log.log("Build strategy: Dockerfile plan (build in Oblien workspaces)\n");
|
|
1684
|
+
source = await this.resolveDockerfileBuildSource(config, log);
|
|
1685
|
+
const plan = compileDockerfileToWorkspacePlan(source.dockerfile);
|
|
1686
|
+
const blocking = plan.diagnostics.filter(
|
|
1687
|
+
(item) => item.severity === "error" || item.severity === "unsupported"
|
|
1688
|
+
);
|
|
1689
|
+
if (blocking.length > 0) {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
blocking.map((item) => item.line ? `line ${item.line}: ${item.message}` : item.message).join("\n")
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
if (plan.stages.length === 0 || !plan.finalStage || !plan.runtime) {
|
|
1695
|
+
throw new Error("Dockerfile did not produce a deployable final stage.");
|
|
1696
|
+
}
|
|
1697
|
+
const unsafe = this.getUnsupportedDockerfilePlanFeatures(plan);
|
|
1698
|
+
if (unsafe.length > 0) {
|
|
1699
|
+
throw new Error(unsafe.join("\n"));
|
|
1700
|
+
}
|
|
1701
|
+
for (const warning of plan.diagnostics.filter((item) => item.severity === "warning")) {
|
|
1702
|
+
log.log(
|
|
1703
|
+
`${warning.line ? `Dockerfile line ${warning.line}: ` : ""}${warning.message}
|
|
1704
|
+
`,
|
|
1705
|
+
"warn"
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
const built = await this.executeDockerfilePlan({
|
|
1709
|
+
config,
|
|
1710
|
+
plan,
|
|
1711
|
+
source,
|
|
1712
|
+
logger: log,
|
|
1713
|
+
onWorkspaceCreated: (workspaceId) => {
|
|
1714
|
+
createdWorkspaceIds.push(workspaceId);
|
|
1715
|
+
this.trackActiveBuildWorkspace(config.sessionId, workspaceId);
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
finalWorkspaceId = built.workspaceId;
|
|
1719
|
+
this.builtArtifacts.set(built.workspaceId, {
|
|
1720
|
+
workspaceId: built.workspaceId,
|
|
1721
|
+
runtime: built.runtime
|
|
1722
|
+
});
|
|
1723
|
+
log.log(
|
|
1724
|
+
`Dockerfile runtime plan: workdir ${built.runtime.workdir}${built.runtime.exposedPort ? ` \xB7 port ${built.runtime.exposedPort}` : " \xB7 no EXPOSE port"}${built.runtime.startCommand ? ` \xB7 start ${built.runtime.startCommand}` : " \xB7 no start command"}
|
|
1725
|
+
`
|
|
1726
|
+
);
|
|
1727
|
+
return {
|
|
1728
|
+
sessionId: config.sessionId,
|
|
1729
|
+
status: "deploying",
|
|
1730
|
+
imageRef: built.workspaceId,
|
|
1731
|
+
durationMs: Date.now() - startedAt
|
|
1732
|
+
};
|
|
1733
|
+
} catch (err) {
|
|
1734
|
+
for (const workspaceId of createdWorkspaceIds) {
|
|
1735
|
+
if (workspaceId === finalWorkspaceId) continue;
|
|
1736
|
+
await this.ws(workspaceId).delete().catch(() => {
|
|
1737
|
+
});
|
|
1738
|
+
this.untrackActiveBuildWorkspace(config.sessionId, workspaceId);
|
|
1739
|
+
}
|
|
1740
|
+
const message = safeErrorMessage(err);
|
|
1741
|
+
log.log(`Dockerfile build failed: ${message}
|
|
1742
|
+
`, "error");
|
|
1743
|
+
return {
|
|
1744
|
+
sessionId: config.sessionId,
|
|
1745
|
+
status: "failed",
|
|
1746
|
+
durationMs: Date.now() - startedAt,
|
|
1747
|
+
errorMessage: message
|
|
1748
|
+
};
|
|
1749
|
+
} finally {
|
|
1750
|
+
await source?.cleanup().catch(() => {
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
async resolveDockerfileBuildSource(config, logger) {
|
|
1755
|
+
if (config.dockerfileContent?.trim()) {
|
|
1756
|
+
logger.log("Using Dockerfile content from source metadata.\n");
|
|
1757
|
+
return {
|
|
1758
|
+
kind: "remote",
|
|
1759
|
+
contextRelativePath: normalizeDockerRelativePath(config.rootDirectory),
|
|
1760
|
+
dockerfile: config.dockerfileContent,
|
|
1761
|
+
cleanup: async () => {
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
if (!config.localPath) {
|
|
1766
|
+
return this.resolveRemoteDockerfileBuildSource(config, logger);
|
|
1767
|
+
}
|
|
1768
|
+
logger.log("Preparing local Dockerfile source...\n");
|
|
1769
|
+
const context = await createDockerBuildContext(config, {
|
|
1770
|
+
requireRepositoryDockerfile: true,
|
|
1771
|
+
onLog: logger.callback
|
|
1772
|
+
});
|
|
1773
|
+
const dockerfilePath = join(context.contextDir, ...context.dockerfileName.split("/"));
|
|
1774
|
+
const dockerfile = await readFile(dockerfilePath, "utf-8");
|
|
1775
|
+
const contextRoot = join(
|
|
1776
|
+
context.contextDir,
|
|
1777
|
+
...normalizeDockerRelativePath(context.rootDirectory).split("/").filter(Boolean)
|
|
1778
|
+
);
|
|
1779
|
+
return {
|
|
1780
|
+
kind: "local",
|
|
1781
|
+
contextRoot,
|
|
1782
|
+
dockerfile,
|
|
1783
|
+
cleanup: context.cleanup
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
async resolveRemoteDockerfileBuildSource(config, logger) {
|
|
1787
|
+
const candidates = resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath);
|
|
1788
|
+
const sourceLabel = config.commitSha ? `${config.branch}@${config.commitSha.slice(0, 7)}` : config.branch;
|
|
1789
|
+
const checkoutRef = config.commitSha ?? "FETCH_HEAD";
|
|
1790
|
+
const sourceDir = "/openship/dockerfile-source";
|
|
1791
|
+
let sourceWorkspaceId;
|
|
1792
|
+
try {
|
|
1793
|
+
logger.log(`Resolving Dockerfile source in cloud workspace (${sourceLabel})...
|
|
1794
|
+
`);
|
|
1795
|
+
const provisioned = await this.provisionWorkspace(
|
|
1796
|
+
{
|
|
1797
|
+
name: `${config.slug ?? config.projectId}-source`.slice(0, 60),
|
|
1798
|
+
image: DOCKERFILE_SOURCE_IMAGE,
|
|
1799
|
+
mode: "temporary",
|
|
1800
|
+
resources: config.resources,
|
|
1801
|
+
env: config.envVars,
|
|
1802
|
+
ttl: "10m"
|
|
1803
|
+
},
|
|
1804
|
+
logger
|
|
1805
|
+
);
|
|
1806
|
+
sourceWorkspaceId = provisioned.workspaceId;
|
|
1807
|
+
this.trackActiveBuildWorkspace(config.sessionId, provisioned.workspaceId);
|
|
1808
|
+
await this.ensureWorkspaceGit(provisioned.runtime, logger, "Dockerfile source workspace");
|
|
1809
|
+
const executor = this.workspaceExecutor(provisioned.runtime);
|
|
1810
|
+
const cloneUrl = injectGitToken(config.repoUrl, config.gitToken);
|
|
1811
|
+
const fetchCommand = [
|
|
1812
|
+
"set -e",
|
|
1813
|
+
`rm -rf ${sq(sourceDir)}`,
|
|
1814
|
+
`mkdir -p ${sq(sourceDir)}`,
|
|
1815
|
+
`cd ${sq(sourceDir)}`,
|
|
1816
|
+
"git init -q",
|
|
1817
|
+
`git remote add origin ${sq(cloneUrl)}`,
|
|
1818
|
+
// GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/echo prevent the
|
|
1819
|
+
// process from hanging on a credential prompt when a non-tty
|
|
1820
|
+
// build runner has no token; --progress forces git to emit
|
|
1821
|
+
// progress lines to stderr so they reach the build log stream.
|
|
1822
|
+
`GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo git -c credential.helper= fetch --progress --depth ${config.commitSha ? "50" : "1"} origin ${sq(config.branch)}`,
|
|
1823
|
+
`git -c credential.helper= -c advice.detachedHead=false checkout -q ${sq(checkoutRef)}`,
|
|
1824
|
+
'echo "Dockerfile source fetch ready."'
|
|
1825
|
+
].join("\n");
|
|
1826
|
+
const fetchResult = await executor.streamExec(fetchCommand, logger.callback);
|
|
1827
|
+
if (fetchResult.code !== 0) {
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
summarizeCommandOutput(fetchResult.output) || "Failed to fetch Dockerfile source"
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
for (const candidate of candidates) {
|
|
1833
|
+
logger.log(`Checking Dockerfile candidate: ${candidate}
|
|
1834
|
+
`);
|
|
1835
|
+
const dockerfile = await executor.exec(
|
|
1836
|
+
[
|
|
1837
|
+
`cd ${sq(sourceDir)}`,
|
|
1838
|
+
`test -f ${sq(candidate)}`,
|
|
1839
|
+
`cat ${sq(candidate)}`
|
|
1840
|
+
].join(" && "),
|
|
1841
|
+
{ timeout: 3e4 }
|
|
1842
|
+
).catch(() => null);
|
|
1843
|
+
if (dockerfile !== null) {
|
|
1844
|
+
logger.log(`Dockerfile source resolved: ${candidate}
|
|
1845
|
+
`);
|
|
1846
|
+
const workspaceId = sourceWorkspaceId;
|
|
1847
|
+
sourceWorkspaceId = void 0;
|
|
1848
|
+
return {
|
|
1849
|
+
kind: "remote",
|
|
1850
|
+
contextRelativePath: normalizeDockerRelativePath(config.rootDirectory),
|
|
1851
|
+
dockerfile,
|
|
1852
|
+
cleanup: async () => {
|
|
1853
|
+
await this.ws(workspaceId).delete().catch(() => {
|
|
1854
|
+
});
|
|
1855
|
+
this.untrackActiveBuildWorkspace(config.sessionId, workspaceId);
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
const discovered = await executor.exec(
|
|
1861
|
+
`cd ${sq(sourceDir)} && find . -maxdepth 5 -type f -iname Dockerfile | sed 's#^./##' | head -20`,
|
|
1862
|
+
{ timeout: 3e4 }
|
|
1863
|
+
).then((value) => value.trim()).catch(() => "");
|
|
1864
|
+
throw new Error(
|
|
1865
|
+
`No Dockerfile found. Checked: ${candidates.join(", ")}${discovered ? `. Found: ${discovered.replace(/\n/g, ", ")}` : ""}`
|
|
1866
|
+
);
|
|
1867
|
+
} finally {
|
|
1868
|
+
if (sourceWorkspaceId) {
|
|
1869
|
+
await this.ws(sourceWorkspaceId).delete().catch(() => {
|
|
1870
|
+
});
|
|
1871
|
+
this.untrackActiveBuildWorkspace(config.sessionId, sourceWorkspaceId);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
getUnsupportedDockerfilePlanFeatures(plan) {
|
|
1876
|
+
const errors = [];
|
|
1877
|
+
for (const stage of plan.stages) {
|
|
1878
|
+
if (stage.shell && stage.shell.join("\0") !== ["/bin/sh", "-c"].join("\0")) {
|
|
1879
|
+
errors.push(
|
|
1880
|
+
`Dockerfile stage "${stage.name ?? stage.index}" uses SHELL ${JSON.stringify(stage.shell)}, which is not supported by cloud Dockerfile builds yet.`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
for (const run of stage.runs) {
|
|
1884
|
+
const flags = Object.keys(run.flags);
|
|
1885
|
+
if (flags.length > 0) {
|
|
1886
|
+
errors.push(
|
|
1887
|
+
`Dockerfile line ${run.line}: RUN flags (${flags.map((flag) => `--${flag}`).join(", ")}) are not supported by cloud Dockerfile builds yet.`
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
for (const copy of stage.copies) {
|
|
1892
|
+
if (copy.kind === "add") {
|
|
1893
|
+
errors.push(
|
|
1894
|
+
`Dockerfile line ${copy.line}: ADD is not supported by cloud Dockerfile builds yet. Use COPY for local files.`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
const flags = Object.keys(copy.flags).filter((flag) => flag !== "from");
|
|
1898
|
+
if (flags.length > 0) {
|
|
1899
|
+
errors.push(
|
|
1900
|
+
`Dockerfile line ${copy.line}: ${copy.kind.toUpperCase()} flags (${flags.map((flag) => `--${flag}`).join(", ")}) are not supported by cloud Dockerfile builds yet.`
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return errors;
|
|
1906
|
+
}
|
|
1907
|
+
async executeDockerfilePlan(opts) {
|
|
1908
|
+
const { config, plan, source, logger, onWorkspaceCreated } = opts;
|
|
1909
|
+
const plannedStageRefs = /* @__PURE__ */ new Map();
|
|
1910
|
+
const resolvedStageBaseImages = /* @__PURE__ */ new Map();
|
|
1911
|
+
const stageBaseLabels = /* @__PURE__ */ new Map();
|
|
1912
|
+
let workspaceBaseImage;
|
|
1913
|
+
let workspaceId;
|
|
1914
|
+
try {
|
|
1915
|
+
for (const stage of plan.stages) {
|
|
1916
|
+
const stageName = stage.name ?? String(stage.index);
|
|
1917
|
+
const allArgs = { ...plan.globalArgs, ...stage.args };
|
|
1918
|
+
const requestedBaseImage = substituteDockerArgs(stage.baseImage, allArgs);
|
|
1919
|
+
const baseStageRef = plannedStageRefs.get(requestedBaseImage);
|
|
1920
|
+
const baseImage = baseStageRef ? resolvedStageBaseImages.get(baseStageRef.index) : requestedBaseImage;
|
|
1921
|
+
if (!baseImage || baseImage.includes("$")) {
|
|
1922
|
+
throw new Error(
|
|
1923
|
+
`Dockerfile stage "${stageName}" has unresolved base image "${stage.baseImage}".`
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
const baseLabel = stageBaseLabel(baseImage, requestedBaseImage, baseStageRef?.name);
|
|
1927
|
+
if (workspaceBaseImage && workspaceBaseImage !== baseImage) {
|
|
1928
|
+
throw new Error(
|
|
1929
|
+
`Dockerfile stage "${stageName}" uses base image "${baseLabel}", but cloud Dockerfile builds run one workspace per service and only support one base image per Dockerfile. Use stage aliases that resolve to "${workspaceBaseImage}" or deploy this service with Docker support.`
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
workspaceBaseImage ??= baseImage;
|
|
1933
|
+
stageBaseLabels.set(stage.index, baseLabel);
|
|
1934
|
+
resolvedStageBaseImages.set(stage.index, baseImage);
|
|
1935
|
+
plannedStageRefs.set(String(stage.index), stage);
|
|
1936
|
+
if (stage.name) plannedStageRefs.set(stage.name, stage);
|
|
1937
|
+
}
|
|
1938
|
+
if (!workspaceBaseImage) {
|
|
1939
|
+
throw new Error("Dockerfile did not define a base image.");
|
|
1940
|
+
}
|
|
1941
|
+
const provisioned = await this.provisionWorkspace(
|
|
1942
|
+
{
|
|
1943
|
+
name: `${config.slug ?? config.projectId}-dockerfile`.slice(0, 60),
|
|
1944
|
+
image: workspaceBaseImage,
|
|
1945
|
+
mode: "temporary",
|
|
1946
|
+
resources: config.resources,
|
|
1947
|
+
env: config.envVars,
|
|
1948
|
+
ttl: "15m"
|
|
1949
|
+
},
|
|
1950
|
+
logger
|
|
1951
|
+
);
|
|
1952
|
+
workspaceId = provisioned.workspaceId;
|
|
1953
|
+
onWorkspaceCreated(provisioned.workspaceId);
|
|
1954
|
+
if (plan.stages.some(
|
|
1955
|
+
(stage) => stage.steps.some((step) => step.type === "copy" && !step.copy.from)
|
|
1956
|
+
)) {
|
|
1957
|
+
await this.transferDockerfileContext(config, source, provisioned.runtime, logger);
|
|
1958
|
+
}
|
|
1959
|
+
const completedStageRefs = /* @__PURE__ */ new Map();
|
|
1960
|
+
for (const stage of plan.stages) {
|
|
1961
|
+
const stageName = stage.name ?? String(stage.index);
|
|
1962
|
+
const baseLabel = stageBaseLabels.get(stage.index) ?? workspaceBaseImage;
|
|
1963
|
+
logger.log(`Preparing Dockerfile stage "${stageName}" from ${baseLabel}...
|
|
1964
|
+
`);
|
|
1965
|
+
await this.execAndStream(
|
|
1966
|
+
provisioned.runtime,
|
|
1967
|
+
["sh", "-c", `mkdir -p ${sq(stage.workdir || "/")}`],
|
|
1968
|
+
logger.callback
|
|
1969
|
+
);
|
|
1970
|
+
for (const step of stage.steps) {
|
|
1971
|
+
if (step.type === "copy") {
|
|
1972
|
+
await this.applyDockerfileCopyStep({
|
|
1973
|
+
copy: step.copy,
|
|
1974
|
+
stageRefs: completedStageRefs,
|
|
1975
|
+
runtime: provisioned.runtime,
|
|
1976
|
+
logger
|
|
1977
|
+
});
|
|
1978
|
+
} else {
|
|
1979
|
+
await this.applyDockerfileRunStep({
|
|
1980
|
+
run: step.run,
|
|
1981
|
+
runtime: provisioned.runtime,
|
|
1982
|
+
logger,
|
|
1983
|
+
env: {
|
|
1984
|
+
...config.envVars,
|
|
1985
|
+
...plan.globalArgs,
|
|
1986
|
+
...stage.args,
|
|
1987
|
+
...step.run.env
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
completedStageRefs.set(String(stage.index), stage);
|
|
1993
|
+
if (stage.name) completedStageRefs.set(stage.name, stage);
|
|
1994
|
+
}
|
|
1995
|
+
if (!plan.runtime) {
|
|
1996
|
+
throw new Error("Dockerfile final stage workspace was not created.");
|
|
1997
|
+
}
|
|
1998
|
+
return { workspaceId: provisioned.workspaceId, runtime: plan.runtime };
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
if (workspaceId) {
|
|
2001
|
+
await this.ws(workspaceId).delete().catch(() => {
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
throw err;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
async transferDockerfileContext(config, source, runtime, logger) {
|
|
2008
|
+
if (source.kind === "local") {
|
|
2009
|
+
await transferLocalDirectory(
|
|
2010
|
+
source.contextRoot,
|
|
2011
|
+
{ kind: "cloud-runtime", runtime, path: "/openship/context" },
|
|
2012
|
+
logger,
|
|
2013
|
+
{ excludes: [...TRANSFER_EXCLUDES] }
|
|
2014
|
+
);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
await this.cloneDockerfileContext(config, source, runtime, logger);
|
|
2018
|
+
}
|
|
2019
|
+
async cloneDockerfileContext(config, source, targetRuntime, logger) {
|
|
2020
|
+
const repoRoot = "/openship/repo";
|
|
2021
|
+
const contextRoot = "/openship/context";
|
|
2022
|
+
const contextRelativePath = normalizeDockerRelativePath(source.contextRelativePath);
|
|
2023
|
+
if (contextRelativePath.startsWith("..")) {
|
|
2024
|
+
throw new Error("Dockerfile build context escapes the repository source.");
|
|
2025
|
+
}
|
|
2026
|
+
const cloneUrl = injectGitToken(config.repoUrl, config.gitToken);
|
|
2027
|
+
const depthArgs = config.commitSha ? "--depth 50 " : "--depth 1 ";
|
|
2028
|
+
const cloneTarget = contextRelativePath ? repoRoot : contextRoot;
|
|
2029
|
+
const contextSource = contextRelativePath ? joinWorkspacePath(repoRoot, contextRelativePath) : contextRoot;
|
|
2030
|
+
const prepareContextCommands = contextRelativePath ? [
|
|
2031
|
+
`echo "Copying Dockerfile context ${contextRelativePath}..."`,
|
|
2032
|
+
`mkdir -p ${sq(contextRoot)}`,
|
|
2033
|
+
`cp -a ${sq(`${contextSource}/.`)} ${sq(contextRoot)}/`
|
|
2034
|
+
] : ['echo "Using repository root as Dockerfile context."'];
|
|
2035
|
+
const cloneCommand = [
|
|
2036
|
+
"set -e",
|
|
2037
|
+
`rm -rf ${sq(repoRoot)} ${sq(contextRoot)}`,
|
|
2038
|
+
"mkdir -p /openship",
|
|
2039
|
+
// See fetchCommand above for env-var rationale; --progress keeps
|
|
2040
|
+
// the clone visible in the streamed log even though stdout/stderr
|
|
2041
|
+
// are pipes, not a tty.
|
|
2042
|
+
`GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo git -c credential.helper= clone --progress ${depthArgs}--branch ${sq(config.branch)} ${sq(cloneUrl)} ${sq(cloneTarget)}`
|
|
2043
|
+
].join("\n");
|
|
2044
|
+
const checkoutCommand = config.commitSha ? `cd ${sq(cloneTarget)} && git -c credential.helper= -c advice.detachedHead=false checkout ${sq(config.commitSha)}` : "";
|
|
2045
|
+
const prepareCommand = [
|
|
2046
|
+
"set -e",
|
|
2047
|
+
`rm -rf ${sq(joinWorkspacePath(cloneTarget, ".git"))}`,
|
|
2048
|
+
...prepareContextCommands,
|
|
2049
|
+
'echo "Dockerfile context prepared."'
|
|
2050
|
+
].join("\n");
|
|
2051
|
+
logger.log(`Preparing Dockerfile build workspace for repository clone (branch: ${config.branch})...
|
|
2052
|
+
`);
|
|
2053
|
+
await this.ensureWorkspaceGit(targetRuntime, logger, "Dockerfile build workspace");
|
|
2054
|
+
logger.log(`Cloning Dockerfile context in build workspace (branch: ${config.branch})...
|
|
2055
|
+
`);
|
|
2056
|
+
await this.execAndStream(targetRuntime, ["sh", "-c", cloneCommand], logger.callback, 900);
|
|
2057
|
+
if (checkoutCommand) {
|
|
2058
|
+
await this.execAndStream(targetRuntime, ["sh", "-c", checkoutCommand], logger.callback, 300);
|
|
2059
|
+
}
|
|
2060
|
+
await this.execAndStream(targetRuntime, ["sh", "-c", prepareCommand], logger.callback, 300);
|
|
2061
|
+
logger.log("Dockerfile context ready.\n");
|
|
2062
|
+
}
|
|
2063
|
+
async applyDockerfileRunStep(opts) {
|
|
2064
|
+
const { run, runtime, logger, env } = opts;
|
|
2065
|
+
const command = `mkdir -p ${sq(run.workdir)} && cd ${sq(run.workdir)} && ${envExportPrefix(env)}${run.command}`;
|
|
2066
|
+
logger.log(`[Dockerfile] RUN ${run.command}
|
|
2067
|
+
`);
|
|
2068
|
+
await this.execAndStream(runtime, ["sh", "-c", command], logger.callback);
|
|
2069
|
+
}
|
|
2070
|
+
async applyDockerfileCopyStep(opts) {
|
|
2071
|
+
const { copy, stageRefs, runtime, logger } = opts;
|
|
2072
|
+
let sourceBase = "/openship/context";
|
|
2073
|
+
let sourcePaths = copy.sources;
|
|
2074
|
+
let copyFromStage = false;
|
|
2075
|
+
if (copy.from) {
|
|
2076
|
+
const sourceStage = stageRefs.get(copy.from);
|
|
2077
|
+
if (!sourceStage) {
|
|
2078
|
+
throw new Error(
|
|
2079
|
+
`COPY --from=${copy.from} references an external image, current stage, later stage, or unknown stage. Cloud Dockerfile builds support --from only for previous stages in the same Dockerfile.`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
if (copy.sources.some(hasGlob)) {
|
|
2083
|
+
throw new Error(
|
|
2084
|
+
`COPY --from=${copy.from} uses glob sources (${copy.sources.join(", ")}), which are not supported in cloud Dockerfile stage artifact copies yet.`
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
const downloadPaths = copy.sources.map(stageArtifactDownloadPath);
|
|
2088
|
+
const sourceLabel = downloadPaths.join(" ");
|
|
2089
|
+
logger.log(`[Dockerfile] COPY artifacts from stage "${copy.from}": ${sourceLabel}
|
|
2090
|
+
`);
|
|
2091
|
+
copyFromStage = true;
|
|
2092
|
+
sourceBase = "";
|
|
2093
|
+
sourcePaths = downloadPaths;
|
|
2094
|
+
}
|
|
2095
|
+
const destination = resolveVmPath(copy.workdir, copy.destination);
|
|
2096
|
+
const destinationIsDir = copy.sources.length > 1 || copy.destination.endsWith("/") || copy.destination === "." || copy.destination.endsWith("/.");
|
|
2097
|
+
const destinationTarget = destinationIsDir ? destination : posix.dirname(destination);
|
|
2098
|
+
const sourceExprs = copyFromStage ? sourcePaths.map((source) => sq(source)) : sourcePaths.map((source) => sourceExpression(sourceBase, source));
|
|
2099
|
+
if (copyFromStage) {
|
|
2100
|
+
const samePathCopies = sourcePaths.map((source) => ({
|
|
2101
|
+
source: posix.normalize(source),
|
|
2102
|
+
target: posix.normalize(
|
|
2103
|
+
resolveCopyDestinationPath(copy, source, destination, destinationIsDir)
|
|
2104
|
+
)
|
|
2105
|
+
}));
|
|
2106
|
+
if (samePathCopies.every(({ source, target }) => source === target)) {
|
|
2107
|
+
const verifyCommand = samePathCopies.map(({ source }) => `test -e ${sq(source)}`).join(" && ");
|
|
2108
|
+
await this.execAndStream(runtime, ["sh", "-c", verifyCommand], logger.callback);
|
|
2109
|
+
logger.log("[Dockerfile] COPY artifacts already available in current workspace.\n");
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const command = [
|
|
2114
|
+
"set -e",
|
|
2115
|
+
`mkdir -p ${sq(destinationTarget)}`,
|
|
2116
|
+
...dockerCopyShellCommands({
|
|
2117
|
+
sources: sourceExprs,
|
|
2118
|
+
destination,
|
|
2119
|
+
destinationIsDir,
|
|
2120
|
+
destinationTarget
|
|
2121
|
+
})
|
|
2122
|
+
].join("\n");
|
|
2123
|
+
logger.log(
|
|
2124
|
+
`[Dockerfile] ${copy.kind.toUpperCase()} ${copy.sources.join(" ")} ${copy.destination}
|
|
2125
|
+
`
|
|
2126
|
+
);
|
|
2127
|
+
await this.execAndStream(runtime, ["sh", "-c", command], logger.callback);
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Provision a temporary cloud workspace for a build.
|
|
2131
|
+
*
|
|
2132
|
+
* This is the cloud-specific "prepare" phase:
|
|
2133
|
+
* 1. Create workspace (image + resources + env vars)
|
|
2134
|
+
* 2. Set TTL for auto-cleanup
|
|
2135
|
+
* 3. Acquire runtime token (enable API server + get JWT)
|
|
2136
|
+
*
|
|
2137
|
+
* Output streams to the terminal via logger so the user sees
|
|
2138
|
+
* progress before the numbered build steps begin.
|
|
2139
|
+
*/
|
|
2140
|
+
async provisionWorkspace(config, logger) {
|
|
2141
|
+
return provisionCloudWorkspace(this.client, config, logger);
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Folder-upload flow: attach to a workspace the browser already uploaded its
|
|
2145
|
+
* source into and acquire its runtime handle. Mirrors the connect-with-retry
|
|
2146
|
+
* in `provisionBuildWorkspace`, but creates nothing — the workspace already
|
|
2147
|
+
* exists (provisioned when the upload session was opened).
|
|
2148
|
+
*/
|
|
2149
|
+
async adoptWorkspaceRuntime(workspaceId) {
|
|
2150
|
+
const ws = this.ws(workspaceId);
|
|
2151
|
+
let rt;
|
|
2152
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2153
|
+
try {
|
|
2154
|
+
rt = await ws.runtime();
|
|
2155
|
+
break;
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
if (attempt < 2) await new Promise((r) => setTimeout(r, 2e3));
|
|
2158
|
+
else throw err;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
if (!rt) throw new Error("Failed to connect to uploaded workspace");
|
|
2162
|
+
return rt;
|
|
2163
|
+
}
|
|
2164
|
+
async provisionBuildWorkspace(config, logger) {
|
|
2165
|
+
logger.log("Provisioning build environment...\n");
|
|
2166
|
+
const envArray = Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`);
|
|
2167
|
+
logger.log(`Creating build workspace from image "${config.buildImage}"...
|
|
2168
|
+
`);
|
|
2169
|
+
let wsData;
|
|
2170
|
+
try {
|
|
2171
|
+
wsData = await this.client.workspaces.create({
|
|
2172
|
+
name: config.slug ?? `build-${config.projectId.slice(0, 20)}`,
|
|
2173
|
+
image: config.buildImage,
|
|
2174
|
+
mode: "temporary",
|
|
2175
|
+
config: {
|
|
2176
|
+
cpus: cloudCpus(config.resources.cpuCores),
|
|
2177
|
+
memory_mb: config.resources.memoryMb,
|
|
2178
|
+
disk_size_mb: config.resources.diskMb,
|
|
2179
|
+
env: envArray
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
} catch (err) {
|
|
2183
|
+
const message = formatCloudError(err);
|
|
2184
|
+
logger.log(
|
|
2185
|
+
`Failed to create build workspace from image "${config.buildImage}": ${message}
|
|
2186
|
+
`,
|
|
2187
|
+
"error"
|
|
2188
|
+
);
|
|
2189
|
+
throw err;
|
|
2190
|
+
}
|
|
2191
|
+
const ws = this.ws(wsData.id);
|
|
2192
|
+
try {
|
|
2193
|
+
try {
|
|
2194
|
+
await ws.lifecycle.makeTemporary({
|
|
2195
|
+
ttl: "15m",
|
|
2196
|
+
ttl_action: "remove",
|
|
2197
|
+
remove_on_exit: true
|
|
2198
|
+
});
|
|
2199
|
+
} catch {
|
|
2200
|
+
}
|
|
2201
|
+
logger.log("Connecting to build environment...\n");
|
|
2202
|
+
let rt;
|
|
2203
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2204
|
+
try {
|
|
2205
|
+
rt = await ws.runtime();
|
|
2206
|
+
break;
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
if (attempt < 2) {
|
|
2209
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
2210
|
+
} else {
|
|
2211
|
+
throw err;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
if (!rt) throw new Error("Failed to connect to build environment");
|
|
2216
|
+
logger.log("Build environment ready\n");
|
|
2217
|
+
return { workspaceId: wsData.id, runtime: rt };
|
|
2218
|
+
} catch (err) {
|
|
2219
|
+
await ws.delete().catch(() => {
|
|
2220
|
+
});
|
|
2221
|
+
const message = formatCloudError(err);
|
|
2222
|
+
logger.log(`Failed to prepare build workspace "${wsData.id}": ${message}
|
|
2223
|
+
`, "error");
|
|
2224
|
+
throw err;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
async cancelBuild(sessionId) {
|
|
2228
|
+
const activeBuild = this.activeBuilds.get(sessionId);
|
|
2229
|
+
if (!activeBuild) {
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
activeBuild.abort.abort();
|
|
2233
|
+
const workspaceIds = [...activeBuild.workspaceIds];
|
|
2234
|
+
await Promise.allSettled(
|
|
2235
|
+
workspaceIds.map(async (workspaceId) => {
|
|
2236
|
+
await this.destroy(workspaceId).catch(() => {
|
|
2237
|
+
});
|
|
2238
|
+
activeBuild.workspaceIds.delete(workspaceId);
|
|
2239
|
+
})
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
async getBuildLogs(sessionId) {
|
|
2243
|
+
void sessionId;
|
|
2244
|
+
return [];
|
|
2245
|
+
}
|
|
2246
|
+
// ── Deploy lifecycle ───────────────────────────────────────────────────
|
|
2247
|
+
async deploy(config, onLog) {
|
|
2248
|
+
const workspaceId = config.imageRef;
|
|
2249
|
+
if (!workspaceId) {
|
|
2250
|
+
return {
|
|
2251
|
+
deploymentId: config.deploymentId,
|
|
2252
|
+
status: "failed"
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const ws = this.ws(workspaceId);
|
|
2256
|
+
const log = onLog ?? (() => {
|
|
2257
|
+
});
|
|
2258
|
+
try {
|
|
2259
|
+
await ws.lifecycle.makePermanent();
|
|
2260
|
+
} catch (err) {
|
|
2261
|
+
throw new Error(
|
|
2262
|
+
`Failed to make workspace permanent: ${err instanceof Error ? err.message : err}`
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
const prodResources = config.resources ?? DEFAULT_RESOURCE_CONFIG;
|
|
2266
|
+
let resized = false;
|
|
2267
|
+
for (let attempt = 1; attempt <= 3 && !resized; attempt++) {
|
|
2268
|
+
try {
|
|
2269
|
+
await ws.resources.update({
|
|
2270
|
+
cpus: cloudCpus(prodResources.cpuCores),
|
|
2271
|
+
memory_mb: prodResources.memoryMb,
|
|
2272
|
+
apply: true
|
|
2273
|
+
});
|
|
2274
|
+
resized = true;
|
|
2275
|
+
log({
|
|
2276
|
+
timestamp: now2(),
|
|
2277
|
+
level: "info",
|
|
2278
|
+
message: `Resized workspace to ${prodResources.cpuCores} vCPU \xB7 ${prodResources.memoryMb} MB.
|
|
2279
|
+
`
|
|
2280
|
+
});
|
|
2281
|
+
} catch (err) {
|
|
2282
|
+
if (attempt === 3) {
|
|
2283
|
+
throw new Error(
|
|
2284
|
+
`Failed to shrink workspace from the build tier to ${prodResources.cpuCores} vCPU \xB7 ${prodResources.memoryMb} MB after 3 attempts \u2014 refusing to promote a build-sized workspace: ${safeErrorMessage(err)}`
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
log({
|
|
2288
|
+
timestamp: now2(),
|
|
2289
|
+
level: "warn",
|
|
2290
|
+
message: `Resize attempt ${attempt} failed, retrying\u2026 ${safeErrorMessage(err)}
|
|
2291
|
+
`
|
|
2292
|
+
});
|
|
2293
|
+
await new Promise((r) => setTimeout(r, 1e3 * attempt));
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
const builtArtifact = this.builtArtifacts.get(workspaceId);
|
|
2297
|
+
const prodPaths = config.productionPaths;
|
|
2298
|
+
const workDir = builtArtifact?.runtime.workdir ?? (prodPaths?.length ? "/app/production" : "/app");
|
|
2299
|
+
if (prodPaths?.length) {
|
|
2300
|
+
try {
|
|
2301
|
+
const rt = await ws.runtime();
|
|
2302
|
+
const logCb = onLog ?? (() => {
|
|
2303
|
+
});
|
|
2304
|
+
const safePaths = prodPaths.filter((p) => {
|
|
2305
|
+
const normalized = p.replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
|
2306
|
+
return normalized.length > 0 && normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../") && !normalized.includes("\\");
|
|
2307
|
+
});
|
|
2308
|
+
if (safePaths.length === 0) {
|
|
2309
|
+
throw new Error("No valid production paths after sanitization");
|
|
2310
|
+
}
|
|
2311
|
+
const sq2 = (s) => s.replace(/'/g, "'\\''");
|
|
2312
|
+
const moveLines = safePaths.map((p) => {
|
|
2313
|
+
const e = sq2(p);
|
|
2314
|
+
return `if [ -e '/app/${e}' ]; then
|
|
2315
|
+
d=$(dirname '${e}')
|
|
2316
|
+
mkdir -p "/app/.staging/$d"
|
|
2317
|
+
mv '/app/${e}' '/app/.staging/${e}'
|
|
2318
|
+
echo ' moved /app/${e}'
|
|
2319
|
+
else
|
|
2320
|
+
echo ' skip /app/${e} (not found)'
|
|
2321
|
+
fi`;
|
|
2322
|
+
}).join("\n");
|
|
2323
|
+
const script = `set -e
|
|
2324
|
+
cleanup() { echo "Cleaning up staging dir"; rm -rf /app/.staging; }
|
|
2325
|
+
echo "Preparing production directory..."
|
|
2326
|
+
rm -rf /app/.staging
|
|
2327
|
+
mkdir -p /app/.staging
|
|
2328
|
+
${moveLines}
|
|
2329
|
+
if [ "$(ls -A /app/.staging)" ]; then
|
|
2330
|
+
rm -rf /app/production
|
|
2331
|
+
mv /app/.staging /app/production
|
|
2332
|
+
echo "Production directory ready"
|
|
2333
|
+
else
|
|
2334
|
+
cleanup
|
|
2335
|
+
echo "ERROR: no files were moved - check production paths"
|
|
2336
|
+
exit 1
|
|
2337
|
+
fi`;
|
|
2338
|
+
await this.execAndStream(rt, ["sh", "-c", script], logCb);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
throw new Error(
|
|
2341
|
+
`Failed to prepare production directory: ${err instanceof Error ? err.message : err}`
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
const startCommand = builtArtifact?.runtime.startCommand || config.startCommand || "npm start";
|
|
2346
|
+
const envArray = toEnvArray2({
|
|
2347
|
+
...builtArtifact?.runtime.env ?? {},
|
|
2348
|
+
...config.envVars
|
|
2349
|
+
});
|
|
2350
|
+
const restartPolicy = config.restartPolicy === "no" ? "never" : config.restartPolicy ?? "always";
|
|
2351
|
+
try {
|
|
2352
|
+
await ws.workloads.create({
|
|
2353
|
+
name: "app",
|
|
2354
|
+
cmd: ["sh", "-c", `cd ${workDir} && ${startCommand}`],
|
|
2355
|
+
working_dir: workDir,
|
|
2356
|
+
env: [...envArray, `PORT=${config.port}`],
|
|
2357
|
+
restart_policy: restartPolicy,
|
|
2358
|
+
max_restarts: 10
|
|
2359
|
+
});
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
throw new Error(`Failed to create workload: ${err instanceof Error ? err.message : err}`);
|
|
2362
|
+
}
|
|
2363
|
+
let url;
|
|
2364
|
+
const primaryEndpoint = primaryPublicEndpoint(config);
|
|
2365
|
+
const primarySlug = endpointSlug(primaryEndpoint);
|
|
2366
|
+
const primaryCustomDomain = endpointCustomDomain(primaryEndpoint);
|
|
2367
|
+
const primaryPort = primaryEndpoint?.port ?? config.port;
|
|
2368
|
+
if (primaryCustomDomain) {
|
|
2369
|
+
try {
|
|
2370
|
+
await ws.network.update({ ingress_ports: [primaryPort] });
|
|
2371
|
+
await this.connectCustomDomainReassigning(
|
|
2372
|
+
workspaceId,
|
|
2373
|
+
primaryCustomDomain,
|
|
2374
|
+
primaryPort,
|
|
2375
|
+
log
|
|
2376
|
+
);
|
|
2377
|
+
url = `https://${primaryCustomDomain}`;
|
|
2378
|
+
} catch (err) {
|
|
2379
|
+
throw new Error(
|
|
2380
|
+
`Failed to connect custom domain ${primaryCustomDomain}: ${safeErrorMessage(err)}`
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
} else if (primarySlug) {
|
|
2384
|
+
try {
|
|
2385
|
+
log({
|
|
2386
|
+
timestamp: now2(),
|
|
2387
|
+
level: "info",
|
|
2388
|
+
message: `Exposing ${exposeTarget2(primaryPort, primarySlug)}...
|
|
2389
|
+
`
|
|
2390
|
+
});
|
|
2391
|
+
const exposeResult = await ws.publicAccess.expose({
|
|
2392
|
+
port: primaryPort,
|
|
2393
|
+
domain: SYSTEM.DOMAINS.CLOUD_DOMAIN,
|
|
2394
|
+
slug: primarySlug
|
|
2395
|
+
});
|
|
2396
|
+
url = exposeResult.url;
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
throw new Error(
|
|
2399
|
+
`Failed to expose ${exposeTarget2(primaryPort, primarySlug)}: ${safeErrorMessage(err)}`
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
} else {
|
|
2403
|
+
log({
|
|
2404
|
+
timestamp: now2(),
|
|
2405
|
+
level: "info",
|
|
2406
|
+
message: "No public endpoint configured; skipping cloud public exposure.\n"
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
return {
|
|
2410
|
+
deploymentId: config.deploymentId,
|
|
2411
|
+
containerId: workspaceId,
|
|
2412
|
+
url: url ?? void 0,
|
|
2413
|
+
status: "running"
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Deploy a static site via Oblien Pages.
|
|
2418
|
+
*
|
|
2419
|
+
* Flow:
|
|
2420
|
+
* 1. Create a page from the workspace build output → files copied to edge
|
|
2421
|
+
* 2. Page goes live immediately on CDN
|
|
2422
|
+
* 3. Delete the workspace - page is independent, no VM needed
|
|
2423
|
+
*
|
|
2424
|
+
* The "containerId" in the result is the page ID (for future updates/teardown).
|
|
2425
|
+
*/
|
|
2426
|
+
async deployStatic(config) {
|
|
2427
|
+
const workspaceId = config.imageRef;
|
|
2428
|
+
if (!workspaceId) {
|
|
2429
|
+
return { deploymentId: config.deploymentId, status: "failed" };
|
|
2430
|
+
}
|
|
2431
|
+
const primaryEndpoint = primaryPublicEndpoint(config);
|
|
2432
|
+
const primarySlug = endpointSlug(primaryEndpoint);
|
|
2433
|
+
const primaryCustomDomain = endpointCustomDomain(primaryEndpoint);
|
|
2434
|
+
const pageSlug = primarySlug ?? fallbackRuntimeName(config);
|
|
2435
|
+
const wantFree = !primaryCustomDomain && !!primarySlug;
|
|
2436
|
+
const outputDirError = () => new Error(missingOutputDirectoryMessage(config.outputDirectory, pageSlug));
|
|
2437
|
+
const isOutputPathError = (err) => {
|
|
2438
|
+
const msg = safeErrorMessage(err).toLowerCase();
|
|
2439
|
+
return msg.includes("path is required") || msg.includes("folder inside vm") || msg.includes("no such") || msg.includes("not found") || msg.includes("does not exist");
|
|
2440
|
+
};
|
|
2441
|
+
const trimmedOutput = config.outputDirectory?.trim();
|
|
2442
|
+
if (!trimmedOutput || trimmedOutput === "/") {
|
|
2443
|
+
throw outputDirError();
|
|
2444
|
+
}
|
|
2445
|
+
const outputPath = resolveStaticOutputPath("/app", config.outputDirectory);
|
|
2446
|
+
console.log(
|
|
2447
|
+
`Deploying static site from workspace ${workspaceId}, output path ${outputPath}...`
|
|
2448
|
+
);
|
|
2449
|
+
const createFresh = async () => {
|
|
2450
|
+
if (primaryCustomDomain) {
|
|
2451
|
+
let pg2;
|
|
2452
|
+
try {
|
|
2453
|
+
const result = await this.client.pages.create({
|
|
2454
|
+
workspace_id: workspaceId,
|
|
2455
|
+
path: outputPath,
|
|
2456
|
+
name: config.projectName ?? pageSlug,
|
|
2457
|
+
slug: pageSlug
|
|
2458
|
+
});
|
|
2459
|
+
pg2 = result.page;
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
if (isOutputPathError(err)) throw outputDirError();
|
|
2462
|
+
throw new Error(
|
|
2463
|
+
`Failed to create static page for slug "${pageSlug}" with custom domain "${primaryCustomDomain}": ${safeErrorMessage(err)}`
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
await this.client.pages.connectDomain(pg2.slug, { domain: primaryCustomDomain }).catch(() => {
|
|
2467
|
+
});
|
|
2468
|
+
return { ...pg2, url: pg2.url ?? `https://${primaryCustomDomain}` };
|
|
2469
|
+
}
|
|
2470
|
+
if (wantFree) {
|
|
2471
|
+
const createOnSharedZone = this.adminProxy?.createPage ?? ((input) => this.client.pages.create(input));
|
|
2472
|
+
try {
|
|
2473
|
+
const result = await createOnSharedZone({
|
|
2474
|
+
workspace_id: workspaceId,
|
|
2475
|
+
path: outputPath,
|
|
2476
|
+
name: config.projectName ?? pageSlug,
|
|
2477
|
+
slug: pageSlug,
|
|
2478
|
+
domain: SYSTEM.DOMAINS.CLOUD_DOMAIN
|
|
2479
|
+
});
|
|
2480
|
+
return result.page;
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
if (isOutputPathError(err)) throw outputDirError();
|
|
2483
|
+
throw new Error(
|
|
2484
|
+
`Failed to create static page for slug "${pageSlug}" (${pageSlug}.opsh.io): ${safeErrorMessage(err)}`
|
|
2485
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
let pg;
|
|
2489
|
+
try {
|
|
2490
|
+
const result = await this.client.pages.create({
|
|
2491
|
+
workspace_id: workspaceId,
|
|
2492
|
+
path: outputPath,
|
|
2493
|
+
name: config.projectName ?? pageSlug,
|
|
2494
|
+
slug: pageSlug
|
|
2495
|
+
});
|
|
2496
|
+
pg = result.page;
|
|
2497
|
+
} catch (err) {
|
|
2498
|
+
if (isOutputPathError(err)) throw outputDirError();
|
|
2499
|
+
throw new Error(`Failed to create static page for slug "${pageSlug}": ${safeErrorMessage(err)}`);
|
|
2500
|
+
}
|
|
2501
|
+
return { ...pg, url: void 0 };
|
|
2502
|
+
};
|
|
2503
|
+
let page;
|
|
2504
|
+
const existingPage = (await this.client.pages.get(pageSlug).catch(() => null))?.page ?? null;
|
|
2505
|
+
const bindingMatches = !!existingPage && (primaryCustomDomain ? existingPage.custom_domain === primaryCustomDomain : wantFree ? existingPage.domain === SYSTEM.DOMAINS.CLOUD_DOMAIN : true);
|
|
2506
|
+
if (existingPage && bindingMatches) {
|
|
2507
|
+
try {
|
|
2508
|
+
const result = await this.client.pages.deploy(pageSlug, {
|
|
2509
|
+
workspace_id: workspaceId,
|
|
2510
|
+
path: outputPath
|
|
2511
|
+
});
|
|
2512
|
+
page = result.page;
|
|
2513
|
+
} catch (err) {
|
|
2514
|
+
throw new Error(`Failed to redeploy static page for slug "${pageSlug}": ${safeErrorMessage(err)}`);
|
|
2515
|
+
}
|
|
2516
|
+
} else {
|
|
2517
|
+
if (existingPage) {
|
|
2518
|
+
await this.client.pages.delete(pageSlug).catch(() => {
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
page = await createFresh();
|
|
2522
|
+
}
|
|
2523
|
+
await this.ws(workspaceId).delete().catch(() => {
|
|
2524
|
+
});
|
|
2525
|
+
return {
|
|
2526
|
+
deploymentId: config.deploymentId,
|
|
2527
|
+
containerId: `${PAGE_CONTAINER_PREFIX}${page.slug}`,
|
|
2528
|
+
url: page.url ?? void 0,
|
|
2529
|
+
status: "running"
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
async stop(containerId) {
|
|
2533
|
+
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2534
|
+
const slug = containerId.slice(5);
|
|
2535
|
+
if (this.adminProxy?.disablePage) {
|
|
2536
|
+
await this.adminProxy.disablePage(slug);
|
|
2537
|
+
} else {
|
|
2538
|
+
await this.client.pages.disable(slug);
|
|
2539
|
+
}
|
|
2540
|
+
} else {
|
|
2541
|
+
await this.ws(containerId).stop();
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
async start(containerId) {
|
|
2545
|
+
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2546
|
+
const slug = containerId.slice(5);
|
|
2547
|
+
if (this.adminProxy?.enablePage) {
|
|
2548
|
+
await this.adminProxy.enablePage(slug);
|
|
2549
|
+
} else {
|
|
2550
|
+
await this.client.pages.enable(slug);
|
|
2551
|
+
}
|
|
2552
|
+
} else {
|
|
2553
|
+
await this.ws(containerId).start();
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
async restart(containerId) {
|
|
2557
|
+
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
await this.ws(containerId).restart();
|
|
2561
|
+
}
|
|
2562
|
+
async destroy(containerId) {
|
|
2563
|
+
try {
|
|
2564
|
+
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2565
|
+
const slug = containerId.slice(5);
|
|
2566
|
+
if (this.adminProxy?.deletePage) {
|
|
2567
|
+
await this.adminProxy.deletePage(slug);
|
|
2568
|
+
} else {
|
|
2569
|
+
await this.client.pages.delete(slug);
|
|
2570
|
+
}
|
|
2571
|
+
} else {
|
|
2572
|
+
await this.ws(containerId).delete();
|
|
2573
|
+
this.builtArtifacts.delete(containerId);
|
|
2574
|
+
}
|
|
2575
|
+
} catch (err) {
|
|
2576
|
+
if (isRuntimeNotFoundError(err)) {
|
|
2577
|
+
this.builtArtifacts.delete(containerId);
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
throw err;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
// ── Rollback primitives ──────────────────────────────────────────────
|
|
2584
|
+
//
|
|
2585
|
+
// Cloud semantics — internal to Oblien, no external object store:
|
|
2586
|
+
//
|
|
2587
|
+
// archive — `snapshots.createArchive` captures the workspace disk
|
|
2588
|
+
// as a point-in-time Oblien archive (file + metadata
|
|
2589
|
+
// stored next to the workspace), then `ws.stop` powers
|
|
2590
|
+
// down compute. Compute billing pauses; the workspace
|
|
2591
|
+
// slot + archive remain claimed.
|
|
2592
|
+
// makeActive — stops the outgoing workspace, starts the target.
|
|
2593
|
+
// Workspace IDs are stable across stop/start, so no new
|
|
2594
|
+
// containerId is minted.
|
|
2595
|
+
// purge — deletes every archive of the workspace (with their
|
|
2596
|
+
// files) then deletes the workspace itself. Past this
|
|
2597
|
+
// point rollback is impossible.
|
|
2598
|
+
//
|
|
2599
|
+
// Why archive PLUS stop, not archive INSTEAD OF the running workspace:
|
|
2600
|
+
// Oblien's archives are workspace-scoped (`/workspace/{id}/archives`).
|
|
2601
|
+
// Deleting the workspace orphans the archive — there's no
|
|
2602
|
+
// `workspaces.create({ from_archive })` to bring it back. So keeping
|
|
2603
|
+
// the workspace alive in stopped state is what makes rollback work,
|
|
2604
|
+
// and the explicit `createArchive` call gives us a defense-in-depth
|
|
2605
|
+
// snapshot we can restore over the same workspace if its disk drifts.
|
|
2606
|
+
//
|
|
2607
|
+
// Pages (static deployments, containerId starts with "page:") archive
|
|
2608
|
+
// via `pages.disable` and purge via `pages.delete`. No tar.gz involved.
|
|
2609
|
+
//
|
|
2610
|
+
// All three primitives are idempotent.
|
|
2611
|
+
async makeActive(input) {
|
|
2612
|
+
if (input.from?.containerId) {
|
|
2613
|
+
try {
|
|
2614
|
+
await this.stop(input.from.containerId);
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
if (!input.to.containerId) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`Cannot make deployment ${input.to.id} active: workspace is gone. Artifact has been purged.`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
await this.start(input.to.containerId);
|
|
2624
|
+
return { containerId: input.to.containerId };
|
|
2625
|
+
}
|
|
2626
|
+
async archive(deployment) {
|
|
2627
|
+
if (deployment.containerId?.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2628
|
+
const slug = deployment.containerId.slice(5);
|
|
2629
|
+
try {
|
|
2630
|
+
if (this.adminProxy?.disablePage) {
|
|
2631
|
+
await this.adminProxy.disablePage(slug);
|
|
2632
|
+
} else {
|
|
2633
|
+
await this.client.pages.disable(slug);
|
|
2634
|
+
}
|
|
2635
|
+
} catch {
|
|
2636
|
+
}
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
if (!deployment.containerId) return;
|
|
2640
|
+
const ws = this.ws(deployment.containerId);
|
|
2641
|
+
try {
|
|
2642
|
+
await ws.snapshots.createArchive({});
|
|
2643
|
+
} catch (err) {
|
|
2644
|
+
console.warn(
|
|
2645
|
+
`[CloudRuntime.archive] snapshots.createArchive failed for ${deployment.id}:`,
|
|
2646
|
+
err instanceof Error ? err.message : err
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
try {
|
|
2650
|
+
await this.stop(deployment.containerId);
|
|
2651
|
+
} catch {
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
async purge(deployment) {
|
|
2655
|
+
if (deployment.containerId?.startsWith(PAGE_CONTAINER_PREFIX)) {
|
|
2656
|
+
const slug = deployment.containerId.slice(5);
|
|
2657
|
+
try {
|
|
2658
|
+
if (this.adminProxy?.deletePage) {
|
|
2659
|
+
await this.adminProxy.deletePage(slug);
|
|
2660
|
+
} else {
|
|
2661
|
+
await this.client.pages.delete(slug);
|
|
2662
|
+
}
|
|
2663
|
+
} catch {
|
|
2664
|
+
}
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
if (!deployment.containerId) return;
|
|
2668
|
+
try {
|
|
2669
|
+
await this.ws(deployment.containerId).snapshots.deleteAllArchives({
|
|
2670
|
+
delete_files: true
|
|
2671
|
+
});
|
|
2672
|
+
} catch (err) {
|
|
2673
|
+
console.warn(
|
|
2674
|
+
`[CloudRuntime.purge] deleteAllArchives failed for ${deployment.id}:`,
|
|
2675
|
+
err instanceof Error ? err.message : err
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
await this.destroy(deployment.containerId);
|
|
2680
|
+
} catch {
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
// ── Observability ──────────────────────────────────────────────────────
|
|
2684
|
+
async getContainerInfo(containerId) {
|
|
2685
|
+
const ws = this.ws(containerId);
|
|
2686
|
+
let data;
|
|
2687
|
+
try {
|
|
2688
|
+
data = await ws.get();
|
|
2689
|
+
} catch (err) {
|
|
2690
|
+
if (isRuntimeNotFoundError(err)) {
|
|
2691
|
+
return { containerId, status: "missing" };
|
|
2692
|
+
}
|
|
2693
|
+
throw err;
|
|
2694
|
+
}
|
|
2695
|
+
const statusMap = {
|
|
2696
|
+
running: "running",
|
|
2697
|
+
stopped: "stopped",
|
|
2698
|
+
starting: "deploying",
|
|
2699
|
+
stopping: "stopped",
|
|
2700
|
+
creating: "building",
|
|
2701
|
+
error: "failed"
|
|
2702
|
+
};
|
|
2703
|
+
let status = statusMap[data.status] ?? "stopped";
|
|
2704
|
+
if (status === "running") {
|
|
2705
|
+
try {
|
|
2706
|
+
const workloads = await ws.workloads.list();
|
|
2707
|
+
if (workloads && workloads.length > 0) {
|
|
2708
|
+
const states = workloads.map(
|
|
2709
|
+
(w) => String(
|
|
2710
|
+
w.status ?? w.states ?? ""
|
|
2711
|
+
).toLowerCase()
|
|
2712
|
+
);
|
|
2713
|
+
if (states.some((s) => s.includes("run"))) status = "running";
|
|
2714
|
+
else if (states.some((s) => /(fail|exit|error|crash|dead)/.test(s))) status = "failed";
|
|
2715
|
+
else status = "deploying";
|
|
2716
|
+
}
|
|
2717
|
+
} catch {
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
let ip = data.ip;
|
|
2721
|
+
try {
|
|
2722
|
+
const raw = await ws.apiAccess.rawToken();
|
|
2723
|
+
if (raw?.ip) ip = raw.ip;
|
|
2724
|
+
} catch {
|
|
2725
|
+
}
|
|
2726
|
+
return { containerId, status, ip };
|
|
2727
|
+
}
|
|
2728
|
+
async getRuntimeLogs(containerId, tail) {
|
|
2729
|
+
try {
|
|
2730
|
+
const result = await this.ws(containerId).workloads.logs("app");
|
|
2731
|
+
const raw = result;
|
|
2732
|
+
if (typeof raw.logs === "string") {
|
|
2733
|
+
const logStr = raw.logs;
|
|
2734
|
+
return applyTail(
|
|
2735
|
+
logStr.split("\n").filter(Boolean).map((line) => {
|
|
2736
|
+
const match = line.match(/^\[([^\]]+)\]\s+(stdout|stderr):\s?(.*)/);
|
|
2737
|
+
if (match) {
|
|
2738
|
+
return {
|
|
2739
|
+
timestamp: match[1],
|
|
2740
|
+
message: match[3],
|
|
2741
|
+
level: match[2] === "stderr" ? "warn" : "info"
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
return { timestamp: now2(), message: line, level: "info" };
|
|
2745
|
+
}),
|
|
2746
|
+
tail
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
const lines = Array.isArray(raw.logs) ? raw.logs : Array.isArray(raw.entries) ? raw.entries : Array.isArray(result) ? result : [];
|
|
2750
|
+
if (lines.length === 0) return [];
|
|
2751
|
+
return applyTail(
|
|
2752
|
+
lines.map((line) => {
|
|
2753
|
+
if (typeof line === "string") {
|
|
2754
|
+
return { timestamp: now2(), message: line, level: "info" };
|
|
2755
|
+
}
|
|
2756
|
+
const entry = line;
|
|
2757
|
+
const message = entry.message ?? entry.data ?? String(line);
|
|
2758
|
+
return {
|
|
2759
|
+
timestamp: entry.timestamp ?? now2(),
|
|
2760
|
+
message,
|
|
2761
|
+
level: entry.stream === "stderr" ? "warn" : "info"
|
|
2762
|
+
};
|
|
2763
|
+
}).filter((e) => e.message),
|
|
2764
|
+
tail
|
|
2765
|
+
);
|
|
2766
|
+
} catch {
|
|
2767
|
+
const result = await this.ws(containerId).logs.get({
|
|
2768
|
+
source: "cmd",
|
|
2769
|
+
tail_lines: tail ?? 100
|
|
2770
|
+
});
|
|
2771
|
+
const lines = result.logs;
|
|
2772
|
+
if (!Array.isArray(lines)) return [];
|
|
2773
|
+
return applyTail(
|
|
2774
|
+
lines.map((line) => {
|
|
2775
|
+
if (typeof line === "string") {
|
|
2776
|
+
return { timestamp: now2(), message: line, level: "info" };
|
|
2777
|
+
}
|
|
2778
|
+
const entry = line;
|
|
2779
|
+
return {
|
|
2780
|
+
timestamp: entry.timestamp ?? now2(),
|
|
2781
|
+
message: entry.message ?? String(line),
|
|
2782
|
+
level: "info"
|
|
2783
|
+
};
|
|
2784
|
+
}),
|
|
2785
|
+
tail
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
async streamRuntimeLogs(containerId, onLog, opts) {
|
|
2790
|
+
let cancelled = false;
|
|
2791
|
+
const emitText = (text, level, timestamp) => {
|
|
2792
|
+
if (!text) return;
|
|
2793
|
+
const rawData = Buffer.from(text).toString("base64");
|
|
2794
|
+
onLog({ timestamp: timestamp ?? now2(), message: text, level, rawData });
|
|
2795
|
+
};
|
|
2796
|
+
const run = async () => {
|
|
2797
|
+
try {
|
|
2798
|
+
const replayTail = opts?.tail ?? 100;
|
|
2799
|
+
if (replayTail > 0) {
|
|
2800
|
+
try {
|
|
2801
|
+
const history = await this.getRuntimeLogs(containerId, replayTail);
|
|
2802
|
+
for (const entry of history) {
|
|
2803
|
+
if (cancelled) return;
|
|
2804
|
+
if (!entry.message) continue;
|
|
2805
|
+
const rawData = entry.rawData ?? Buffer.from(entry.message).toString("base64");
|
|
2806
|
+
onLog({ ...entry, rawData });
|
|
2807
|
+
}
|
|
2808
|
+
} catch {
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (cancelled) return;
|
|
2812
|
+
try {
|
|
2813
|
+
const stream = this.ws(containerId).workloads.logsStream("app");
|
|
2814
|
+
for await (const event of stream) {
|
|
2815
|
+
if (cancelled) break;
|
|
2816
|
+
const ev = event;
|
|
2817
|
+
const text = ev.message ?? event.data ?? "";
|
|
2818
|
+
emitText(text, event.stream === "stderr" ? "warn" : "info", event.timestamp);
|
|
2819
|
+
}
|
|
2820
|
+
} catch {
|
|
2821
|
+
if (cancelled) return;
|
|
2822
|
+
try {
|
|
2823
|
+
const stream = this.ws(containerId).logs.streamCmd({
|
|
2824
|
+
tail_lines: opts?.tail ?? 100
|
|
2825
|
+
});
|
|
2826
|
+
for await (const event of stream) {
|
|
2827
|
+
if (cancelled) break;
|
|
2828
|
+
emitText(event.message, "info", event.timestamp);
|
|
2829
|
+
}
|
|
2830
|
+
} catch {
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
} catch {
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
void run();
|
|
2837
|
+
return () => {
|
|
2838
|
+
cancelled = true;
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
async getUsage(containerId) {
|
|
2842
|
+
const result = await this.ws(containerId).metrics.stats();
|
|
2843
|
+
const stats = result;
|
|
2844
|
+
return {
|
|
2845
|
+
cpuPercent: stats.cpu_usage ?? 0,
|
|
2846
|
+
memoryMb: stats.memory_usage ?? 0,
|
|
2847
|
+
diskMb: stats.disk_usage ?? 0,
|
|
2848
|
+
networkRxBytes: stats.network?.rx ?? 0,
|
|
2849
|
+
networkTxBytes: stats.network?.tx ?? 0
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
// ── Network ────────────────────────────────────────────────────────────
|
|
2853
|
+
async getContainerIp(containerId) {
|
|
2854
|
+
const data = await this.ws(containerId).get();
|
|
2855
|
+
return data.ip ?? null;
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Open an interactive PTY shell inside an Oblien workspace. Powers
|
|
2859
|
+
* the in-dashboard service terminal — see apps/api/src/modules/service-terminal/.
|
|
2860
|
+
*
|
|
2861
|
+
* Two-step Oblien API:
|
|
2862
|
+
* 1. `rt.terminal.create({shell, cols, rows})` → registers a
|
|
2863
|
+
* terminal session in the workspace and returns its numeric id.
|
|
2864
|
+
* 2. `rt.ws()` → opens a multiplexed WebSocket carrying binary
|
|
2865
|
+
* stdin/stdout (prefixed with the terminal id byte) plus JSON
|
|
2866
|
+
* control frames (resize, exit).
|
|
2867
|
+
*
|
|
2868
|
+
* We bridge that WS into the same ShellSession contract as
|
|
2869
|
+
* SshExecutor and DockerRuntime so the upstream WS bridge in
|
|
2870
|
+
* service-terminal.controller.ts is identical across runtimes.
|
|
2871
|
+
*
|
|
2872
|
+
* Note on workspace id: for the cloud runtime, `containerId` IS the
|
|
2873
|
+
* workspace id (see how cloud.ts already passes containerId into
|
|
2874
|
+
* `this.ws(containerId)` throughout this file).
|
|
2875
|
+
*/
|
|
2876
|
+
async openServiceShell(containerId, opts) {
|
|
2877
|
+
const cols = clampShellWindow(opts?.cols, 80, 1, 1e3);
|
|
2878
|
+
const rows = clampShellWindow(opts?.rows, 24, 1, 500);
|
|
2879
|
+
const shellPath = "/bin/sh";
|
|
2880
|
+
const ws = this.ws(containerId);
|
|
2881
|
+
const rt = await ws.runtime();
|
|
2882
|
+
const session = await rt.terminal.create({
|
|
2883
|
+
shell: shellPath,
|
|
2884
|
+
cols,
|
|
2885
|
+
rows
|
|
2886
|
+
});
|
|
2887
|
+
const terminalId = String(session.id);
|
|
2888
|
+
const wsConn = rt.ws({ reconnect: false });
|
|
2889
|
+
const stdout = new PassThrough();
|
|
2890
|
+
const stderr = new PassThrough();
|
|
2891
|
+
const closeListeners = [];
|
|
2892
|
+
let closed = false;
|
|
2893
|
+
const fireClose = (code, signal) => {
|
|
2894
|
+
if (closed) return;
|
|
2895
|
+
closed = true;
|
|
2896
|
+
try {
|
|
2897
|
+
wsConn.close();
|
|
2898
|
+
} catch {
|
|
2899
|
+
}
|
|
2900
|
+
stdout.end();
|
|
2901
|
+
stderr.end();
|
|
2902
|
+
for (const cb of closeListeners) {
|
|
2903
|
+
try {
|
|
2904
|
+
cb(code, signal);
|
|
2905
|
+
} catch {
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
};
|
|
2909
|
+
wsConn.onTerminalOutput((id, data) => {
|
|
2910
|
+
if (id === terminalId) stdout.write(Buffer.from(data));
|
|
2911
|
+
});
|
|
2912
|
+
wsConn.onTerminalExit((id, code) => {
|
|
2913
|
+
if (id === terminalId) fireClose(code ?? null);
|
|
2914
|
+
});
|
|
2915
|
+
wsConn.onClose(() => fireClose(null));
|
|
2916
|
+
wsConn.onError(() => fireClose(null));
|
|
2917
|
+
wsConn.connect();
|
|
2918
|
+
const stdin = new Writable({
|
|
2919
|
+
write: (chunk, _enc, cb) => {
|
|
2920
|
+
try {
|
|
2921
|
+
wsConn.writeTerminalInput(
|
|
2922
|
+
terminalId,
|
|
2923
|
+
chunk instanceof Buffer ? new Uint8Array(chunk) : chunk
|
|
2924
|
+
);
|
|
2925
|
+
cb();
|
|
2926
|
+
} catch (err) {
|
|
2927
|
+
cb(err);
|
|
2928
|
+
}
|
|
2929
|
+
},
|
|
2930
|
+
final(cb) {
|
|
2931
|
+
try {
|
|
2932
|
+
wsConn.close();
|
|
2933
|
+
} catch {
|
|
2934
|
+
}
|
|
2935
|
+
cb();
|
|
2936
|
+
}
|
|
2937
|
+
});
|
|
2938
|
+
return {
|
|
2939
|
+
stdin,
|
|
2940
|
+
stdout,
|
|
2941
|
+
stderr,
|
|
2942
|
+
setWindow: (c, r) => {
|
|
2943
|
+
const sc = clampShellWindow(c, 80, 1, 1e3);
|
|
2944
|
+
const sr = clampShellWindow(r, 24, 1, 500);
|
|
2945
|
+
try {
|
|
2946
|
+
wsConn.resizeTerminal(terminalId, sc, sr);
|
|
2947
|
+
} catch {
|
|
2948
|
+
}
|
|
2949
|
+
},
|
|
2950
|
+
close: () => {
|
|
2951
|
+
rt.terminal.close(terminalId).catch(() => void 0).finally(() => fireClose(null));
|
|
2952
|
+
},
|
|
2953
|
+
onClose: (cb) => {
|
|
2954
|
+
closeListeners.push(cb);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
}
|
|
2958
|
+
// ── Compose / multi-service ────────────────────────────────────────────
|
|
2959
|
+
async ensureServiceGroup(config) {
|
|
2960
|
+
return this.compose.ensureServiceGroup(config);
|
|
2961
|
+
}
|
|
2962
|
+
async deployServiceWorkload(group, config, onLog) {
|
|
2963
|
+
return this.compose.deployServiceWorkload(group, config, onLog);
|
|
2964
|
+
}
|
|
2965
|
+
async finalizeServiceGroup(group, onLog) {
|
|
2966
|
+
return this.compose.finalizeGroup(group.id, onLog);
|
|
2967
|
+
}
|
|
2968
|
+
registerExistingWorkload(group, service) {
|
|
2969
|
+
this.compose.registerExistingWorkload(group, service);
|
|
2970
|
+
}
|
|
2971
|
+
// ── Account ────────────────────────────────────────────────────────────
|
|
2972
|
+
/** Check cloud credentials and account status. Throws on failure. */
|
|
2973
|
+
async getQuota() {
|
|
2974
|
+
return this.client.workspaces.getQuota();
|
|
2975
|
+
}
|
|
2976
|
+
// ── Domain / Slug checks ───────────────────────────────────────────────
|
|
2977
|
+
/**
|
|
2978
|
+
* Check whether a subdomain slug is available on opsh.io.
|
|
2979
|
+
* Uses Oblien's standalone `domain.checkSlug()` - no workspace needed.
|
|
2980
|
+
*/
|
|
2981
|
+
async checkSlug(slug, domain = SYSTEM.DOMAINS.CLOUD_DOMAIN) {
|
|
2982
|
+
const result = await this.client.domain.checkSlug({ slug, domain });
|
|
2983
|
+
return { available: result.available, url: result.url };
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Verify DNS records for a custom domain.
|
|
2987
|
+
* Uses Oblien's standalone `domain.verify()` - no workspace needed.
|
|
2988
|
+
*/
|
|
2989
|
+
async verifyDomain(domain, resourceId) {
|
|
2990
|
+
const result = await this.client.domain.verify({ domain, resource_id: resourceId });
|
|
2991
|
+
return {
|
|
2992
|
+
verified: result.verified,
|
|
2993
|
+
cname: result.cname,
|
|
2994
|
+
ownership: result.ownership,
|
|
2995
|
+
errors: result.errors,
|
|
2996
|
+
requiredRecords: {
|
|
2997
|
+
cname: result.required_records.cname,
|
|
2998
|
+
txt: result.required_records.txt
|
|
2999
|
+
}
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
// ── Edge routing ───────────────────────────────────────────────────────
|
|
3003
|
+
/**
|
|
3004
|
+
* Atomically set a hostname's edge routing table — the CLOUD counterpart to
|
|
3005
|
+
* the self-hosted OpenResty route registration. Compile a project's
|
|
3006
|
+
* `RoutingConfig` with `compileRoutingToOblien` (static Page at `/`, backend
|
|
3007
|
+
* workspace proxied at `/api/*`, redirects/headers), then hand the result
|
|
3008
|
+
* here. Versioned + applied with no redeploy, so it also backs live edits
|
|
3009
|
+
* from the Routing/Domains tab.
|
|
3010
|
+
*/
|
|
3011
|
+
async setDomainRoutes(hostname, input) {
|
|
3012
|
+
return this.client.routes.set(hostname, input);
|
|
3013
|
+
}
|
|
3014
|
+
// ── Private helpers ────────────────────────────────────────────────────
|
|
3015
|
+
/** Command runner INSIDE the workspace named by `containerId` (the stored
|
|
3016
|
+
* workspace id) — reuses the same runtime-token path as `openServiceShell`.
|
|
3017
|
+
* Powers the advisory post-deploy port probe. */
|
|
3018
|
+
async inContainerExecutor(containerId) {
|
|
3019
|
+
const rt = await this.ws(containerId).runtime();
|
|
3020
|
+
return this.workspaceExecutor(rt);
|
|
3021
|
+
}
|
|
3022
|
+
workspaceExecutor(rt) {
|
|
3023
|
+
const run = async (command, onLog, timeoutMs) => {
|
|
3024
|
+
let output = "";
|
|
3025
|
+
const collect = (entry) => {
|
|
3026
|
+
output += entry.message;
|
|
3027
|
+
onLog?.(entry);
|
|
3028
|
+
};
|
|
3029
|
+
try {
|
|
3030
|
+
await this.execAndStream(
|
|
3031
|
+
rt,
|
|
3032
|
+
["sh", "-c", command],
|
|
3033
|
+
collect,
|
|
3034
|
+
timeoutMs ? Math.ceil(timeoutMs / 1e3) : void 0
|
|
3035
|
+
);
|
|
3036
|
+
return { code: 0, output };
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
const message = safeErrorMessage(err);
|
|
3039
|
+
return { code: 1, output: output || message };
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
return {
|
|
3043
|
+
exec: async (command, opts) => {
|
|
3044
|
+
const result = await run(command, void 0, opts?.timeout);
|
|
3045
|
+
if (result.code !== 0) {
|
|
3046
|
+
throw new Error(result.output);
|
|
3047
|
+
}
|
|
3048
|
+
return result.output;
|
|
3049
|
+
},
|
|
3050
|
+
streamExec: async (command, onLog) => run(command, onLog, WORKSPACE_STREAM_EXEC_TIMEOUT_MS),
|
|
3051
|
+
writeFile: async () => {
|
|
3052
|
+
throw new Error("Workspace file writes are not supported by this executor");
|
|
3053
|
+
},
|
|
3054
|
+
readFile: async () => {
|
|
3055
|
+
throw new Error("Workspace file reads are not supported by this executor");
|
|
3056
|
+
},
|
|
3057
|
+
exists: async () => {
|
|
3058
|
+
throw new Error("Workspace file checks are not supported by this executor");
|
|
3059
|
+
},
|
|
3060
|
+
mkdir: async () => {
|
|
3061
|
+
throw new Error("Workspace mkdir is not supported by this executor");
|
|
3062
|
+
},
|
|
3063
|
+
rm: async () => {
|
|
3064
|
+
throw new Error("Workspace rm is not supported by this executor");
|
|
3065
|
+
},
|
|
3066
|
+
transferIn: async () => {
|
|
3067
|
+
throw new Error("Workspace transferIn is not supported by this executor");
|
|
3068
|
+
},
|
|
3069
|
+
dispose: async () => {
|
|
3070
|
+
}
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
async ensureWorkspaceGit(rt, logger, label = "workspace") {
|
|
3074
|
+
const executor = this.workspaceExecutor(rt);
|
|
3075
|
+
const git = await checkGit(executor);
|
|
3076
|
+
if (git.healthy) {
|
|
3077
|
+
logger.log(`Git ready in ${label}.
|
|
3078
|
+
`);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const result = await installGit(executor, logger.callback, { label });
|
|
3082
|
+
if (!result.success) {
|
|
3083
|
+
const rechecked = await checkGit(executor);
|
|
3084
|
+
if (rechecked.healthy) {
|
|
3085
|
+
logger.log(`Git ready in ${label}.
|
|
3086
|
+
`);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
throw new Error(result.error ?? rechecked.message ?? "Git installation failed");
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
/**
|
|
3093
|
+
* Execute a command via the runtime exec API and stream output to the log callback.
|
|
3094
|
+
* Oblien returns stdout/stderr as native base64 - we pass it through directly
|
|
3095
|
+
* via rawData so session-manager forwards it to the frontend without re-encoding.
|
|
3096
|
+
* message is set to decoded text for DB storage / display.
|
|
3097
|
+
* Throws on non-zero exit code.
|
|
3098
|
+
*/
|
|
3099
|
+
async execAndStream(rt, cmd, onLog, timeoutSeconds) {
|
|
3100
|
+
const params = timeoutSeconds ? { timeoutSeconds } : void 0;
|
|
3101
|
+
const stream = rt.exec.stream(cmd, params);
|
|
3102
|
+
const recentOutput = [];
|
|
3103
|
+
const MAX_OUTPUT_LINES = 50;
|
|
3104
|
+
const emit = (b64, level) => {
|
|
3105
|
+
const message = Buffer.from(b64, "base64").toString("utf-8");
|
|
3106
|
+
onLog({ timestamp: now2(), message, level, rawData: b64 });
|
|
3107
|
+
recentOutput.push(message);
|
|
3108
|
+
if (recentOutput.length > MAX_OUTPUT_LINES) recentOutput.shift();
|
|
3109
|
+
};
|
|
3110
|
+
let exitCode;
|
|
3111
|
+
for await (const event of stream) {
|
|
3112
|
+
switch (event.event) {
|
|
3113
|
+
case "stdout":
|
|
3114
|
+
emit(event.data, "info");
|
|
3115
|
+
break;
|
|
3116
|
+
case "stderr":
|
|
3117
|
+
emit(event.data, "warn");
|
|
3118
|
+
break;
|
|
3119
|
+
case "exit":
|
|
3120
|
+
exitCode = event.exit_code;
|
|
3121
|
+
break;
|
|
3122
|
+
case "output":
|
|
3123
|
+
if (event.stdout) emit(event.stdout, "info");
|
|
3124
|
+
if (event.stderr) emit(event.stderr, "warn");
|
|
3125
|
+
break;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
if (exitCode !== void 0 && exitCode !== 0) {
|
|
3129
|
+
const output = recentOutput.join("").trim();
|
|
3130
|
+
const summary = summarizeCommandOutput(output);
|
|
3131
|
+
const detail = summary ? `: ${summary}` : "";
|
|
3132
|
+
throw new Error(`Command failed with exit code ${exitCode}${detail}`);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
function clampShellWindow(value, fallback, min, max) {
|
|
3137
|
+
const n = Number.isFinite(value) ? Number(value) : fallback;
|
|
3138
|
+
if (n < min) return min;
|
|
3139
|
+
if (n > max) return max;
|
|
3140
|
+
return Math.floor(n);
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
export {
|
|
3144
|
+
PAGE_CONTAINER_PREFIX,
|
|
3145
|
+
provisionCloudWorkspace,
|
|
3146
|
+
CloudRuntime
|
|
3147
|
+
};
|