@django-stack/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1799 -559
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/{scaffold-DbgGYEsc.js → production-DIj9yRcg.js} +93 -66
- package/package.json +1 -1
- package/templates/backend/config/studio.py +9 -0
- package/templates/backend/content/admin.py +16 -2
- package/templates/backend/entrypoint.sh +5 -1
- package/templates/backend/pyproject.toml +2 -2
- package/templates/base/AGENTS.md +2 -1
- package/templates/base/Caddyfile +14 -1
- package/templates/base/README.md +9 -0
- package/templates/base/compose.yaml +39 -1
- package/templates/base/env.example +15 -1
- package/templates/frontend-astro/astro.config.mjs +13 -6
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/src/layouts/Base.astro +3 -0
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/src/app/layout.tsx +9 -0
- package/templates/frontend-next/src/proxy.ts +6 -0
- package/templates/frontend-nuxt/nuxt.config.ts +12 -6
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +6 -1
- package/templates/frontend-nuxt/src/layouts/default.vue +3 -0
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +2 -0
- package/templates/frontend-sveltekit/src/routes/+layout.svelte +3 -0
- package/templates/frontend-sveltekit/vite.config.ts +11 -3
package/dist/bin.js
CHANGED
|
@@ -1,360 +1,410 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { D as
|
|
2
|
+
import { C as stackOption, D as checkTools, E as runDoctor, O as parseEnv, S as resolvePreset, T as generateSecret, _ as STACK_OPTIONS, a as lockBackend, b as encodePreset, c as scaffold, d as umamiEnv, f as validateProjectName, g as NAMED_PRESETS, h as DEFAULT_STACK, i as LOCAL_S3, k as setEnvValues, l as timeZones, m as CLI_VERSION, n as validateDomain, p as validateTimeZone, s as relativeDirectory, t as productionEnv$1, u as toProjectName, v as applies, w as generateProjectSecrets, x as pendingChoices, y as decodePreset } from "./production-DIj9yRcg.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
6
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import tab from "@bomb.sh/tab/commander";
|
|
9
9
|
import * as p from "@clack/prompts";
|
|
10
10
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
// src/deploy/
|
|
13
|
-
/**
|
|
14
|
-
|
|
11
|
+
import { homedir, tmpdir } from "node:os";
|
|
12
|
+
// src/deploy/bunny-compose.ts
|
|
13
|
+
/** Volumes that are not needed on Bunny: its CDN holds the certificate, uploads and backups go to S3. */
|
|
14
|
+
const DROPPED_VOLUMES = /* @__PURE__ */ new Set([
|
|
15
|
+
"caddy_data",
|
|
16
|
+
"caddy_config",
|
|
17
|
+
"backups",
|
|
18
|
+
"media"
|
|
19
|
+
]);
|
|
20
|
+
/** Compose keys that do not apply on Bunny: its gVisor sandbox isolates the app instead. */
|
|
21
|
+
const IGNORED_KEYS = [
|
|
22
|
+
"read_only",
|
|
23
|
+
"tmpfs",
|
|
24
|
+
"cap_add",
|
|
25
|
+
"cap_drop",
|
|
26
|
+
"security_opt",
|
|
27
|
+
"user",
|
|
28
|
+
"deploy",
|
|
29
|
+
"restart",
|
|
30
|
+
"depends_on"
|
|
31
|
+
];
|
|
32
|
+
/** Images whose port is set with PORT, so a clash with another service can move them. */
|
|
33
|
+
const PORT_ENV_IMAGES = [/(^|\/)umami(-software)?\/umami/i, /^umamisoftware\/umami/i];
|
|
34
|
+
/** Parses a Compose duration such as 5s, 1m0s or 500ms into seconds. */
|
|
35
|
+
function durationSeconds(value, fallback) {
|
|
36
|
+
if (!value) return fallback;
|
|
37
|
+
let total = 0;
|
|
38
|
+
let matched = false;
|
|
39
|
+
for (const [, amount, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ms|us|ns|h|m|s)/g)) {
|
|
40
|
+
matched = true;
|
|
41
|
+
const number = Number(amount);
|
|
42
|
+
total += unit === "h" ? number * 3600 : unit === "m" ? number * 60 : unit === "s" ? number : unit === "ms" ? number / 1e3 : 0;
|
|
43
|
+
}
|
|
44
|
+
return matched ? total : fallback;
|
|
45
|
+
}
|
|
46
|
+
/** Reads a public image reference: postgres:17-alpine, valkey/valkey:8, ghcr.io/owner/name:tag. */
|
|
47
|
+
function parsePublicImage(reference) {
|
|
48
|
+
let rest = reference.trim();
|
|
49
|
+
let digest;
|
|
50
|
+
const at = rest.indexOf("@");
|
|
51
|
+
if (at >= 0) {
|
|
52
|
+
digest = rest.slice(at + 1);
|
|
53
|
+
rest = rest.slice(0, at);
|
|
54
|
+
}
|
|
55
|
+
let source = "dockerHub";
|
|
56
|
+
const slash = rest.indexOf("/");
|
|
57
|
+
const first = slash >= 0 ? rest.slice(0, slash) : "";
|
|
58
|
+
if (first && (first.includes(".") || first.includes(":") || first === "localhost")) {
|
|
59
|
+
if (first === "docker.io" || first === "index.docker.io") source = "dockerHub";
|
|
60
|
+
else if (first === "ghcr.io") source = "gitHub";
|
|
61
|
+
else throw new Error(`Bunny pulls public images from Docker Hub and GitHub only; ${reference} is on ${first}. Push it to one of them, or build it in compose.yaml.`);
|
|
62
|
+
rest = rest.slice(slash + 1);
|
|
63
|
+
}
|
|
64
|
+
const colon = rest.lastIndexOf(":");
|
|
65
|
+
const tag = colon > rest.lastIndexOf("/") ? rest.slice(colon + 1) : "latest";
|
|
66
|
+
const path = colon > rest.lastIndexOf("/") ? rest.slice(0, colon) : rest;
|
|
67
|
+
const parts = path.split("/");
|
|
68
|
+
const name = parts.pop() ?? path;
|
|
69
|
+
const namespace = parts.join("/") || (source === "dockerHub" ? "library" : "");
|
|
70
|
+
if (!name || !namespace) throw new Error(`Cannot read the image ${reference}.`);
|
|
15
71
|
return {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
72
|
+
source,
|
|
73
|
+
namespace,
|
|
74
|
+
name,
|
|
75
|
+
tag,
|
|
76
|
+
...digest ? { digest } : {}
|
|
19
77
|
};
|
|
20
78
|
}
|
|
21
|
-
/**
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
79
|
+
/** A repository name for Bunny Container Registry. */
|
|
80
|
+
function repositoryName(value) {
|
|
81
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "app";
|
|
82
|
+
}
|
|
83
|
+
function escapeRegExp(value) {
|
|
84
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
85
|
+
}
|
|
86
|
+
/** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
|
|
87
|
+
function hostPattern(name) {
|
|
88
|
+
return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
|
|
89
|
+
}
|
|
90
|
+
/** The names other services reach a service by: its name and its network aliases. */
|
|
91
|
+
function hostNames(service, definition) {
|
|
92
|
+
const names = /* @__PURE__ */ new Set([service]);
|
|
93
|
+
for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
|
|
94
|
+
return [...names];
|
|
95
|
+
}
|
|
96
|
+
/** Replaces service host names with 127.0.0.1, which is the pod on Bunny. */
|
|
97
|
+
function rewriteHosts(text, names) {
|
|
98
|
+
let result = text;
|
|
99
|
+
for (const name of names) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
function containerName(service, taken) {
|
|
103
|
+
let name = service.replace(/-prod$/, "").slice(0, 50) || service;
|
|
104
|
+
for (let index = 2; taken.has(name); index++) name = `${service.slice(0, 46)}-${index}`;
|
|
105
|
+
taken.add(name);
|
|
106
|
+
return name;
|
|
107
|
+
}
|
|
108
|
+
function healthUrl(test) {
|
|
109
|
+
const match = (test ?? []).join(" ").match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::(\d+))?(\/[^\s'"]*)?/);
|
|
110
|
+
if (!match) return void 0;
|
|
111
|
+
return {
|
|
112
|
+
port: Number(match[1] ?? 80),
|
|
113
|
+
path: match[2] ?? "/"
|
|
114
|
+
};
|
|
32
115
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
function probeFor(definition, ports) {
|
|
117
|
+
const check = definition.healthcheck;
|
|
118
|
+
if (check?.disable) return void 0;
|
|
119
|
+
const url = check?.test ? healthUrl(check.test) : void 0;
|
|
120
|
+
const port = url?.port ?? ports[0];
|
|
121
|
+
if (port === void 0) return void 0;
|
|
122
|
+
const period = Math.max(1, Math.round(durationSeconds(check?.interval, 10)));
|
|
123
|
+
const timeout = Math.max(1, Math.round(durationSeconds(check?.timeout, 5)));
|
|
124
|
+
const retries = Math.max(1, check?.retries ?? 3);
|
|
125
|
+
const start = durationSeconds(check?.start_period, 0);
|
|
126
|
+
return {
|
|
127
|
+
...url ? { http: {
|
|
128
|
+
path: url.path,
|
|
129
|
+
port
|
|
130
|
+
} } : { tcp: { port } },
|
|
131
|
+
periodSeconds: period,
|
|
132
|
+
timeoutSeconds: Math.min(timeout, period),
|
|
133
|
+
failureThreshold: Math.min(3, retries),
|
|
134
|
+
startupThreshold: Math.min(1e3, Math.ceil(start / period) + retries)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Plans the Bunny app for a resolved Compose project. */
|
|
138
|
+
function planApp(project, options) {
|
|
139
|
+
const entries = Object.entries(project.services).sort(([a], [b]) => a.localeCompare(b));
|
|
140
|
+
if (!entries.length) throw new Error("compose.yaml has no services for production.");
|
|
141
|
+
const names = /* @__PURE__ */ new Map();
|
|
142
|
+
for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
|
|
143
|
+
const allNames = [...names.values()].flat();
|
|
144
|
+
const builds = /* @__PURE__ */ new Map();
|
|
145
|
+
const baked = /* @__PURE__ */ new Map();
|
|
146
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
147
|
+
const volumes = /* @__PURE__ */ new Set();
|
|
148
|
+
const texts = [];
|
|
149
|
+
for (const [, definition] of entries) for (const value of Object.values(definition.environment ?? {})) if (value) texts.push(value);
|
|
150
|
+
for (const [service, definition] of entries) {
|
|
151
|
+
const binds = (definition.volumes ?? []).filter((mount) => mount.type === "bind");
|
|
152
|
+
if (!binds.length) continue;
|
|
153
|
+
if (definition.build || !definition.image) throw new Error(`${service} bind-mounts ${binds[0]?.source}; Bunny has no bind mounts. Copy the file into the image in its Dockerfile.`);
|
|
154
|
+
const files = binds.map((mount) => {
|
|
155
|
+
let content;
|
|
156
|
+
try {
|
|
157
|
+
content = options.readFile(mount.source ?? "");
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`${service} bind-mounts ${mount.source}, which is not a file; Bunny has no bind mounts, so only files can be copied into the image.`);
|
|
160
|
+
}
|
|
161
|
+
texts.push(content);
|
|
162
|
+
return {
|
|
163
|
+
source: mount.source ?? "",
|
|
164
|
+
target: mount.target,
|
|
165
|
+
content
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const repository = repositoryName(`${project.name}-${service.replace(/-prod$/, "")}`);
|
|
169
|
+
builds.set(repository, {
|
|
170
|
+
kind: "bake",
|
|
171
|
+
repository,
|
|
172
|
+
from: definition.image,
|
|
173
|
+
files
|
|
174
|
+
});
|
|
175
|
+
baked.set(service, repository);
|
|
68
176
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.fetchImpl = fetchImpl;
|
|
177
|
+
const portsOf = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const [service, definition] of entries) {
|
|
179
|
+
const found = /* @__PURE__ */ new Set();
|
|
180
|
+
for (const name of names.get(service) ?? []) for (const text of texts) for (const match of text.matchAll(hostPattern(name))) found.add(Number(match[2]));
|
|
181
|
+
const own = definition.healthcheck?.test ? healthUrl(definition.healthcheck.test) : void 0;
|
|
182
|
+
if (own) found.add(own.port);
|
|
183
|
+
for (const port of definition.ports ?? []) found.add(port.target);
|
|
184
|
+
portsOf.set(service, [...found].sort((a, b) => a - b));
|
|
78
185
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
186
|
+
const owner = /* @__PURE__ */ new Map();
|
|
187
|
+
const moved = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const [service, definition] of entries) {
|
|
189
|
+
const ports = portsOf.get(service) ?? [];
|
|
190
|
+
for (const [index, port] of ports.entries()) {
|
|
191
|
+
const other = owner.get(port);
|
|
192
|
+
if (!other) {
|
|
193
|
+
owner.set(port, service);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!(ports.length === 1 && PORT_ENV_IMAGES.some((pattern) => pattern.test(definition.image ?? "")))) throw new Error(`${other} and ${service} both listen on port ${port}. Bunny runs all containers of an app in one pod, so give one of them another port.`);
|
|
197
|
+
let to = port + 1;
|
|
198
|
+
while (owner.has(to)) to++;
|
|
199
|
+
owner.set(to, service);
|
|
200
|
+
ports[index] = to;
|
|
201
|
+
moved.set(service, {
|
|
202
|
+
from: port,
|
|
203
|
+
to
|
|
91
204
|
});
|
|
92
|
-
} catch (error) {
|
|
93
|
-
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
94
205
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
206
|
+
}
|
|
207
|
+
const published = entries.filter(([, definition]) => (definition.ports ?? []).length);
|
|
208
|
+
if (published.length !== 1) throw new Error(published.length ? `${published.map(([service]) => service).join(" and ")} publish ports; Bunny gives an app one public endpoint, so only the proxy may.` : "No service publishes a port, so there is nothing for Bunny's CDN to send requests to.");
|
|
209
|
+
const [entryService, entryDefinition] = published[0];
|
|
210
|
+
const entryPorts = (entryDefinition.ports ?? []).map((port) => port.target);
|
|
211
|
+
const endpointPort = entryPorts.includes(80) ? 80 : entryPorts[0];
|
|
212
|
+
const rewrite = (text) => {
|
|
213
|
+
let result = text;
|
|
214
|
+
for (const [service, move] of moved) for (const name of names.get(service) ?? []) result = result.replace(hostPattern(name), (whole, before, port) => Number(port) === move.from ? `${before}${name}:${move.to}` : whole);
|
|
215
|
+
return rewriteHosts(result, allNames);
|
|
216
|
+
};
|
|
217
|
+
for (const build of builds.values()) if (build.kind === "bake") for (const file of build.files) file.content = rewrite(file.content);
|
|
218
|
+
const s3 = entries.some(([, definition]) => Boolean(definition.environment?.S3_BUCKET));
|
|
219
|
+
const taken = /* @__PURE__ */ new Set();
|
|
220
|
+
const containers = [];
|
|
221
|
+
for (const [service, definition] of entries) {
|
|
222
|
+
for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
|
|
223
|
+
let image;
|
|
224
|
+
const bake = baked.get(service);
|
|
225
|
+
if (bake) image = {
|
|
226
|
+
source: "push",
|
|
227
|
+
repository: bake
|
|
228
|
+
};
|
|
229
|
+
else if (definition.build) {
|
|
230
|
+
const repository = repositoryName(definition.image ?? `${project.name}-${service}`);
|
|
231
|
+
const existing = builds.get(repository);
|
|
232
|
+
const build = {
|
|
233
|
+
kind: "build",
|
|
234
|
+
repository,
|
|
235
|
+
context: definition.build.context,
|
|
236
|
+
dockerfile: definition.build.dockerfile,
|
|
237
|
+
target: definition.build.target,
|
|
238
|
+
args: definition.build.args
|
|
239
|
+
};
|
|
240
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(build)) throw new Error(`Two services build the image ${repository} differently; give them their own image names.`);
|
|
241
|
+
builds.set(repository, build);
|
|
242
|
+
image = {
|
|
243
|
+
source: "push",
|
|
244
|
+
repository
|
|
245
|
+
};
|
|
246
|
+
} else if (definition.image) image = parsePublicImage(definition.image);
|
|
247
|
+
else throw new Error(`${service} has neither an image nor a build.`);
|
|
248
|
+
const env = {};
|
|
249
|
+
for (const [key, value] of Object.entries(definition.environment ?? {})) {
|
|
250
|
+
if (value === null || value === void 0 || key === "COMPOSE_PROFILES" || key === "ENV_FILE") continue;
|
|
251
|
+
env[key] = rewrite(value);
|
|
252
|
+
}
|
|
253
|
+
const move = moved.get(service);
|
|
254
|
+
if (move) env.PORT = String(move.to);
|
|
255
|
+
const volumeMounts = [];
|
|
256
|
+
for (const mount of definition.volumes ?? []) {
|
|
257
|
+
if (mount.type === "tmpfs") continue;
|
|
258
|
+
if (mount.type !== "volume" || !mount.source) {
|
|
259
|
+
if (mount.type === "bind") continue;
|
|
260
|
+
throw new Error(`${service} mounts a ${mount.type} volume, which Bunny does not have.`);
|
|
261
|
+
}
|
|
262
|
+
if (mount.source === "media" && !s3) throw new Error("Uploads must be in S3 on Bunny: its volumes belong to one pod and are not backed up. Set S3_BUCKET and the other S3_ variables in .env.production (see the docs on media).");
|
|
263
|
+
if (DROPPED_VOLUMES.has(mount.source)) continue;
|
|
264
|
+
volumes.add(mount.source);
|
|
265
|
+
volumeMounts.push({
|
|
266
|
+
name: mount.source,
|
|
267
|
+
mountPath: mount.target
|
|
268
|
+
});
|
|
269
|
+
if (mount.target === "/var/lib/postgresql/data" && !env.PGDATA) env.PGDATA = `${mount.target}/pgdata`;
|
|
102
270
|
}
|
|
271
|
+
const ports = portsOf.get(service) ?? [];
|
|
272
|
+
containers.push({
|
|
273
|
+
service,
|
|
274
|
+
name: containerName(service, taken),
|
|
275
|
+
image,
|
|
276
|
+
env,
|
|
277
|
+
...definition.entrypoint?.length ? { command: definition.entrypoint } : {},
|
|
278
|
+
...definition.command?.length ? { args: definition.command } : {},
|
|
279
|
+
...definition.working_dir ? { workingDir: definition.working_dir } : {},
|
|
280
|
+
ports,
|
|
281
|
+
probe: probeFor(definition, move ? [move.to] : ports.filter((port) => port !== 443)),
|
|
282
|
+
volumeMounts,
|
|
283
|
+
...service === entryService ? { endpoint: endpointPort } : {}
|
|
284
|
+
});
|
|
103
285
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
286
|
+
if (volumes.size > 2) throw new Error(`Bunny allows 2 volumes per app, and this project needs ${volumes.size}: ${[...volumes].join(", ")}. Use an external Postgres or S3 for some of them.`);
|
|
287
|
+
return {
|
|
288
|
+
name: project.name,
|
|
289
|
+
containers,
|
|
290
|
+
volumes: [...volumes],
|
|
291
|
+
builds: [...builds.values()],
|
|
292
|
+
ignored: [...ignored].sort()
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// src/deploy/link.ts
|
|
296
|
+
/** Where a project deploys, relative to the project directory. It holds no secrets and can be committed. */
|
|
297
|
+
const LINK_FILE = ".dstack/deploy.json";
|
|
298
|
+
function readLink(directory) {
|
|
299
|
+
const path = join(directory, LINK_FILE);
|
|
300
|
+
if (!existsSync(path)) return void 0;
|
|
301
|
+
let link;
|
|
113
302
|
try {
|
|
114
|
-
|
|
115
|
-
message = body.message ?? "";
|
|
116
|
-
if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
|
|
303
|
+
link = JSON.parse(readFileSync(path, "utf8"));
|
|
117
304
|
} catch {
|
|
118
|
-
|
|
305
|
+
throw new Error(`${LINK_FILE} is not valid JSON.`);
|
|
119
306
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
307
|
+
for (const key of [
|
|
308
|
+
"provider",
|
|
309
|
+
"url",
|
|
310
|
+
"project",
|
|
311
|
+
"environment",
|
|
312
|
+
"application"
|
|
313
|
+
]) if (typeof link[key] !== "string" || !link[key]) throw new Error(`${LINK_FILE} has no ${key}.`);
|
|
314
|
+
return {
|
|
315
|
+
...link,
|
|
316
|
+
directory
|
|
317
|
+
};
|
|
129
318
|
}
|
|
130
|
-
function
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
319
|
+
function writeLink(directory, link) {
|
|
320
|
+
const path = join(directory, LINK_FILE);
|
|
321
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
322
|
+
const { provider, url, project, environment, application } = link;
|
|
323
|
+
writeFileSync(path, `${JSON.stringify({
|
|
324
|
+
provider,
|
|
325
|
+
url,
|
|
326
|
+
project,
|
|
327
|
+
environment,
|
|
328
|
+
application
|
|
329
|
+
}, null, 2)}\n`);
|
|
136
330
|
}
|
|
137
|
-
/**
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
331
|
+
/** An https URL for a git remote: git@github.com:owner/repo.git becomes https://github.com/owner/repo. */
|
|
332
|
+
function httpsRepository(remote) {
|
|
333
|
+
const value = remote.trim();
|
|
334
|
+
const scp = value.match(/^[\w.-]+@([\w.-]+):(.+)$/);
|
|
335
|
+
let url;
|
|
336
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
337
|
+
else try {
|
|
338
|
+
const parsed = new URL(value);
|
|
339
|
+
url = `https://${parsed.host.replace(/:\d+$/, "")}${parsed.pathname}`;
|
|
143
340
|
} catch {
|
|
144
|
-
|
|
145
|
-
text,
|
|
146
|
-
stderr: false
|
|
147
|
-
}));
|
|
341
|
+
throw new Error(`Cannot read the git remote ${value}.`);
|
|
148
342
|
}
|
|
149
|
-
|
|
150
|
-
return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
|
|
151
|
-
text,
|
|
152
|
-
stderr: entry.type === "stderr"
|
|
153
|
-
})));
|
|
343
|
+
return url.replace(/\.git$/, "").replace(/\/$/, "");
|
|
154
344
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
345
|
+
function git(directory, args) {
|
|
346
|
+
try {
|
|
347
|
+
return execFileSync("git", args, {
|
|
348
|
+
cwd: directory,
|
|
349
|
+
encoding: "utf8",
|
|
350
|
+
stdio: [
|
|
351
|
+
"ignore",
|
|
352
|
+
"pipe",
|
|
353
|
+
"ignore"
|
|
354
|
+
]
|
|
355
|
+
}).trim();
|
|
160
356
|
} catch {
|
|
161
|
-
|
|
357
|
+
return;
|
|
162
358
|
}
|
|
163
|
-
const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
|
|
164
|
-
if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
|
|
165
|
-
return domains.map((domain) => domain.replace(/:\d+$/, ""));
|
|
166
|
-
}
|
|
167
|
-
function appUrl(api, link) {
|
|
168
|
-
return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
|
|
169
359
|
}
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
message: item.commit_message?.split("\n")[0] || void 0,
|
|
177
|
-
createdAt: item.created_at,
|
|
178
|
-
updatedAt: item.updated_at,
|
|
179
|
-
url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
|
|
180
|
-
log: parseLog(item.logs)
|
|
181
|
-
};
|
|
360
|
+
function gitRemote(directory, remote = "origin") {
|
|
361
|
+
return git(directory, [
|
|
362
|
+
"remote",
|
|
363
|
+
"get-url",
|
|
364
|
+
remote
|
|
365
|
+
]) || void 0;
|
|
182
366
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
367
|
+
function gitBranch(directory) {
|
|
368
|
+
const branch = git(directory, [
|
|
369
|
+
"rev-parse",
|
|
370
|
+
"--abbrev-ref",
|
|
371
|
+
"HEAD"
|
|
372
|
+
]);
|
|
373
|
+
return branch && branch !== "HEAD" ? branch : void 0;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Why a deploy of `branch` would not include the local commit, or undefined
|
|
377
|
+
* when origin has it. The platform builds from the remote, not from here.
|
|
378
|
+
*/
|
|
379
|
+
function unpushedReason(directory, branch) {
|
|
380
|
+
const head = git(directory, ["rev-parse", "HEAD"]);
|
|
381
|
+
if (!head) return void 0;
|
|
382
|
+
if (git(directory, [
|
|
383
|
+
"rev-parse",
|
|
384
|
+
"--verify",
|
|
385
|
+
"--quiet",
|
|
386
|
+
`refs/remotes/origin/${branch}`
|
|
387
|
+
]) === void 0) return `origin has no branch ${branch}. Push it first: git push -u origin ${branch}`;
|
|
388
|
+
if (git(directory, [
|
|
389
|
+
"merge-base",
|
|
390
|
+
"--is-ancestor",
|
|
391
|
+
head,
|
|
392
|
+
`refs/remotes/origin/${branch}`
|
|
393
|
+
]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Why an upload of HEAD would not match the working tree, or undefined when
|
|
397
|
+
* it does. Providers that upload the local commit deploy HEAD only, so
|
|
398
|
+
* changes to tracked files must be committed first.
|
|
399
|
+
*/
|
|
400
|
+
function uncommittedReason(directory) {
|
|
401
|
+
if (git(directory, ["rev-parse", "HEAD"]) === void 0) return "This project has no commit yet. Commit it first: git add -A && git commit";
|
|
402
|
+
if (git(directory, [
|
|
403
|
+
"status",
|
|
404
|
+
"--porcelain",
|
|
405
|
+
"--untracked-files=no"
|
|
406
|
+
])) return "You have uncommitted changes, so the deploy would not include them. Commit first.";
|
|
186
407
|
}
|
|
187
|
-
var CoolifyClient = class {
|
|
188
|
-
api;
|
|
189
|
-
constructor(url, token, fetchImpl) {
|
|
190
|
-
this.api = new CoolifyApi(url, token, fetchImpl);
|
|
191
|
-
}
|
|
192
|
-
async verify() {
|
|
193
|
-
const version = await this.api.get("/version");
|
|
194
|
-
await this.api.get("/servers");
|
|
195
|
-
return String(version).trim();
|
|
196
|
-
}
|
|
197
|
-
async setup(context) {
|
|
198
|
-
const { ask, options } = context;
|
|
199
|
-
if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
|
|
200
|
-
const api = this.api;
|
|
201
|
-
const servers = await api.get("/servers");
|
|
202
|
-
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
203
|
-
let server;
|
|
204
|
-
if (options.server) {
|
|
205
|
-
server = find$1(servers, options.server);
|
|
206
|
-
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
207
|
-
} else if (servers.length === 1) server = servers[0];
|
|
208
|
-
else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
209
|
-
value: item.uuid,
|
|
210
|
-
label: item.name,
|
|
211
|
-
hint: item.ip
|
|
212
|
-
}))));
|
|
213
|
-
if (!server) throw new Error("No server chosen.");
|
|
214
|
-
const projects = await api.get("/projects");
|
|
215
|
-
let project;
|
|
216
|
-
const projectName = options.project ?? context.name;
|
|
217
|
-
project = find$1(projects, projectName);
|
|
218
|
-
if (!options.project && ask.interactive && projects.length) {
|
|
219
|
-
const NEW = "";
|
|
220
|
-
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
221
|
-
value: NEW,
|
|
222
|
-
label: `Create ${projectName}`
|
|
223
|
-
}], ...projects.map((item) => ({
|
|
224
|
-
value: item.uuid,
|
|
225
|
-
label: item.name,
|
|
226
|
-
hint: item.description ?? void 0
|
|
227
|
-
}))], project?.uuid ?? NEW);
|
|
228
|
-
project = uuid === NEW ? void 0 : find$1(projects, uuid);
|
|
229
|
-
}
|
|
230
|
-
if (!project) {
|
|
231
|
-
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
232
|
-
project = {
|
|
233
|
-
uuid: (await api.post("/projects", {
|
|
234
|
-
name: projectName,
|
|
235
|
-
description: "DjangoStack"
|
|
236
|
-
})).uuid,
|
|
237
|
-
name: projectName
|
|
238
|
-
};
|
|
239
|
-
ask.step(`Created the Coolify project ${projectName}`);
|
|
240
|
-
}
|
|
241
|
-
const environmentName = options.environment ?? "production";
|
|
242
|
-
let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
243
|
-
if (!environment) {
|
|
244
|
-
environment = {
|
|
245
|
-
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
246
|
-
name: environmentName
|
|
247
|
-
};
|
|
248
|
-
ask.step(`Created the environment ${environmentName}`);
|
|
249
|
-
}
|
|
250
|
-
let githubApp;
|
|
251
|
-
if (options.githubApp && options.githubApp !== "public") {
|
|
252
|
-
const app = find$1(await api.get("/github-apps"), options.githubApp);
|
|
253
|
-
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
254
|
-
githubApp = app.uuid;
|
|
255
|
-
} else if (!options.githubApp && ask.interactive) {
|
|
256
|
-
const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
|
|
257
|
-
if (apps.length) {
|
|
258
|
-
const choice = await ask.select("How Coolify gets the code", [{
|
|
259
|
-
value: "public",
|
|
260
|
-
label: "Public repository",
|
|
261
|
-
hint: "no access needed"
|
|
262
|
-
}, ...apps.map((app) => ({
|
|
263
|
-
value: app.uuid,
|
|
264
|
-
label: `GitHub App ${app.name}`,
|
|
265
|
-
hint: "private repository"
|
|
266
|
-
}))], "public");
|
|
267
|
-
githubApp = choice === "public" ? void 0 : choice;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
const body = {
|
|
271
|
-
project_uuid: project.uuid,
|
|
272
|
-
server_uuid: server.uuid,
|
|
273
|
-
environment_uuid: environment.uuid,
|
|
274
|
-
git_repository: context.repository,
|
|
275
|
-
git_branch: context.branch,
|
|
276
|
-
build_pack: "dockercompose",
|
|
277
|
-
docker_compose_location: "/compose.yaml",
|
|
278
|
-
docker_compose_domains: [{
|
|
279
|
-
name: "proxy",
|
|
280
|
-
domain: `https://${context.domain}:80`
|
|
281
|
-
}],
|
|
282
|
-
name: context.name,
|
|
283
|
-
description: "DjangoStack",
|
|
284
|
-
instant_deploy: false,
|
|
285
|
-
...githubApp ? { github_app_uuid: githubApp } : {}
|
|
286
|
-
};
|
|
287
|
-
const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
|
|
288
|
-
ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
|
|
289
|
-
const link = {
|
|
290
|
-
provider: "coolify",
|
|
291
|
-
url: api.url,
|
|
292
|
-
project: project.uuid,
|
|
293
|
-
environment: environment.uuid,
|
|
294
|
-
application: created.uuid
|
|
295
|
-
};
|
|
296
|
-
await this.pushEnv(link, context.env);
|
|
297
|
-
ask.step(`Set ${Object.keys(context.env).length} environment variables`);
|
|
298
|
-
return {
|
|
299
|
-
project: link.project,
|
|
300
|
-
environment: link.environment,
|
|
301
|
-
application: link.application
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
async pushEnv(link, env) {
|
|
305
|
-
const data = Object.entries(env).map(([key, value]) => ({
|
|
306
|
-
key,
|
|
307
|
-
value,
|
|
308
|
-
is_literal: true,
|
|
309
|
-
is_preview: false
|
|
310
|
-
}));
|
|
311
|
-
await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
|
|
312
|
-
}
|
|
313
|
-
async deploy(link, options) {
|
|
314
|
-
const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
|
|
315
|
-
const result = await this.api.post(`/deploy?${query}`);
|
|
316
|
-
const id = result?.deployments?.[0]?.deployment_uuid;
|
|
317
|
-
if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
|
|
318
|
-
return id;
|
|
319
|
-
}
|
|
320
|
-
async deployment(link, id) {
|
|
321
|
-
return toDeployment$1(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
322
|
-
}
|
|
323
|
-
async deployments(link, limit) {
|
|
324
|
-
const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
|
|
325
|
-
return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment$1(this.api, link, item));
|
|
326
|
-
}
|
|
327
|
-
async status(link) {
|
|
328
|
-
const app = await this.api.get(`/applications/${enc(link.application)}`);
|
|
329
|
-
return {
|
|
330
|
-
state: appState(app.status),
|
|
331
|
-
detail: app.status ?? "unknown",
|
|
332
|
-
domains: composeDomains(app),
|
|
333
|
-
branch: app.git_branch,
|
|
334
|
-
url: appUrl(this.api, link)
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
const coolify = {
|
|
339
|
-
id: "coolify",
|
|
340
|
-
name: "Coolify",
|
|
341
|
-
auth: {
|
|
342
|
-
kind: "token",
|
|
343
|
-
env: "DSTACK_COOLIFY_TOKEN",
|
|
344
|
-
help: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`
|
|
345
|
-
},
|
|
346
|
-
source: "remote",
|
|
347
|
-
target: urlTarget("Coolify", "https://coolify.example.com"),
|
|
348
|
-
platformEnv: PLATFORM_ENV,
|
|
349
|
-
setupFlags: [...SHARED_SETUP_FLAGS, {
|
|
350
|
-
name: "githubApp",
|
|
351
|
-
description: "A Coolify GitHub App for a private repository, or public"
|
|
352
|
-
}],
|
|
353
|
-
connect: (url, token) => {
|
|
354
|
-
if (!token) throw new Error("Coolify needs an API key.");
|
|
355
|
-
return new CoolifyClient(url, token);
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
408
|
const systemSsh = (args, input) => new Promise((resolve, reject) => {
|
|
359
409
|
const child = spawn("ssh", args, { stdio: [
|
|
360
410
|
"pipe",
|
|
@@ -653,127 +703,1393 @@ echo $! > ${q(`${record.id}.pid`)}
|
|
|
653
703
|
echo $! > ${q(`${link.project}/deploy.lock/pid`)}
|
|
654
704
|
`, script);
|
|
655
705
|
}
|
|
656
|
-
/** The newest deploy records first, with their status. */
|
|
657
|
-
async records(link, limit) {
|
|
658
|
-
const output = await this.sh(`
|
|
659
|
-
cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
|
|
660
|
-
for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
|
|
661
|
-
id="\${file%.json}"
|
|
662
|
-
cat "$file"
|
|
663
|
-
pid=$(cat "$id.pid" 2>/dev/null)
|
|
664
|
-
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
665
|
-
printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
666
|
-
echo ${MARK}
|
|
667
|
-
done
|
|
668
|
-
`);
|
|
669
|
-
const items = [];
|
|
670
|
-
for (const part of parts(`\n${output}`)) {
|
|
671
|
-
const lines = part.trim().split("\n");
|
|
672
|
-
if (lines.length < 2) continue;
|
|
673
|
-
try {
|
|
674
|
-
const record = JSON.parse(lines[0] ?? "");
|
|
675
|
-
const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
|
|
676
|
-
items.push({
|
|
677
|
-
record,
|
|
678
|
-
status: recordStatus(status, alive === "1"),
|
|
679
|
-
updatedAt: updatedAt || void 0
|
|
680
|
-
});
|
|
681
|
-
} catch {}
|
|
682
|
-
}
|
|
683
|
-
return items;
|
|
706
|
+
/** The newest deploy records first, with their status. */
|
|
707
|
+
async records(link, limit) {
|
|
708
|
+
const output = await this.sh(`
|
|
709
|
+
cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
|
|
710
|
+
for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
|
|
711
|
+
id="\${file%.json}"
|
|
712
|
+
cat "$file"
|
|
713
|
+
pid=$(cat "$id.pid" 2>/dev/null)
|
|
714
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
715
|
+
printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
716
|
+
echo ${MARK}
|
|
717
|
+
done
|
|
718
|
+
`);
|
|
719
|
+
const items = [];
|
|
720
|
+
for (const part of parts(`\n${output}`)) {
|
|
721
|
+
const lines = part.trim().split("\n");
|
|
722
|
+
if (lines.length < 2) continue;
|
|
723
|
+
try {
|
|
724
|
+
const record = JSON.parse(lines[0] ?? "");
|
|
725
|
+
const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
|
|
726
|
+
items.push({
|
|
727
|
+
record,
|
|
728
|
+
status: recordStatus(status, alive === "1"),
|
|
729
|
+
updatedAt: updatedAt || void 0
|
|
730
|
+
});
|
|
731
|
+
} catch {}
|
|
732
|
+
}
|
|
733
|
+
return items;
|
|
734
|
+
}
|
|
735
|
+
async deployment(link, id) {
|
|
736
|
+
if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
|
|
737
|
+
const deploys = shellQuote(`${link.project}/deploys`);
|
|
738
|
+
const output = await this.sh(`
|
|
739
|
+
cd ${deploys} || exit 1
|
|
740
|
+
[ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
|
|
741
|
+
cat ${shellQuote(`${id}.json`)}
|
|
742
|
+
pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
|
|
743
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
744
|
+
printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
745
|
+
echo ${MARK}
|
|
746
|
+
cat ${shellQuote(`${id}.log`)} 2>/dev/null
|
|
747
|
+
`);
|
|
748
|
+
if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
|
|
749
|
+
const [head = "", log = ""] = output.split(`\n${MARK}\n`);
|
|
750
|
+
const [json = "{}", state = ""] = head.split("\n");
|
|
751
|
+
const record = JSON.parse(json);
|
|
752
|
+
const [status = "", alive = "0", updatedAt] = state.split(" ");
|
|
753
|
+
return {
|
|
754
|
+
...toDeployment$2(record, recordStatus(status, alive === "1"), updatedAt || void 0),
|
|
755
|
+
log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
|
|
756
|
+
text,
|
|
757
|
+
stderr: false
|
|
758
|
+
})) : []
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async deployments(link, limit) {
|
|
762
|
+
return (await this.records(link, limit)).map((item) => ({
|
|
763
|
+
...toDeployment$2(item.record, item.status, item.updatedAt),
|
|
764
|
+
log: []
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
async status(link) {
|
|
768
|
+
const [ps = "", site = ""] = parts(await this.sh(`
|
|
769
|
+
docker compose -p ${shellQuote(link.application)} ps --all --format json
|
|
770
|
+
echo ${MARK}
|
|
771
|
+
sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
|
|
772
|
+
`));
|
|
773
|
+
const { state, detail } = composeState(parseComposePs(ps));
|
|
774
|
+
const domain = site.trim().replace(/^["']|["']$/g, "");
|
|
775
|
+
return {
|
|
776
|
+
state,
|
|
777
|
+
detail,
|
|
778
|
+
domains: domain ? [domain] : []
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
function toDeployment$2(record, status, updatedAt) {
|
|
783
|
+
return {
|
|
784
|
+
id: record.id,
|
|
785
|
+
status,
|
|
786
|
+
commit: record.commit,
|
|
787
|
+
message: record.message,
|
|
788
|
+
createdAt: record.createdAt,
|
|
789
|
+
updatedAt
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* A value for a Compose .env line: bare when safe, else single-quoted (taken
|
|
794
|
+
* literally), or double-quoted with escapes when it holds a single quote.
|
|
795
|
+
*/
|
|
796
|
+
function envValue(value) {
|
|
797
|
+
if (/^[\w@%+=:,./-]*$/.test(value)) return value;
|
|
798
|
+
if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
|
|
799
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
|
|
800
|
+
}
|
|
801
|
+
function defaultGit(args) {
|
|
802
|
+
return execFileSync("git", args, {
|
|
803
|
+
maxBuffer: 1073741824,
|
|
804
|
+
stdio: [
|
|
805
|
+
"ignore",
|
|
806
|
+
"pipe",
|
|
807
|
+
"pipe"
|
|
808
|
+
]
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
const ssh = {
|
|
812
|
+
id: "ssh",
|
|
813
|
+
name: "SSH server",
|
|
814
|
+
auth: { kind: "ssh" },
|
|
815
|
+
source: "local",
|
|
816
|
+
target: {
|
|
817
|
+
label: "Server",
|
|
818
|
+
placeholder: "deploy@example.com",
|
|
819
|
+
normalize: (value) => formatTarget(parseTarget(value))
|
|
820
|
+
},
|
|
821
|
+
platformEnv: {},
|
|
822
|
+
setupFlags: [{
|
|
823
|
+
name: "path",
|
|
824
|
+
description: "The project directory on the server (default: /srv/<project>)"
|
|
825
|
+
}],
|
|
826
|
+
connect: (target) => new SshClient(target)
|
|
827
|
+
};
|
|
828
|
+
// src/deploy/bunny.ts
|
|
829
|
+
/**
|
|
830
|
+
* Bunny Magic Containers (https://bunny.net/magic-containers), through the
|
|
831
|
+
* API at https://api.bunny.net/mc with the account API key in the AccessKey
|
|
832
|
+
* header. Bunny has no Compose: the CLI reads the resolved compose.yaml,
|
|
833
|
+
* builds the images of the local commit for linux/amd64, pushes them to
|
|
834
|
+
* Bunny Container Registry and creates one app whose containers are the
|
|
835
|
+
* production services, in one pod on localhost. A CDN endpoint on the
|
|
836
|
+
* proxy carries the site's domain; Bunny's CDN holds the certificate, so
|
|
837
|
+
* Caddy serves plain HTTP on port 80. A deploy pushes new images tagged with
|
|
838
|
+
* the commit and changes the containers' tags, which starts Bunny's rolling
|
|
839
|
+
* update.
|
|
840
|
+
*
|
|
841
|
+
* Link ids: project = the app id, environment = the account's registry
|
|
842
|
+
* namespace, application = the CDN pull zone id.
|
|
843
|
+
*/
|
|
844
|
+
const BUNNY_API = "https://api.bunny.net";
|
|
845
|
+
const BUNNY_REGISTRY = "registry.bunny.net";
|
|
846
|
+
/** Variables that put Caddy behind Bunny's CDN. TRUSTED_PROXIES is Bunny's edge list, set in setup. */
|
|
847
|
+
const BUNNY_PLATFORM_ENV = {
|
|
848
|
+
CADDY_ADDRESS: ":80",
|
|
849
|
+
NUM_PROXIES: "2"
|
|
850
|
+
};
|
|
851
|
+
/** Where a deploy's record and log are kept, in the project. Bunny keeps no deploy history. */
|
|
852
|
+
const BUNNY_DEPLOYS_DIR = ".dstack/deploys";
|
|
853
|
+
const systemRunner = (command, args, options = {}) => new Promise((resolvePromise, reject) => {
|
|
854
|
+
const child = spawn(command, args, {
|
|
855
|
+
cwd: options.cwd,
|
|
856
|
+
env: options.env ? {
|
|
857
|
+
...process.env,
|
|
858
|
+
...options.env
|
|
859
|
+
} : process.env,
|
|
860
|
+
stdio: [
|
|
861
|
+
"pipe",
|
|
862
|
+
"pipe",
|
|
863
|
+
"pipe"
|
|
864
|
+
]
|
|
865
|
+
});
|
|
866
|
+
const stdout = [];
|
|
867
|
+
const stderr = [];
|
|
868
|
+
child.stdout.on("data", (chunk) => {
|
|
869
|
+
stdout.push(chunk);
|
|
870
|
+
if (options.stream) process.stderr.write(chunk);
|
|
871
|
+
});
|
|
872
|
+
child.stderr.on("data", (chunk) => {
|
|
873
|
+
stderr.push(chunk);
|
|
874
|
+
if (options.stream) process.stderr.write(chunk);
|
|
875
|
+
});
|
|
876
|
+
child.on("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? `There is no ${command} command here.${command === "docker" ? " Install Docker: https://docs.docker.com/get-docker/" : ""}` : `Could not run ${command}: ${error.message}`)));
|
|
877
|
+
child.on("close", (code) => resolvePromise({
|
|
878
|
+
code: code ?? 1,
|
|
879
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
880
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
881
|
+
}));
|
|
882
|
+
child.stdin.on("error", () => {});
|
|
883
|
+
child.stdin.end(options.input);
|
|
884
|
+
});
|
|
885
|
+
var BunnyError = class extends Error {
|
|
886
|
+
status;
|
|
887
|
+
constructor(message, status) {
|
|
888
|
+
super(message);
|
|
889
|
+
this.status = status;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
/** The items of a list response: an array, or an object with items, data or a similar key. */
|
|
893
|
+
function listOf(value) {
|
|
894
|
+
if (Array.isArray(value)) return value;
|
|
895
|
+
if (value && typeof value === "object") for (const key of [
|
|
896
|
+
"items",
|
|
897
|
+
"Items",
|
|
898
|
+
"data",
|
|
899
|
+
"applications",
|
|
900
|
+
"registries",
|
|
901
|
+
"containerRegistries",
|
|
902
|
+
"endpoints",
|
|
903
|
+
"regions"
|
|
904
|
+
]) {
|
|
905
|
+
const items = value[key];
|
|
906
|
+
if (Array.isArray(items)) return items;
|
|
907
|
+
}
|
|
908
|
+
return [];
|
|
909
|
+
}
|
|
910
|
+
function text(value) {
|
|
911
|
+
return typeof value === "string" && value ? value : typeof value === "number" ? String(value) : void 0;
|
|
912
|
+
}
|
|
913
|
+
/** The first value under a key that matches, searched depth first. */
|
|
914
|
+
function findKey(value, pattern) {
|
|
915
|
+
if (!value || typeof value !== "object") return void 0;
|
|
916
|
+
for (const [key, item] of Object.entries(value)) if (pattern.test(key) && item != null && item !== "") return item;
|
|
917
|
+
for (const item of Object.values(value)) {
|
|
918
|
+
const found = findKey(item, pattern);
|
|
919
|
+
if (found !== void 0) return found;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
var BunnyApi = class {
|
|
923
|
+
token;
|
|
924
|
+
fetchImpl;
|
|
925
|
+
base;
|
|
926
|
+
constructor(token, fetchImpl = fetch, base = BUNNY_API) {
|
|
927
|
+
this.token = token;
|
|
928
|
+
this.fetchImpl = fetchImpl;
|
|
929
|
+
this.base = base;
|
|
930
|
+
}
|
|
931
|
+
async request(method, path, body) {
|
|
932
|
+
let response;
|
|
933
|
+
try {
|
|
934
|
+
response = await this.fetchImpl(`${this.base}${path}`, {
|
|
935
|
+
method,
|
|
936
|
+
headers: {
|
|
937
|
+
AccessKey: this.token,
|
|
938
|
+
Accept: "application/json",
|
|
939
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
940
|
+
},
|
|
941
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
942
|
+
});
|
|
943
|
+
} catch (error) {
|
|
944
|
+
throw new Error(`Could not reach ${this.base}: ${error.message}`);
|
|
945
|
+
}
|
|
946
|
+
const raw = await response.text();
|
|
947
|
+
let data;
|
|
948
|
+
try {
|
|
949
|
+
data = raw ? JSON.parse(raw) : void 0;
|
|
950
|
+
} catch {
|
|
951
|
+
data = raw;
|
|
952
|
+
}
|
|
953
|
+
if (response.ok) return data;
|
|
954
|
+
if (response.status === 401 || response.status === 403 && !raw) throw new BunnyError("Bunny refused the API key. Use the key of your account (not a sub-user's) from https://dash.bunny.net/account/api-key.", response.status);
|
|
955
|
+
throw new BunnyError(`Bunny answered ${response.status} to ${method} ${path}: ${errorText(data)}`, response.status);
|
|
956
|
+
}
|
|
957
|
+
/** The Magic Containers API. */
|
|
958
|
+
mc(method, path, body) {
|
|
959
|
+
return this.request(method, `/mc${path}`, body);
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
function errorText(data) {
|
|
963
|
+
if (typeof data === "string") return data.slice(0, 300) || "no details";
|
|
964
|
+
if (data && typeof data === "object") {
|
|
965
|
+
const value = data;
|
|
966
|
+
const parts = [
|
|
967
|
+
value.title,
|
|
968
|
+
value.detail,
|
|
969
|
+
value.message,
|
|
970
|
+
value.Message,
|
|
971
|
+
value.error
|
|
972
|
+
].map((item) => text(item)).filter(Boolean);
|
|
973
|
+
const errors = value.errors;
|
|
974
|
+
if (errors && typeof errors === "object") for (const [key, messages] of Object.entries(errors)) parts.push(`${key}: ${Array.isArray(messages) ? messages.join(" ") : String(messages)}`);
|
|
975
|
+
if (parts.length) return parts.join("; ");
|
|
976
|
+
}
|
|
977
|
+
return "no details";
|
|
978
|
+
}
|
|
979
|
+
/** A Bunny probe from a planned one. */
|
|
980
|
+
function probeRequest(probe, failureThreshold) {
|
|
981
|
+
const check = probe.http ? { httpGet: {
|
|
982
|
+
request: {
|
|
983
|
+
path: probe.http.path,
|
|
984
|
+
portNumber: probe.http.port
|
|
985
|
+
},
|
|
986
|
+
response: { expectedStatusCode: "ok" }
|
|
987
|
+
} } : { tcpSocket: { request: { portNumber: probe.tcp?.port } } };
|
|
988
|
+
return {
|
|
989
|
+
initialDelaySeconds: 1,
|
|
990
|
+
periodSeconds: probe.periodSeconds,
|
|
991
|
+
timeoutSeconds: probe.timeoutSeconds,
|
|
992
|
+
failureThreshold,
|
|
993
|
+
successThreshold: 1,
|
|
994
|
+
...check
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/** The image fields of a container. */
|
|
998
|
+
function imageFields(container, options) {
|
|
999
|
+
const { image } = container;
|
|
1000
|
+
if (image.source === "push") return {
|
|
1001
|
+
imageRegistryId: options.registries.bunny,
|
|
1002
|
+
imageNamespace: options.namespace,
|
|
1003
|
+
imageName: image.repository,
|
|
1004
|
+
imageTag: options.tag
|
|
1005
|
+
};
|
|
1006
|
+
const registry = image.source === "gitHub" ? options.registries.gitHub : options.registries.dockerHub;
|
|
1007
|
+
if (!registry) throw new Error(`There is no public ${image.source === "gitHub" ? "GitHub" : "Docker Hub"} registry in Bunny.`);
|
|
1008
|
+
return {
|
|
1009
|
+
imageRegistryId: registry,
|
|
1010
|
+
imageNamespace: image.namespace,
|
|
1011
|
+
imageName: image.name,
|
|
1012
|
+
imageTag: image.tag,
|
|
1013
|
+
...image.digest ? { imageDigest: image.digest } : {}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
/** The body of POST /apps for a planned app. */
|
|
1017
|
+
function appRequest(plan, options) {
|
|
1018
|
+
return {
|
|
1019
|
+
name: plan.name.replace(/[^A-Za-z0-9-\s]+/g, "-").slice(0, 100),
|
|
1020
|
+
runtimeType: "shared",
|
|
1021
|
+
autoScaling: {
|
|
1022
|
+
min: 1,
|
|
1023
|
+
max: 1
|
|
1024
|
+
},
|
|
1025
|
+
regionSettings: {
|
|
1026
|
+
allowedRegionIds: [options.region],
|
|
1027
|
+
requiredRegionIds: [options.region],
|
|
1028
|
+
maxAllowedRegions: 1
|
|
1029
|
+
},
|
|
1030
|
+
containerTemplates: plan.containers.map((container) => ({
|
|
1031
|
+
name: container.name,
|
|
1032
|
+
...imageFields(container, options),
|
|
1033
|
+
imagePullPolicy: "ifNotPresent",
|
|
1034
|
+
...container.command || container.args || container.workingDir ? { entryPoint: {
|
|
1035
|
+
...container.command ? { commandArray: container.command } : {},
|
|
1036
|
+
...container.args ? { argumentsArray: container.args } : {},
|
|
1037
|
+
...container.workingDir ? { workingDirectory: container.workingDir } : {}
|
|
1038
|
+
} } : {},
|
|
1039
|
+
environmentVariables: envList(container.env),
|
|
1040
|
+
...container.probe ? { probes: {
|
|
1041
|
+
startup: probeRequest(container.probe, container.probe.startupThreshold),
|
|
1042
|
+
readiness: probeRequest(container.probe, container.probe.failureThreshold)
|
|
1043
|
+
} } : {},
|
|
1044
|
+
...container.endpoint ? { endpoints: [{
|
|
1045
|
+
displayName: "site",
|
|
1046
|
+
cdn: {
|
|
1047
|
+
isSslEnabled: false,
|
|
1048
|
+
portMappings: [{ containerPort: container.endpoint }]
|
|
1049
|
+
}
|
|
1050
|
+
}] } : {},
|
|
1051
|
+
...container.volumeMounts.length ? { volumeMounts: container.volumeMounts } : {}
|
|
1052
|
+
})),
|
|
1053
|
+
...plan.volumes.length ? { volumes: plan.volumes.map((name) => ({
|
|
1054
|
+
name,
|
|
1055
|
+
size: options.volumeSize
|
|
1056
|
+
})) } : {}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function envList(env) {
|
|
1060
|
+
return Object.entries(env).map(([name, value]) => ({
|
|
1061
|
+
name,
|
|
1062
|
+
value
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
/** Bunny's word for an app's state, read from the fields its API returns. */
|
|
1066
|
+
function bunnyState(value) {
|
|
1067
|
+
const word = (value ?? "").toLowerCase();
|
|
1068
|
+
if (!word) return "unknown";
|
|
1069
|
+
if (/fail|error|crash|unhealthy|degrad/.test(word)) return "degraded";
|
|
1070
|
+
if (/stop|suspend|undeploy|inactive|paused|deleted/.test(word)) return "stopped";
|
|
1071
|
+
if (/run|active|healthy|^ok$|ready|deployed|online/.test(word)) return "running";
|
|
1072
|
+
return "unknown";
|
|
1073
|
+
}
|
|
1074
|
+
/** Whether Bunny is still rolling out. */
|
|
1075
|
+
function bunnyBusy(value) {
|
|
1076
|
+
return /deploying|progress|updat|pending|creat|start|provision|rolling|scaling/i.test(value ?? "");
|
|
1077
|
+
}
|
|
1078
|
+
function appContainers(app) {
|
|
1079
|
+
const value = app;
|
|
1080
|
+
return listOf(value?.containerTemplates ?? value?.containers ?? []).map((item) => ({
|
|
1081
|
+
id: text(item.id) ?? "",
|
|
1082
|
+
name: text(item.name) ?? "",
|
|
1083
|
+
imageNamespace: text(item.imageNamespace),
|
|
1084
|
+
imageName: text(item.imageName),
|
|
1085
|
+
imageTag: text(item.imageTag)
|
|
1086
|
+
})).filter((item) => item.id && item.name);
|
|
1087
|
+
}
|
|
1088
|
+
function appStatusText(app) {
|
|
1089
|
+
const value = app;
|
|
1090
|
+
for (const key of [
|
|
1091
|
+
"status",
|
|
1092
|
+
"state",
|
|
1093
|
+
"appStatus",
|
|
1094
|
+
"displayStatus",
|
|
1095
|
+
"deploymentStatus"
|
|
1096
|
+
]) {
|
|
1097
|
+
const item = value?.[key];
|
|
1098
|
+
if (typeof item === "string") return item;
|
|
1099
|
+
if (item && typeof item === "object") {
|
|
1100
|
+
const nested = text(item.status ?? item.state ?? item.name);
|
|
1101
|
+
if (nested) return nested;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
var BunnyClient = class {
|
|
1106
|
+
fetchImpl;
|
|
1107
|
+
api;
|
|
1108
|
+
token;
|
|
1109
|
+
run;
|
|
1110
|
+
now;
|
|
1111
|
+
readFile;
|
|
1112
|
+
edgeServersImpl;
|
|
1113
|
+
constructor(token, fetchImpl = fetch, options = {}) {
|
|
1114
|
+
this.fetchImpl = fetchImpl;
|
|
1115
|
+
this.api = new BunnyApi(token, fetchImpl);
|
|
1116
|
+
this.token = token;
|
|
1117
|
+
this.run = options.run ?? systemRunner;
|
|
1118
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1119
|
+
this.readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
1120
|
+
this.edgeServersImpl = options.edgeServers ?? (() => this.defaultEdgeServers());
|
|
1121
|
+
}
|
|
1122
|
+
async verify() {
|
|
1123
|
+
const apps = listOf(await this.api.mc("GET", "/apps"));
|
|
1124
|
+
return `Magic Containers (${apps.length} app${apps.length === 1 ? "" : "s"})`;
|
|
1125
|
+
}
|
|
1126
|
+
async docker(args, options = {}) {
|
|
1127
|
+
const result = await this.run("docker", args, options);
|
|
1128
|
+
if (result.code === 0) return result.stdout;
|
|
1129
|
+
const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
|
|
1130
|
+
throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
|
|
1131
|
+
}
|
|
1132
|
+
async git(directory, args) {
|
|
1133
|
+
return this.run("git", [
|
|
1134
|
+
"-C",
|
|
1135
|
+
directory,
|
|
1136
|
+
...args
|
|
1137
|
+
]);
|
|
1138
|
+
}
|
|
1139
|
+
/** The short commit and its subject. */
|
|
1140
|
+
async commit(directory) {
|
|
1141
|
+
const result = await this.git(directory, [
|
|
1142
|
+
"log",
|
|
1143
|
+
"-1",
|
|
1144
|
+
"--format=%h%n%s"
|
|
1145
|
+
]);
|
|
1146
|
+
if (result.code !== 0) throw new Error("This project has no commit yet. Commit it first: git add -A && git commit");
|
|
1147
|
+
const [commit = "", message = ""] = result.stdout.trim().split("\n");
|
|
1148
|
+
return {
|
|
1149
|
+
commit,
|
|
1150
|
+
message
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
/** Extracts the local commit into a temporary directory, so the images match the commit. */
|
|
1154
|
+
async checkout(directory) {
|
|
1155
|
+
const target = mkdtempSync(join(tmpdir(), "dstack-bunny-"));
|
|
1156
|
+
const archive = await this.git(directory, [
|
|
1157
|
+
"archive",
|
|
1158
|
+
"--format=tar",
|
|
1159
|
+
"-o",
|
|
1160
|
+
join(target, ".archive.tar"),
|
|
1161
|
+
"HEAD"
|
|
1162
|
+
]);
|
|
1163
|
+
if (archive.code !== 0) {
|
|
1164
|
+
rmSync(target, {
|
|
1165
|
+
recursive: true,
|
|
1166
|
+
force: true
|
|
1167
|
+
});
|
|
1168
|
+
throw new Error(`git archive failed: ${archive.stderr.trim().split("\n").at(-1) ?? ""}`);
|
|
1169
|
+
}
|
|
1170
|
+
const extract = await this.run("tar", [
|
|
1171
|
+
"-x",
|
|
1172
|
+
"-f",
|
|
1173
|
+
".archive.tar"
|
|
1174
|
+
], { cwd: target });
|
|
1175
|
+
rmSync(join(target, ".archive.tar"), { force: true });
|
|
1176
|
+
if (extract.code !== 0) {
|
|
1177
|
+
rmSync(target, {
|
|
1178
|
+
recursive: true,
|
|
1179
|
+
force: true
|
|
1180
|
+
});
|
|
1181
|
+
throw new Error(`Could not extract the commit: ${extract.stderr.trim()}`);
|
|
1182
|
+
}
|
|
1183
|
+
return target;
|
|
1184
|
+
}
|
|
1185
|
+
/** The production Compose project, resolved with the variables, as JSON. */
|
|
1186
|
+
async composeConfig(directory, env) {
|
|
1187
|
+
const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
|
|
1188
|
+
const file = join(folder, ".env");
|
|
1189
|
+
writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
|
|
1190
|
+
try {
|
|
1191
|
+
const output = await this.docker([
|
|
1192
|
+
"compose",
|
|
1193
|
+
"--env-file",
|
|
1194
|
+
file,
|
|
1195
|
+
"config",
|
|
1196
|
+
"--format",
|
|
1197
|
+
"json"
|
|
1198
|
+
], {
|
|
1199
|
+
cwd: directory,
|
|
1200
|
+
env: {
|
|
1201
|
+
...env,
|
|
1202
|
+
ENV_FILE: file
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
return JSON.parse(output);
|
|
1206
|
+
} finally {
|
|
1207
|
+
rmSync(folder, {
|
|
1208
|
+
recursive: true,
|
|
1209
|
+
force: true
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async plan(directory, env) {
|
|
1214
|
+
return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
|
|
1215
|
+
}
|
|
1216
|
+
/** Builds an image for linux/amd64 and pushes it to Bunny Container Registry. */
|
|
1217
|
+
async buildAndPush(build, reference, directory) {
|
|
1218
|
+
if (build.kind === "build") await this.docker([
|
|
1219
|
+
"build",
|
|
1220
|
+
"--platform",
|
|
1221
|
+
"linux/amd64",
|
|
1222
|
+
...build.target ? ["--target", build.target] : [],
|
|
1223
|
+
...build.dockerfile ? ["-f", resolve(build.context, build.dockerfile)] : [],
|
|
1224
|
+
...Object.entries(build.args ?? {}).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
|
|
1225
|
+
"-t",
|
|
1226
|
+
reference,
|
|
1227
|
+
build.context
|
|
1228
|
+
], {
|
|
1229
|
+
cwd: directory,
|
|
1230
|
+
stream: true
|
|
1231
|
+
});
|
|
1232
|
+
else {
|
|
1233
|
+
const context = mkdtempSync(join(tmpdir(), "dstack-bake-"));
|
|
1234
|
+
try {
|
|
1235
|
+
const lines = [`FROM ${build.from}`];
|
|
1236
|
+
build.files.forEach((file, index) => {
|
|
1237
|
+
writeFileSync(join(context, `file-${index}`), file.content);
|
|
1238
|
+
lines.push(`COPY file-${index} ${file.target}`);
|
|
1239
|
+
});
|
|
1240
|
+
writeFileSync(join(context, "Dockerfile"), `${lines.join("\n")}\n`);
|
|
1241
|
+
await this.docker([
|
|
1242
|
+
"build",
|
|
1243
|
+
"--platform",
|
|
1244
|
+
"linux/amd64",
|
|
1245
|
+
"-t",
|
|
1246
|
+
reference,
|
|
1247
|
+
context
|
|
1248
|
+
], { stream: true });
|
|
1249
|
+
} finally {
|
|
1250
|
+
rmSync(context, {
|
|
1251
|
+
recursive: true,
|
|
1252
|
+
force: true
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const config = mkdtempSync(join(tmpdir(), "dstack-docker-"));
|
|
1257
|
+
try {
|
|
1258
|
+
await this.docker([
|
|
1259
|
+
"login",
|
|
1260
|
+
BUNNY_REGISTRY,
|
|
1261
|
+
"--username",
|
|
1262
|
+
"token",
|
|
1263
|
+
"--password-stdin"
|
|
1264
|
+
], {
|
|
1265
|
+
env: { DOCKER_CONFIG: config },
|
|
1266
|
+
input: this.token
|
|
1267
|
+
});
|
|
1268
|
+
await this.docker(["push", reference], {
|
|
1269
|
+
env: { DOCKER_CONFIG: config },
|
|
1270
|
+
stream: true
|
|
1271
|
+
});
|
|
1272
|
+
} finally {
|
|
1273
|
+
rmSync(config, {
|
|
1274
|
+
recursive: true,
|
|
1275
|
+
force: true
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
/** Builds and pushes every image of the local commit; returns the plan of the commit. */
|
|
1280
|
+
async pushImages(directory, env, namespace, tag, log) {
|
|
1281
|
+
const checkout = await this.checkout(directory);
|
|
1282
|
+
try {
|
|
1283
|
+
const plan = await this.plan(checkout, env);
|
|
1284
|
+
for (const build of plan.builds) {
|
|
1285
|
+
const reference = `${BUNNY_REGISTRY}/${namespace}/${build.repository}:${tag}`;
|
|
1286
|
+
log(`Building and pushing ${reference}`);
|
|
1287
|
+
await this.buildAndPush(build, reference, checkout);
|
|
1288
|
+
}
|
|
1289
|
+
return plan;
|
|
1290
|
+
} finally {
|
|
1291
|
+
rmSync(checkout, {
|
|
1292
|
+
recursive: true,
|
|
1293
|
+
force: true
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
async registries() {
|
|
1298
|
+
const list = listOf(await this.api.mc("GET", "/registries"));
|
|
1299
|
+
const registries = {};
|
|
1300
|
+
let namespace;
|
|
1301
|
+
for (const item of list) {
|
|
1302
|
+
const type = (text(item.type) ?? "").toLowerCase();
|
|
1303
|
+
const id = text(item.id);
|
|
1304
|
+
if (!id) continue;
|
|
1305
|
+
if (type === "dockerhub" && !registries.dockerHub) registries.dockerHub = id;
|
|
1306
|
+
else if (type === "github" && !registries.gitHub) registries.gitHub = id;
|
|
1307
|
+
else if ((type === "bunnyregistry" || type === "bunny") && !registries.bunny) {
|
|
1308
|
+
registries.bunny = id;
|
|
1309
|
+
namespace = text(item.namespace ?? item.accountId ?? item.userName ?? item.username);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return {
|
|
1313
|
+
registries,
|
|
1314
|
+
namespace
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
async ensureRegistries() {
|
|
1318
|
+
const found = await this.registries();
|
|
1319
|
+
if (!found.registries.dockerHub) throw new Error("Bunny lists no public Docker Hub registry for this account. Check Magic Containers › Image Registries.");
|
|
1320
|
+
if (!found.registries.bunny) {
|
|
1321
|
+
const created = await this.api.mc("POST", "/registries", {
|
|
1322
|
+
displayName: "Bunny Container Registry",
|
|
1323
|
+
type: "bunnyRegistry"
|
|
1324
|
+
});
|
|
1325
|
+
const id = text(created?.id) ?? text(findKey(created, /^id$/i));
|
|
1326
|
+
if (!id) throw new Error("Could not add Bunny Container Registry to Magic Containers.");
|
|
1327
|
+
found.registries.bunny = id;
|
|
1328
|
+
}
|
|
1329
|
+
return {
|
|
1330
|
+
registries: found.registries,
|
|
1331
|
+
namespace: found.namespace
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
async app(link) {
|
|
1335
|
+
return await this.api.mc("GET", `/apps/${encodeURIComponent(link.project)}`) ?? {};
|
|
1336
|
+
}
|
|
1337
|
+
async defaultEdgeServers() {
|
|
1338
|
+
const addresses = [];
|
|
1339
|
+
for (const path of ["/system/edgeserverlist", "/system/edgeserverlist/ipv6"]) {
|
|
1340
|
+
const response = await this.fetchImpl(`${BUNNY_API}${path}`, { headers: { Accept: "application/json" } });
|
|
1341
|
+
if (!response.ok) throw new Error(`Bunny answered ${response.status} to ${path}`);
|
|
1342
|
+
const list = await response.json();
|
|
1343
|
+
if (Array.isArray(list)) {
|
|
1344
|
+
for (const item of list) if (typeof item === "string") addresses.push(item);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return addresses;
|
|
1348
|
+
}
|
|
1349
|
+
/** TRUSTED_PROXIES for Caddy: the addresses Bunny's CDN connects from. */
|
|
1350
|
+
async trustedProxies() {
|
|
1351
|
+
try {
|
|
1352
|
+
const addresses = await this.edgeServersImpl();
|
|
1353
|
+
return addresses.length ? addresses.join(" ") : void 0;
|
|
1354
|
+
} catch {
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
async pullZoneId(appId) {
|
|
1359
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1360
|
+
const id = text(findKey(await this.api.mc("GET", `/apps/${encodeURIComponent(appId)}/endpoints`), /^pull_?zone_?id$/i));
|
|
1361
|
+
if (id && id !== "0") return id;
|
|
1362
|
+
await new Promise((done) => setTimeout(done, attempt ? 3e3 : 1e3));
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
async pullZone(id) {
|
|
1366
|
+
return await this.api.request("GET", `/pullzone/${encodeURIComponent(id)}`) ?? {};
|
|
1367
|
+
}
|
|
1368
|
+
/** The custom hostname, its certificate and a cache bypass for pages that must not be cached. */
|
|
1369
|
+
async setupPullZone(id, domain, step) {
|
|
1370
|
+
const zone = `/pullzone/${encodeURIComponent(id)}`;
|
|
1371
|
+
await this.api.request("POST", zone, { AddHostHeader: true });
|
|
1372
|
+
if (!listOf((await this.pullZone(id)).Hostnames).map((item) => text(item.Value)?.toLowerCase()).includes(domain.toLowerCase())) {
|
|
1373
|
+
await this.api.request("POST", `${zone}/addHostname`, { Hostname: domain });
|
|
1374
|
+
step(`Added ${domain} to the CDN pull zone`);
|
|
1375
|
+
}
|
|
1376
|
+
await this.api.request("POST", `${zone}/edgerules/addOrUpdate`, {
|
|
1377
|
+
ActionType: 3,
|
|
1378
|
+
ActionParameter1: "0",
|
|
1379
|
+
ActionParameter2: "0",
|
|
1380
|
+
Triggers: [{
|
|
1381
|
+
Type: 0,
|
|
1382
|
+
PatternMatches: [
|
|
1383
|
+
"*/admin",
|
|
1384
|
+
"*/admin/*",
|
|
1385
|
+
"*/api",
|
|
1386
|
+
"*/api/*",
|
|
1387
|
+
"*/_a/*"
|
|
1388
|
+
],
|
|
1389
|
+
PatternMatchingType: 0
|
|
1390
|
+
}],
|
|
1391
|
+
TriggerMatchingType: 0,
|
|
1392
|
+
Description: "dstack: do not cache the admin, the API and analytics",
|
|
1393
|
+
Enabled: true
|
|
1394
|
+
});
|
|
1395
|
+
await this.certificate(id, domain);
|
|
1396
|
+
}
|
|
1397
|
+
/** Requests the free certificate once DNS points at the CDN; returns whether it is active. */
|
|
1398
|
+
async certificate(id, domain) {
|
|
1399
|
+
if (listOf((await this.pullZone(id)).Hostnames).find((item) => text(item.Value)?.toLowerCase() === domain.toLowerCase())?.HasCertificate === true) return true;
|
|
1400
|
+
try {
|
|
1401
|
+
await this.api.request("GET", `/pullzone/loadFreeCertificate?hostname=${encodeURIComponent(domain)}`);
|
|
1402
|
+
await this.api.request("POST", `/pullzone/${encodeURIComponent(id)}/setForceSSL`, {
|
|
1403
|
+
Hostname: domain,
|
|
1404
|
+
ForceSSL: true
|
|
1405
|
+
});
|
|
1406
|
+
return true;
|
|
1407
|
+
} catch {
|
|
1408
|
+
return false;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
async cname(id) {
|
|
1412
|
+
const name = text((await this.pullZone(id)).Name);
|
|
1413
|
+
return name ? `${name}.b-cdn.net` : void 0;
|
|
1414
|
+
}
|
|
1415
|
+
recordsDir(directory) {
|
|
1416
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1417
|
+
if (!existsSync(folder)) {
|
|
1418
|
+
mkdirSync(folder, { recursive: true });
|
|
1419
|
+
writeFileSync(join(folder, ".gitignore"), "*\n");
|
|
1420
|
+
}
|
|
1421
|
+
return folder;
|
|
1422
|
+
}
|
|
1423
|
+
writeRecord(directory, record, log) {
|
|
1424
|
+
const folder = this.recordsDir(directory);
|
|
1425
|
+
writeFileSync(join(folder, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`);
|
|
1426
|
+
writeFileSync(join(folder, `${record.id}.log`), log.length ? `${log.join("\n")}\n` : "");
|
|
1427
|
+
}
|
|
1428
|
+
records(directory) {
|
|
1429
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1430
|
+
if (!existsSync(folder)) return [];
|
|
1431
|
+
const records = [];
|
|
1432
|
+
for (const file of readdirSync(folder)) {
|
|
1433
|
+
if (!file.endsWith(".json")) continue;
|
|
1434
|
+
try {
|
|
1435
|
+
records.push(JSON.parse(readFileSync(join(folder, file), "utf8")));
|
|
1436
|
+
} catch {}
|
|
1437
|
+
}
|
|
1438
|
+
return records.sort((a, b) => b.id.localeCompare(a.id));
|
|
1439
|
+
}
|
|
1440
|
+
async setup(context) {
|
|
1441
|
+
const { ask, options } = context;
|
|
1442
|
+
const { directory } = context;
|
|
1443
|
+
const volumeSize = Number.parseInt(options.volumeSize ?? "10", 10);
|
|
1444
|
+
if (!Number.isFinite(volumeSize) || volumeSize < 1 || volumeSize > 100) throw new Error("Give --volume-size in GB, from 1 to 100.");
|
|
1445
|
+
const env = { ...context.env };
|
|
1446
|
+
const trusted = await this.trustedProxies();
|
|
1447
|
+
if (trusted) env.TRUSTED_PROXIES = trusted;
|
|
1448
|
+
else ask.step("Could not read Bunny's edge server list; Django may see the CDN's address instead of the client's. Run dstack deploy env push later.");
|
|
1449
|
+
const local = await this.plan(directory, env);
|
|
1450
|
+
if (local.ignored.length) ask.step(`Bunny runs the app in a gVisor sandbox; not applied from compose.yaml: ${local.ignored.join(", ")}`);
|
|
1451
|
+
const { registries, namespace: found } = await this.ensureRegistries();
|
|
1452
|
+
let namespace = options.account ?? found;
|
|
1453
|
+
if (!namespace) namespace = (await ask.text("Bunny account id (Magic Containers › Image Registries, or bunny whoami)", { placeholder: "123456" })).trim();
|
|
1454
|
+
if (!/^[\w.-]+$/.test(namespace)) throw new Error(`${namespace} is not a Bunny account id.`);
|
|
1455
|
+
let region = options.region;
|
|
1456
|
+
if (!region) {
|
|
1457
|
+
const optimal = text(findKey(await this.api.mc("GET", "/regions/optimal").catch(() => void 0), /^id$|regionid/i));
|
|
1458
|
+
const regions = listOf(await this.api.mc("GET", "/regions")).map((item) => ({
|
|
1459
|
+
value: text(item.id) ?? "",
|
|
1460
|
+
label: text(item.name) ?? text(item.id) ?? ""
|
|
1461
|
+
})).filter((item) => item.value);
|
|
1462
|
+
if (regions.length) region = await ask.select("Region", regions, optimal ?? regions[0]?.value);
|
|
1463
|
+
else region = optimal;
|
|
1464
|
+
}
|
|
1465
|
+
if (!region) throw new Error("Pass --region, such as DE.");
|
|
1466
|
+
const { commit, message } = await this.commit(directory);
|
|
1467
|
+
const uncommitted = uncommittedReason(directory);
|
|
1468
|
+
if (uncommitted) ask.step(`Building the last commit, ${commit}: ${uncommitted.replace(/ Commit first\.$/, "")}`);
|
|
1469
|
+
const log = [];
|
|
1470
|
+
const plan = await this.pushImages(directory, env, namespace, commit, (line) => {
|
|
1471
|
+
log.push(line);
|
|
1472
|
+
ask.step(line);
|
|
1473
|
+
});
|
|
1474
|
+
const appId = text((await this.api.mc("POST", "/apps", appRequest(plan, {
|
|
1475
|
+
registries,
|
|
1476
|
+
namespace,
|
|
1477
|
+
tag: commit,
|
|
1478
|
+
region,
|
|
1479
|
+
volumeSize
|
|
1480
|
+
})))?.id);
|
|
1481
|
+
if (!appId) throw new Error("Bunny created the app but returned no id.");
|
|
1482
|
+
ask.step(`Created the app ${plan.name} in ${region}`);
|
|
1483
|
+
try {
|
|
1484
|
+
await this.api.mc("POST", `/apps/${encodeURIComponent(appId)}/deploy`);
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
if (!(error instanceof BunnyError && [400, 409].includes(error.status))) throw error;
|
|
1487
|
+
}
|
|
1488
|
+
const zone = await this.pullZoneId(appId);
|
|
1489
|
+
if (!zone) throw new Error(`Bunny has not created the CDN endpoint of app ${appId} yet. Run dstack deploy setup --force later.`);
|
|
1490
|
+
await this.setupPullZone(zone, context.domain, ask.step);
|
|
1491
|
+
const target = await this.cname(zone);
|
|
1492
|
+
ask.step(`Add this DNS record: ${context.domain} CNAME ${target ?? "the pull zone's b-cdn.net address"}${context.domain.split(".").length === 2 ? " (an apex domain needs Bunny DNS, or use www and redirect)" : ""}`);
|
|
1493
|
+
this.writeRecord(directory, {
|
|
1494
|
+
id: deployId(this.now(), commit),
|
|
1495
|
+
tag: commit,
|
|
1496
|
+
commit,
|
|
1497
|
+
message,
|
|
1498
|
+
createdAt: this.now().toISOString()
|
|
1499
|
+
}, log);
|
|
1500
|
+
return {
|
|
1501
|
+
project: appId,
|
|
1502
|
+
environment: namespace,
|
|
1503
|
+
application: zone
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
async pushEnv(link, env) {
|
|
1507
|
+
const directory = projectDirectory(link);
|
|
1508
|
+
const values = { ...env };
|
|
1509
|
+
const trusted = await this.trustedProxies();
|
|
1510
|
+
if (trusted) values.TRUSTED_PROXIES = trusted;
|
|
1511
|
+
const plan = await this.plan(directory, values);
|
|
1512
|
+
const containers = appContainers(await this.app(link));
|
|
1513
|
+
for (const container of plan.containers) {
|
|
1514
|
+
const remote = containers.find((item) => item.name === container.name);
|
|
1515
|
+
if (!remote) throw new Error(missingContainer(container.name));
|
|
1516
|
+
await this.api.mc("PUT", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(remote.id)}/env`, { environmentVariables: envList(container.env) });
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
async deploy(link, options) {
|
|
1520
|
+
const { directory } = options;
|
|
1521
|
+
const { commit, message } = await this.commit(directory);
|
|
1522
|
+
const id = deployId(this.now(), commit);
|
|
1523
|
+
const log = [];
|
|
1524
|
+
const record = {
|
|
1525
|
+
id,
|
|
1526
|
+
tag: commit,
|
|
1527
|
+
commit,
|
|
1528
|
+
message,
|
|
1529
|
+
createdAt: this.now().toISOString()
|
|
1530
|
+
};
|
|
1531
|
+
const containers = appContainers(await this.app(link));
|
|
1532
|
+
const env = {
|
|
1533
|
+
...productionEnv(directory),
|
|
1534
|
+
...BUNNY_PLATFORM_ENV
|
|
1535
|
+
};
|
|
1536
|
+
try {
|
|
1537
|
+
const local = await this.plan(directory, env);
|
|
1538
|
+
const pushed = local.containers.filter((container) => container.image.source === "push");
|
|
1539
|
+
for (const container of local.containers) if (!containers.some((item) => item.name === container.name)) throw new Error(missingContainer(container.name));
|
|
1540
|
+
if (pushed.every((container) => containers.find((item) => item.name === container.name)?.imageTag === commit)) {
|
|
1541
|
+
if (options.force) {
|
|
1542
|
+
await this.api.mc("POST", `/apps/${encodeURIComponent(link.project)}/restart`);
|
|
1543
|
+
log.push(`Restarted the app on ${commit}`);
|
|
1544
|
+
} else log.push(`The app already runs ${commit}; nothing to deploy. Pass --force to restart it.`);
|
|
1545
|
+
} else {
|
|
1546
|
+
await this.pushImages(directory, env, link.environment, commit, (line) => {
|
|
1547
|
+
log.push(line);
|
|
1548
|
+
process.stderr.write(`${line}\n`);
|
|
1549
|
+
});
|
|
1550
|
+
await this.retag(link, containers, pushed.map((container) => container.name), commit);
|
|
1551
|
+
log.push(`Bunny is rolling out ${commit}`);
|
|
1552
|
+
}
|
|
1553
|
+
if (link.application) {
|
|
1554
|
+
const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
1555
|
+
if (domain) await this.certificate(link.application, domain).catch(() => false);
|
|
1556
|
+
}
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
log.push(error.message);
|
|
1559
|
+
this.writeRecord(directory, {
|
|
1560
|
+
...record,
|
|
1561
|
+
failed: true
|
|
1562
|
+
}, log);
|
|
1563
|
+
throw error;
|
|
1564
|
+
}
|
|
1565
|
+
this.writeRecord(directory, record, log);
|
|
1566
|
+
this.prune(directory);
|
|
1567
|
+
return id;
|
|
1568
|
+
}
|
|
1569
|
+
/** Points the containers of pushed images at a tag; Bunny starts a rolling update. */
|
|
1570
|
+
async retag(link, remote, names, tag) {
|
|
1571
|
+
for (const name of names) {
|
|
1572
|
+
const item = remote.find((entry) => entry.name === name);
|
|
1573
|
+
if (!item || item.imageTag === tag) continue;
|
|
1574
|
+
await this.api.mc("PATCH", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(item.id)}`, { imageTag: tag });
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/** Removes records beyond KEEP_DEPLOYS. Their images stay in the registry. */
|
|
1578
|
+
prune(directory) {
|
|
1579
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1580
|
+
for (const record of this.records(directory).slice(20)) {
|
|
1581
|
+
rmSync(join(folder, `${record.id}.json`), { force: true });
|
|
1582
|
+
rmSync(join(folder, `${record.id}.log`), { force: true });
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
async rollback(link, to) {
|
|
1586
|
+
const directory = projectDirectory(link);
|
|
1587
|
+
const records = this.records(directory);
|
|
1588
|
+
const containers = appContainers(await this.app(link));
|
|
1589
|
+
const pushed = containers.filter((item) => isPushed(item, link));
|
|
1590
|
+
const currentTag = pushed[0]?.imageTag;
|
|
1591
|
+
let target;
|
|
1592
|
+
if (to) {
|
|
1593
|
+
target = records.find((item) => item.id === to || item.tag === to);
|
|
1594
|
+
if (!target) throw new Error(`There is no deploy ${to} in ${BUNNY_DEPLOYS_DIR}.`);
|
|
1595
|
+
} else {
|
|
1596
|
+
target = records.find((item) => !item.failed && item.tag !== currentTag);
|
|
1597
|
+
if (!target) throw new Error("There is no earlier deploy to roll back to.");
|
|
1598
|
+
}
|
|
1599
|
+
if (target.failed) throw new Error(`Deploy ${target.id} failed, so its images may not exist.`);
|
|
1600
|
+
const id = deployId(this.now(), "rollback");
|
|
1601
|
+
await this.retag(link, containers, pushed.map((item) => item.name), target.tag);
|
|
1602
|
+
this.writeRecord(directory, {
|
|
1603
|
+
id,
|
|
1604
|
+
tag: target.tag,
|
|
1605
|
+
commit: target.commit,
|
|
1606
|
+
message: `Rollback to ${target.id}`,
|
|
1607
|
+
createdAt: this.now().toISOString(),
|
|
1608
|
+
rollbackOf: target.id
|
|
1609
|
+
}, [`Bunny is rolling out ${target.tag}`]);
|
|
1610
|
+
return id;
|
|
1611
|
+
}
|
|
1612
|
+
/** The tag the app's own images run, and Bunny's state. */
|
|
1613
|
+
async current(link) {
|
|
1614
|
+
const app = await this.app(link);
|
|
1615
|
+
return {
|
|
1616
|
+
tag: appContainers(app).filter((item) => isPushed(item, link))[0]?.imageTag,
|
|
1617
|
+
status: appStatusText(app)
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
statusOf(record, latest, current) {
|
|
1621
|
+
if (record.failed) return "failed";
|
|
1622
|
+
if (record.tag === current.tag && latest) {
|
|
1623
|
+
if (bunnyBusy(current.status)) return "building";
|
|
1624
|
+
const state = bunnyState(current.status);
|
|
1625
|
+
if (state === "running" || state === "unknown") return "finished";
|
|
1626
|
+
return "failed";
|
|
1627
|
+
}
|
|
1628
|
+
if (latest) return this.now().getTime() - new Date(record.createdAt).getTime() < 12e4 ? "queued" : "failed";
|
|
1629
|
+
return "finished";
|
|
1630
|
+
}
|
|
1631
|
+
async deployment(link, id) {
|
|
1632
|
+
const directory = projectDirectory(link);
|
|
1633
|
+
const records = this.records(directory);
|
|
1634
|
+
const record = records.find((item) => item.id === id);
|
|
1635
|
+
if (!record) throw new Error(`There is no deploy ${id} in ${BUNNY_DEPLOYS_DIR}.`);
|
|
1636
|
+
const current = await this.current(link);
|
|
1637
|
+
const logFile = join(directory, BUNNY_DEPLOYS_DIR, `${id}.log`);
|
|
1638
|
+
const log = existsSync(logFile) ? readFileSync(logFile, "utf8").replace(/\n$/, "") : "";
|
|
1639
|
+
return {
|
|
1640
|
+
...toDeployment$1(record, this.statusOf(record, records[0]?.id === id, current), link),
|
|
1641
|
+
log: log ? log.split("\n").map((line) => ({
|
|
1642
|
+
text: line,
|
|
1643
|
+
stderr: false
|
|
1644
|
+
})) : []
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
async deployments(link, limit) {
|
|
1648
|
+
const records = this.records(projectDirectory(link)).slice(0, Math.max(1, limit));
|
|
1649
|
+
if (!records.length) return [];
|
|
1650
|
+
const current = await this.current(link);
|
|
1651
|
+
return records.map((record, index) => ({
|
|
1652
|
+
...toDeployment$1(record, this.statusOf(record, index === 0, current), link),
|
|
1653
|
+
log: []
|
|
1654
|
+
}));
|
|
1655
|
+
}
|
|
1656
|
+
async status(link) {
|
|
1657
|
+
const word = appStatusText(await this.app(link));
|
|
1658
|
+
const state = bunnyBusy(word) ? "degraded" : bunnyState(word);
|
|
1659
|
+
const details = [word ?? "no status"];
|
|
1660
|
+
const domains = [];
|
|
1661
|
+
if (link.application) try {
|
|
1662
|
+
const zone = await this.pullZone(link.application);
|
|
1663
|
+
for (const item of listOf(zone.Hostnames)) {
|
|
1664
|
+
const host = text(item.Value);
|
|
1665
|
+
if (!host || item.IsSystemHostname === true || host.endsWith(".b-cdn.net")) continue;
|
|
1666
|
+
domains.push(`https://${host}`);
|
|
1667
|
+
if (item.HasCertificate !== true) details.push(`no certificate for ${host} yet: check its DNS record`);
|
|
1668
|
+
}
|
|
1669
|
+
} catch {
|
|
1670
|
+
details.push("could not read the CDN pull zone");
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
state,
|
|
1674
|
+
detail: details.join(", "),
|
|
1675
|
+
domains,
|
|
1676
|
+
url: dashboardUrl(link)
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
function missingContainer(name) {
|
|
1681
|
+
return `The app on Bunny has no container ${name}. compose.yaml changed since setup: run dstack deploy setup --force to create the app again.`;
|
|
1682
|
+
}
|
|
1683
|
+
/** Whether a Bunny container runs an image this project pushes: one in the account's namespace. */
|
|
1684
|
+
function isPushed(container, link) {
|
|
1685
|
+
return container.imageNamespace === link.environment;
|
|
1686
|
+
}
|
|
1687
|
+
/** The project directory, where the deploy records are. */
|
|
1688
|
+
function projectDirectory(link) {
|
|
1689
|
+
return link.directory ?? process.cwd();
|
|
1690
|
+
}
|
|
1691
|
+
function dashboardUrl(link) {
|
|
1692
|
+
return `https://dash.bunny.net/magic-containers/apps/${encodeURIComponent(link.project)}`;
|
|
1693
|
+
}
|
|
1694
|
+
function toDeployment$1(record, status, link) {
|
|
1695
|
+
return {
|
|
1696
|
+
id: record.id,
|
|
1697
|
+
status,
|
|
1698
|
+
commit: record.commit,
|
|
1699
|
+
message: record.message,
|
|
1700
|
+
createdAt: record.createdAt,
|
|
1701
|
+
url: dashboardUrl(link)
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
/** The variables of .env.production, or of .env on a machine where that is the production one. */
|
|
1705
|
+
function productionEnv(directory) {
|
|
1706
|
+
for (const name of [".env.production", ".env"]) {
|
|
1707
|
+
const file = join(directory, name);
|
|
1708
|
+
if (existsSync(file)) return parseEnv(readFileSync(file, "utf8"));
|
|
1709
|
+
}
|
|
1710
|
+
throw new Error(".env.production does not exist. Run dstack production --domain example.com first.");
|
|
1711
|
+
}
|
|
1712
|
+
const bunny = {
|
|
1713
|
+
id: "bunny",
|
|
1714
|
+
name: "Bunny Magic Containers",
|
|
1715
|
+
auth: {
|
|
1716
|
+
kind: "token",
|
|
1717
|
+
env: "DSTACK_BUNNY_TOKEN",
|
|
1718
|
+
help: () => "Copy the API key of your bunny.net account from https://dash.bunny.net/account/api-key. It also logs in to Bunny Container Registry."
|
|
1719
|
+
},
|
|
1720
|
+
source: "local",
|
|
1721
|
+
target: {
|
|
1722
|
+
label: "Bunny API",
|
|
1723
|
+
placeholder: BUNNY_API,
|
|
1724
|
+
fixed: BUNNY_API,
|
|
1725
|
+
normalize: () => BUNNY_API
|
|
1726
|
+
},
|
|
1727
|
+
platformEnv: BUNNY_PLATFORM_ENV,
|
|
1728
|
+
setupFlags: [
|
|
1729
|
+
{
|
|
1730
|
+
name: "region",
|
|
1731
|
+
description: "Bunny: the region of the app, such as DE (default: the closest)"
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
name: "volumeSize",
|
|
1735
|
+
description: "Bunny: the size of the database volume in GB (default: 10)"
|
|
1736
|
+
},
|
|
1737
|
+
{
|
|
1738
|
+
name: "account",
|
|
1739
|
+
description: "Bunny: the account id, the namespace in Bunny Container Registry"
|
|
1740
|
+
}
|
|
1741
|
+
],
|
|
1742
|
+
printsDns: true,
|
|
1743
|
+
connect: (_target, token) => {
|
|
1744
|
+
if (!token) throw new Error("Bunny needs an API key.");
|
|
1745
|
+
return new BunnyClient(token);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
// src/deploy/provider.ts
|
|
1749
|
+
/** The target of a platform with a web API and an API key. */
|
|
1750
|
+
function urlTarget(name, placeholder) {
|
|
1751
|
+
return {
|
|
1752
|
+
label: `${name} URL`,
|
|
1753
|
+
placeholder,
|
|
1754
|
+
normalize: normalizeUrl
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
/** Normalizes an instance URL: https by default, no trailing slash or path. */
|
|
1758
|
+
function normalizeUrl(value) {
|
|
1759
|
+
const trimmed = value.trim();
|
|
1760
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1761
|
+
let parsed;
|
|
1762
|
+
try {
|
|
1763
|
+
parsed = new URL(withScheme);
|
|
1764
|
+
} catch {
|
|
1765
|
+
throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
|
|
1766
|
+
}
|
|
1767
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
1768
|
+
}
|
|
1769
|
+
// src/deploy/coolify.ts
|
|
1770
|
+
/**
|
|
1771
|
+
* Coolify (https://coolify.io), through its API at /api/v1 with a bearer
|
|
1772
|
+
* token. The project deploys as a Docker Compose application that Coolify
|
|
1773
|
+
* builds on the server from the git repository; Coolify's proxy gets the
|
|
1774
|
+
* certificate and forwards to the `proxy` service (Caddy) on port 80.
|
|
1775
|
+
*/
|
|
1776
|
+
/** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
|
|
1777
|
+
const PLATFORM_ENV = {
|
|
1778
|
+
CADDY_ADDRESS: ":80",
|
|
1779
|
+
TRUSTED_PROXIES: "private_ranges",
|
|
1780
|
+
NUM_PROXIES: "2",
|
|
1781
|
+
HTTP_PORT: "127.0.0.1:8080",
|
|
1782
|
+
HTTPS_PORT: "127.0.0.1:8443"
|
|
1783
|
+
};
|
|
1784
|
+
/** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
|
|
1785
|
+
const SHARED_SETUP_FLAGS = [
|
|
1786
|
+
{
|
|
1787
|
+
name: "server",
|
|
1788
|
+
description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
name: "project",
|
|
1792
|
+
description: "The platform's project, by name or id (default: the project name)"
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
name: "environment",
|
|
1796
|
+
description: "The platform's environment (default: production)"
|
|
1797
|
+
}
|
|
1798
|
+
];
|
|
1799
|
+
var CoolifyError = class extends Error {
|
|
1800
|
+
status;
|
|
1801
|
+
constructor(message, status) {
|
|
1802
|
+
super(message);
|
|
1803
|
+
this.status = status;
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
var CoolifyApi = class {
|
|
1807
|
+
url;
|
|
1808
|
+
token;
|
|
1809
|
+
fetchImpl;
|
|
1810
|
+
constructor(url, token, fetchImpl = fetch) {
|
|
1811
|
+
this.url = url;
|
|
1812
|
+
this.token = token;
|
|
1813
|
+
this.fetchImpl = fetchImpl;
|
|
1814
|
+
}
|
|
1815
|
+
async request(method, path, body) {
|
|
1816
|
+
let response;
|
|
1817
|
+
try {
|
|
1818
|
+
response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
|
|
1819
|
+
method,
|
|
1820
|
+
headers: {
|
|
1821
|
+
Authorization: `Bearer ${this.token}`,
|
|
1822
|
+
Accept: "application/json",
|
|
1823
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1824
|
+
},
|
|
1825
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
1826
|
+
signal: AbortSignal.timeout(3e4)
|
|
1827
|
+
});
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
1830
|
+
}
|
|
1831
|
+
const text = await response.text();
|
|
1832
|
+
if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
|
|
1833
|
+
if (!text) return void 0;
|
|
1834
|
+
try {
|
|
1835
|
+
return JSON.parse(text);
|
|
1836
|
+
} catch {
|
|
1837
|
+
return text;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
get = (path) => this.request("GET", path);
|
|
1841
|
+
post = (path, body) => this.request("POST", path, body ?? {});
|
|
1842
|
+
patch = (path, body) => this.request("PATCH", path, body);
|
|
1843
|
+
};
|
|
1844
|
+
function errorMessage$1(status, text, url) {
|
|
1845
|
+
if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
1846
|
+
if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
|
|
1847
|
+
if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
|
|
1848
|
+
let message = "";
|
|
1849
|
+
try {
|
|
1850
|
+
const body = JSON.parse(text);
|
|
1851
|
+
message = body.message ?? "";
|
|
1852
|
+
if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
|
|
1853
|
+
} catch {
|
|
1854
|
+
message = text.slice(0, 200);
|
|
1855
|
+
}
|
|
1856
|
+
return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
|
|
1857
|
+
}
|
|
1858
|
+
const enc = encodeURIComponent;
|
|
1859
|
+
function deploymentStatus(value) {
|
|
1860
|
+
if (value === "queued") return "queued";
|
|
1861
|
+
if (value === "in_progress") return "building";
|
|
1862
|
+
if (value === "finished") return "finished";
|
|
1863
|
+
if (value.startsWith("cancelled")) return "cancelled";
|
|
1864
|
+
return "failed";
|
|
1865
|
+
}
|
|
1866
|
+
function appState(value) {
|
|
1867
|
+
const [state = "", health = ""] = (value ?? "").split(/[:(]/);
|
|
1868
|
+
if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
|
|
1869
|
+
if (state === "degraded" || state === "restarting") return "degraded";
|
|
1870
|
+
if (state === "exited" || state === "stopped") return "stopped";
|
|
1871
|
+
return "unknown";
|
|
1872
|
+
}
|
|
1873
|
+
/** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
|
|
1874
|
+
function parseLog(logs) {
|
|
1875
|
+
if (!logs) return [];
|
|
1876
|
+
let entries;
|
|
1877
|
+
try {
|
|
1878
|
+
entries = JSON.parse(logs);
|
|
1879
|
+
} catch {
|
|
1880
|
+
return logs.split("\n").map((text) => ({
|
|
1881
|
+
text,
|
|
1882
|
+
stderr: false
|
|
1883
|
+
}));
|
|
1884
|
+
}
|
|
1885
|
+
if (!Array.isArray(entries)) return [];
|
|
1886
|
+
return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
|
|
1887
|
+
text,
|
|
1888
|
+
stderr: entry.type === "stderr"
|
|
1889
|
+
})));
|
|
1890
|
+
}
|
|
1891
|
+
/** The domains of a Compose application, by service. */
|
|
1892
|
+
function composeDomains(app) {
|
|
1893
|
+
let value = app.docker_compose_domains;
|
|
1894
|
+
if (typeof value === "string") try {
|
|
1895
|
+
value = JSON.parse(value);
|
|
1896
|
+
} catch {
|
|
1897
|
+
value = null;
|
|
1898
|
+
}
|
|
1899
|
+
const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
|
|
1900
|
+
if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
|
|
1901
|
+
return domains.map((domain) => domain.replace(/:\d+$/, ""));
|
|
1902
|
+
}
|
|
1903
|
+
function appUrl(api, link) {
|
|
1904
|
+
return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
|
|
1905
|
+
}
|
|
1906
|
+
function toDeployment(api, link, item) {
|
|
1907
|
+
const path = item.deployment_url;
|
|
1908
|
+
return {
|
|
1909
|
+
id: item.deployment_uuid,
|
|
1910
|
+
status: deploymentStatus(item.status),
|
|
1911
|
+
commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
|
|
1912
|
+
message: item.commit_message?.split("\n")[0] || void 0,
|
|
1913
|
+
createdAt: item.created_at,
|
|
1914
|
+
updatedAt: item.updated_at,
|
|
1915
|
+
url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
|
|
1916
|
+
log: parseLog(item.logs)
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
/** Finds a name or uuid in a list. */
|
|
1920
|
+
function find$1(items, wanted) {
|
|
1921
|
+
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
1922
|
+
}
|
|
1923
|
+
var CoolifyClient = class {
|
|
1924
|
+
api;
|
|
1925
|
+
constructor(url, token, fetchImpl) {
|
|
1926
|
+
this.api = new CoolifyApi(url, token, fetchImpl);
|
|
1927
|
+
}
|
|
1928
|
+
async verify() {
|
|
1929
|
+
const version = await this.api.get("/version");
|
|
1930
|
+
await this.api.get("/servers");
|
|
1931
|
+
return String(version).trim();
|
|
1932
|
+
}
|
|
1933
|
+
async setup(context) {
|
|
1934
|
+
const { ask, options } = context;
|
|
1935
|
+
if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
|
|
1936
|
+
const api = this.api;
|
|
1937
|
+
const servers = await api.get("/servers");
|
|
1938
|
+
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
1939
|
+
let server;
|
|
1940
|
+
if (options.server) {
|
|
1941
|
+
server = find$1(servers, options.server);
|
|
1942
|
+
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
1943
|
+
} else if (servers.length === 1) server = servers[0];
|
|
1944
|
+
else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
1945
|
+
value: item.uuid,
|
|
1946
|
+
label: item.name,
|
|
1947
|
+
hint: item.ip
|
|
1948
|
+
}))));
|
|
1949
|
+
if (!server) throw new Error("No server chosen.");
|
|
1950
|
+
const projects = await api.get("/projects");
|
|
1951
|
+
let project;
|
|
1952
|
+
const projectName = options.project ?? context.name;
|
|
1953
|
+
project = find$1(projects, projectName);
|
|
1954
|
+
if (!options.project && ask.interactive && projects.length) {
|
|
1955
|
+
const NEW = "";
|
|
1956
|
+
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
1957
|
+
value: NEW,
|
|
1958
|
+
label: `Create ${projectName}`
|
|
1959
|
+
}], ...projects.map((item) => ({
|
|
1960
|
+
value: item.uuid,
|
|
1961
|
+
label: item.name,
|
|
1962
|
+
hint: item.description ?? void 0
|
|
1963
|
+
}))], project?.uuid ?? NEW);
|
|
1964
|
+
project = uuid === NEW ? void 0 : find$1(projects, uuid);
|
|
1965
|
+
}
|
|
1966
|
+
if (!project) {
|
|
1967
|
+
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
1968
|
+
project = {
|
|
1969
|
+
uuid: (await api.post("/projects", {
|
|
1970
|
+
name: projectName,
|
|
1971
|
+
description: "DjangoStack"
|
|
1972
|
+
})).uuid,
|
|
1973
|
+
name: projectName
|
|
1974
|
+
};
|
|
1975
|
+
ask.step(`Created the Coolify project ${projectName}`);
|
|
1976
|
+
}
|
|
1977
|
+
const environmentName = options.environment ?? "production";
|
|
1978
|
+
let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
1979
|
+
if (!environment) {
|
|
1980
|
+
environment = {
|
|
1981
|
+
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
1982
|
+
name: environmentName
|
|
1983
|
+
};
|
|
1984
|
+
ask.step(`Created the environment ${environmentName}`);
|
|
1985
|
+
}
|
|
1986
|
+
let githubApp;
|
|
1987
|
+
if (options.githubApp && options.githubApp !== "public") {
|
|
1988
|
+
const app = find$1(await api.get("/github-apps"), options.githubApp);
|
|
1989
|
+
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
1990
|
+
githubApp = app.uuid;
|
|
1991
|
+
} else if (!options.githubApp && ask.interactive) {
|
|
1992
|
+
const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
|
|
1993
|
+
if (apps.length) {
|
|
1994
|
+
const choice = await ask.select("How Coolify gets the code", [{
|
|
1995
|
+
value: "public",
|
|
1996
|
+
label: "Public repository",
|
|
1997
|
+
hint: "no access needed"
|
|
1998
|
+
}, ...apps.map((app) => ({
|
|
1999
|
+
value: app.uuid,
|
|
2000
|
+
label: `GitHub App ${app.name}`,
|
|
2001
|
+
hint: "private repository"
|
|
2002
|
+
}))], "public");
|
|
2003
|
+
githubApp = choice === "public" ? void 0 : choice;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
const body = {
|
|
2007
|
+
project_uuid: project.uuid,
|
|
2008
|
+
server_uuid: server.uuid,
|
|
2009
|
+
environment_uuid: environment.uuid,
|
|
2010
|
+
git_repository: context.repository,
|
|
2011
|
+
git_branch: context.branch,
|
|
2012
|
+
build_pack: "dockercompose",
|
|
2013
|
+
docker_compose_location: "/compose.yaml",
|
|
2014
|
+
docker_compose_domains: [{
|
|
2015
|
+
name: "proxy",
|
|
2016
|
+
domain: `https://${context.domain}:80`
|
|
2017
|
+
}],
|
|
2018
|
+
name: context.name,
|
|
2019
|
+
description: "DjangoStack",
|
|
2020
|
+
instant_deploy: false,
|
|
2021
|
+
...githubApp ? { github_app_uuid: githubApp } : {}
|
|
2022
|
+
};
|
|
2023
|
+
const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
|
|
2024
|
+
ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
|
|
2025
|
+
const link = {
|
|
2026
|
+
provider: "coolify",
|
|
2027
|
+
url: api.url,
|
|
2028
|
+
project: project.uuid,
|
|
2029
|
+
environment: environment.uuid,
|
|
2030
|
+
application: created.uuid
|
|
2031
|
+
};
|
|
2032
|
+
await this.pushEnv(link, context.env);
|
|
2033
|
+
ask.step(`Set ${Object.keys(context.env).length} environment variables`);
|
|
2034
|
+
return {
|
|
2035
|
+
project: link.project,
|
|
2036
|
+
environment: link.environment,
|
|
2037
|
+
application: link.application
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
async pushEnv(link, env) {
|
|
2041
|
+
const data = Object.entries(env).map(([key, value]) => ({
|
|
2042
|
+
key,
|
|
2043
|
+
value,
|
|
2044
|
+
is_literal: true,
|
|
2045
|
+
is_preview: false
|
|
2046
|
+
}));
|
|
2047
|
+
await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
|
|
2048
|
+
}
|
|
2049
|
+
async deploy(link, options) {
|
|
2050
|
+
const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
|
|
2051
|
+
const result = await this.api.post(`/deploy?${query}`);
|
|
2052
|
+
const id = result?.deployments?.[0]?.deployment_uuid;
|
|
2053
|
+
if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
|
|
2054
|
+
return id;
|
|
684
2055
|
}
|
|
685
2056
|
async deployment(link, id) {
|
|
686
|
-
|
|
687
|
-
const deploys = shellQuote(`${link.project}/deploys`);
|
|
688
|
-
const output = await this.sh(`
|
|
689
|
-
cd ${deploys} || exit 1
|
|
690
|
-
[ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
|
|
691
|
-
cat ${shellQuote(`${id}.json`)}
|
|
692
|
-
pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
|
|
693
|
-
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
694
|
-
printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
695
|
-
echo ${MARK}
|
|
696
|
-
cat ${shellQuote(`${id}.log`)} 2>/dev/null
|
|
697
|
-
`);
|
|
698
|
-
if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
|
|
699
|
-
const [head = "", log = ""] = output.split(`\n${MARK}\n`);
|
|
700
|
-
const [json = "{}", state = ""] = head.split("\n");
|
|
701
|
-
const record = JSON.parse(json);
|
|
702
|
-
const [status = "", alive = "0", updatedAt] = state.split(" ");
|
|
703
|
-
return {
|
|
704
|
-
...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
|
|
705
|
-
log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
|
|
706
|
-
text,
|
|
707
|
-
stderr: false
|
|
708
|
-
})) : []
|
|
709
|
-
};
|
|
2057
|
+
return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
710
2058
|
}
|
|
711
2059
|
async deployments(link, limit) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
log: []
|
|
715
|
-
}));
|
|
2060
|
+
const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
|
|
2061
|
+
return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
|
|
716
2062
|
}
|
|
717
2063
|
async status(link) {
|
|
718
|
-
const
|
|
719
|
-
docker compose -p ${shellQuote(link.application)} ps --all --format json
|
|
720
|
-
echo ${MARK}
|
|
721
|
-
sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
|
|
722
|
-
`));
|
|
723
|
-
const { state, detail } = composeState(parseComposePs(ps));
|
|
724
|
-
const domain = site.trim().replace(/^["']|["']$/g, "");
|
|
2064
|
+
const app = await this.api.get(`/applications/${enc(link.application)}`);
|
|
725
2065
|
return {
|
|
726
|
-
state,
|
|
727
|
-
detail,
|
|
728
|
-
domains:
|
|
2066
|
+
state: appState(app.status),
|
|
2067
|
+
detail: app.status ?? "unknown",
|
|
2068
|
+
domains: composeDomains(app),
|
|
2069
|
+
branch: app.git_branch,
|
|
2070
|
+
url: appUrl(this.api, link)
|
|
729
2071
|
};
|
|
730
2072
|
}
|
|
731
2073
|
};
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
updatedAt
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
/**
|
|
743
|
-
* A value for a Compose .env line: bare when safe, else single-quoted (taken
|
|
744
|
-
* literally), or double-quoted with escapes when it holds a single quote.
|
|
745
|
-
*/
|
|
746
|
-
function envValue(value) {
|
|
747
|
-
if (/^[\w@%+=:,./-]*$/.test(value)) return value;
|
|
748
|
-
if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
|
|
749
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
|
|
750
|
-
}
|
|
751
|
-
function defaultGit(args) {
|
|
752
|
-
return execFileSync("git", args, {
|
|
753
|
-
maxBuffer: 1073741824,
|
|
754
|
-
stdio: [
|
|
755
|
-
"ignore",
|
|
756
|
-
"pipe",
|
|
757
|
-
"pipe"
|
|
758
|
-
]
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
const ssh = {
|
|
762
|
-
id: "ssh",
|
|
763
|
-
name: "SSH server",
|
|
764
|
-
auth: { kind: "ssh" },
|
|
765
|
-
source: "local",
|
|
766
|
-
target: {
|
|
767
|
-
label: "Server",
|
|
768
|
-
placeholder: "deploy@example.com",
|
|
769
|
-
normalize: (value) => formatTarget(parseTarget(value))
|
|
2074
|
+
const coolify = {
|
|
2075
|
+
id: "coolify",
|
|
2076
|
+
name: "Coolify",
|
|
2077
|
+
auth: {
|
|
2078
|
+
kind: "token",
|
|
2079
|
+
env: "DSTACK_COOLIFY_TOKEN",
|
|
2080
|
+
help: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`
|
|
770
2081
|
},
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
2082
|
+
source: "remote",
|
|
2083
|
+
target: urlTarget("Coolify", "https://coolify.example.com"),
|
|
2084
|
+
platformEnv: PLATFORM_ENV,
|
|
2085
|
+
setupFlags: [...SHARED_SETUP_FLAGS, {
|
|
2086
|
+
name: "githubApp",
|
|
2087
|
+
description: "A Coolify GitHub App for a private repository, or public"
|
|
775
2088
|
}],
|
|
776
|
-
connect: (
|
|
2089
|
+
connect: (url, token) => {
|
|
2090
|
+
if (!token) throw new Error("Coolify needs an API key.");
|
|
2091
|
+
return new CoolifyClient(url, token);
|
|
2092
|
+
}
|
|
777
2093
|
};
|
|
778
2094
|
// src/deploy/dokploy.ts
|
|
779
2095
|
var DokployError = class extends Error {
|
|
@@ -1164,123 +2480,14 @@ function deleteToken(provider, url) {
|
|
|
1164
2480
|
} catch {}
|
|
1165
2481
|
return removeFromFile(account) || removed;
|
|
1166
2482
|
}
|
|
1167
|
-
// src/deploy/link.ts
|
|
1168
|
-
/** Where a project deploys, relative to the project directory. It holds no secrets and can be committed. */
|
|
1169
|
-
const LINK_FILE = ".dstack/deploy.json";
|
|
1170
|
-
function readLink(directory) {
|
|
1171
|
-
const path = join(directory, LINK_FILE);
|
|
1172
|
-
if (!existsSync(path)) return void 0;
|
|
1173
|
-
let link;
|
|
1174
|
-
try {
|
|
1175
|
-
link = JSON.parse(readFileSync(path, "utf8"));
|
|
1176
|
-
} catch {
|
|
1177
|
-
throw new Error(`${LINK_FILE} is not valid JSON.`);
|
|
1178
|
-
}
|
|
1179
|
-
for (const key of [
|
|
1180
|
-
"provider",
|
|
1181
|
-
"url",
|
|
1182
|
-
"project",
|
|
1183
|
-
"environment",
|
|
1184
|
-
"application"
|
|
1185
|
-
]) if (typeof link[key] !== "string" || !link[key]) throw new Error(`${LINK_FILE} has no ${key}.`);
|
|
1186
|
-
return link;
|
|
1187
|
-
}
|
|
1188
|
-
function writeLink(directory, link) {
|
|
1189
|
-
const path = join(directory, LINK_FILE);
|
|
1190
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1191
|
-
const { provider, url, project, environment, application } = link;
|
|
1192
|
-
writeFileSync(path, `${JSON.stringify({
|
|
1193
|
-
provider,
|
|
1194
|
-
url,
|
|
1195
|
-
project,
|
|
1196
|
-
environment,
|
|
1197
|
-
application
|
|
1198
|
-
}, null, 2)}\n`);
|
|
1199
|
-
}
|
|
1200
|
-
/** An https URL for a git remote: git@github.com:owner/repo.git becomes https://github.com/owner/repo. */
|
|
1201
|
-
function httpsRepository(remote) {
|
|
1202
|
-
const value = remote.trim();
|
|
1203
|
-
const scp = value.match(/^[\w.-]+@([\w.-]+):(.+)$/);
|
|
1204
|
-
let url;
|
|
1205
|
-
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
1206
|
-
else try {
|
|
1207
|
-
const parsed = new URL(value);
|
|
1208
|
-
url = `https://${parsed.host.replace(/:\d+$/, "")}${parsed.pathname}`;
|
|
1209
|
-
} catch {
|
|
1210
|
-
throw new Error(`Cannot read the git remote ${value}.`);
|
|
1211
|
-
}
|
|
1212
|
-
return url.replace(/\.git$/, "").replace(/\/$/, "");
|
|
1213
|
-
}
|
|
1214
|
-
function git(directory, args) {
|
|
1215
|
-
try {
|
|
1216
|
-
return execFileSync("git", args, {
|
|
1217
|
-
cwd: directory,
|
|
1218
|
-
encoding: "utf8",
|
|
1219
|
-
stdio: [
|
|
1220
|
-
"ignore",
|
|
1221
|
-
"pipe",
|
|
1222
|
-
"ignore"
|
|
1223
|
-
]
|
|
1224
|
-
}).trim();
|
|
1225
|
-
} catch {
|
|
1226
|
-
return;
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
function gitRemote(directory, remote = "origin") {
|
|
1230
|
-
return git(directory, [
|
|
1231
|
-
"remote",
|
|
1232
|
-
"get-url",
|
|
1233
|
-
remote
|
|
1234
|
-
]) || void 0;
|
|
1235
|
-
}
|
|
1236
|
-
function gitBranch(directory) {
|
|
1237
|
-
const branch = git(directory, [
|
|
1238
|
-
"rev-parse",
|
|
1239
|
-
"--abbrev-ref",
|
|
1240
|
-
"HEAD"
|
|
1241
|
-
]);
|
|
1242
|
-
return branch && branch !== "HEAD" ? branch : void 0;
|
|
1243
|
-
}
|
|
1244
|
-
/**
|
|
1245
|
-
* Why a deploy of `branch` would not include the local commit, or undefined
|
|
1246
|
-
* when origin has it. The platform builds from the remote, not from here.
|
|
1247
|
-
*/
|
|
1248
|
-
function unpushedReason(directory, branch) {
|
|
1249
|
-
const head = git(directory, ["rev-parse", "HEAD"]);
|
|
1250
|
-
if (!head) return void 0;
|
|
1251
|
-
if (git(directory, [
|
|
1252
|
-
"rev-parse",
|
|
1253
|
-
"--verify",
|
|
1254
|
-
"--quiet",
|
|
1255
|
-
`refs/remotes/origin/${branch}`
|
|
1256
|
-
]) === void 0) return `origin has no branch ${branch}. Push it first: git push -u origin ${branch}`;
|
|
1257
|
-
if (git(directory, [
|
|
1258
|
-
"merge-base",
|
|
1259
|
-
"--is-ancestor",
|
|
1260
|
-
head,
|
|
1261
|
-
`refs/remotes/origin/${branch}`
|
|
1262
|
-
]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
|
|
1263
|
-
}
|
|
1264
|
-
/**
|
|
1265
|
-
* Why an upload of HEAD would not match the working tree, or undefined when
|
|
1266
|
-
* it does. Providers that upload the local commit deploy HEAD only, so
|
|
1267
|
-
* changes to tracked files must be committed first.
|
|
1268
|
-
*/
|
|
1269
|
-
function uncommittedReason(directory) {
|
|
1270
|
-
if (git(directory, ["rev-parse", "HEAD"]) === void 0) return "This project has no commit yet. Commit it first: git add -A && git commit";
|
|
1271
|
-
if (git(directory, [
|
|
1272
|
-
"status",
|
|
1273
|
-
"--porcelain",
|
|
1274
|
-
"--untracked-files=no"
|
|
1275
|
-
])) return "You have uncommitted changes, so the deploy would not include them. Commit first.";
|
|
1276
|
-
}
|
|
1277
2483
|
// src/deploy/index.ts
|
|
1278
2484
|
const DEFAULT_PROVIDER = coolify;
|
|
1279
2485
|
/** Every deploy provider. */
|
|
1280
2486
|
const PROVIDERS = [
|
|
1281
2487
|
coolify,
|
|
1282
2488
|
dokploy,
|
|
1283
|
-
ssh
|
|
2489
|
+
ssh,
|
|
2490
|
+
bunny
|
|
1284
2491
|
];
|
|
1285
2492
|
function getProvider(id) {
|
|
1286
2493
|
const provider = PROVIDERS.find((item) => item.id === id);
|
|
@@ -1362,14 +2569,21 @@ function projectManifest(project) {
|
|
|
1362
2569
|
/**
|
|
1363
2570
|
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
1364
2571
|
* for projects from before that, the defaults with the storage from `.env`.
|
|
2572
|
+
* Analytics follow the `analytics` profile: projects from before Umami was
|
|
2573
|
+
* generated have the default in their preset without running it.
|
|
1365
2574
|
*/
|
|
1366
2575
|
function projectStack(project) {
|
|
2576
|
+
const analytics = project.profiles.includes("analytics") ? "umami" : "off";
|
|
1367
2577
|
const { preset } = projectManifest(project);
|
|
1368
2578
|
if (preset) try {
|
|
1369
|
-
return
|
|
2579
|
+
return {
|
|
2580
|
+
...decodePreset(preset),
|
|
2581
|
+
analytics
|
|
2582
|
+
};
|
|
1370
2583
|
} catch {}
|
|
1371
2584
|
return {
|
|
1372
2585
|
...DEFAULT_STACK,
|
|
2586
|
+
analytics,
|
|
1373
2587
|
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
1374
2588
|
};
|
|
1375
2589
|
}
|
|
@@ -1650,7 +2864,7 @@ async function deploySetup(options) {
|
|
|
1650
2864
|
}))
|
|
1651
2865
|
}));
|
|
1652
2866
|
const provider = providerOrFail(providerId);
|
|
1653
|
-
let instance = options.url;
|
|
2867
|
+
let instance = options.url ?? provider.target.fixed;
|
|
1654
2868
|
if (!instance) {
|
|
1655
2869
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1656
2870
|
instance = unwrap(await p.text({
|
|
@@ -1693,6 +2907,7 @@ async function deploySetup(options) {
|
|
|
1693
2907
|
for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
|
|
1694
2908
|
const ids = await attempt(client.setup({
|
|
1695
2909
|
ask: promptAsk(interactive),
|
|
2910
|
+
directory: project.directory,
|
|
1696
2911
|
name: project.name,
|
|
1697
2912
|
repository,
|
|
1698
2913
|
branch,
|
|
@@ -1703,11 +2918,12 @@ async function deploySetup(options) {
|
|
|
1703
2918
|
const link = {
|
|
1704
2919
|
provider: provider.id,
|
|
1705
2920
|
url: instance,
|
|
1706
|
-
...ids
|
|
2921
|
+
...ids,
|
|
2922
|
+
directory: project.directory
|
|
1707
2923
|
};
|
|
1708
2924
|
writeLink(project.directory, link);
|
|
1709
2925
|
p.log.success(`Wrote ${pc.bold(LINK_FILE)}. It holds no secrets; commit it so others deploy to the same place.`);
|
|
1710
|
-
p.note([`Point the DNS of ${pc.bold(domain)} at the ${provider.auth.kind === "ssh" ? "" : `${provider.name} `}server
|
|
2926
|
+
p.note([...provider.printsDns ? [] : [`Point the DNS of ${pc.bold(domain)} at the ${provider.auth.kind === "ssh" ? "" : `${provider.name} `}server.`], provider.auth.kind === "ssh" ? `To change variables, edit ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.` : `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
|
|
1711
2927
|
if (options.deploy ?? (interactive ? unwrap(await p.confirm({
|
|
1712
2928
|
message: "Deploy now?",
|
|
1713
2929
|
initialValue: true
|
|
@@ -1901,7 +3117,7 @@ async function loginTarget(options, interactive) {
|
|
|
1901
3117
|
} catch {}
|
|
1902
3118
|
const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
|
|
1903
3119
|
tokenAuth(provider);
|
|
1904
|
-
let instance = options.url ?? link?.url;
|
|
3120
|
+
let instance = options.url ?? link?.url ?? provider.target.fixed;
|
|
1905
3121
|
if (!instance) {
|
|
1906
3122
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1907
3123
|
instance = unwrap(await p.text({
|
|
@@ -2350,6 +3566,7 @@ async function init(directoryArgument, options) {
|
|
|
2350
3566
|
demoContent,
|
|
2351
3567
|
databaseUrl,
|
|
2352
3568
|
localS3: stack.storage === "s3",
|
|
3569
|
+
analytics: stack.analytics === "umami",
|
|
2353
3570
|
preset: encodePreset(stack),
|
|
2354
3571
|
timeZone,
|
|
2355
3572
|
link: options.link
|
|
@@ -2452,7 +3669,7 @@ async function production(directoryArgument, options) {
|
|
|
2452
3669
|
}
|
|
2453
3670
|
let result;
|
|
2454
3671
|
try {
|
|
2455
|
-
result = productionEnv(readFileSync(envPath, "utf8"), {
|
|
3672
|
+
result = productionEnv$1(readFileSync(envPath, "utf8"), {
|
|
2456
3673
|
domain,
|
|
2457
3674
|
emailUrl: options.emailUrl
|
|
2458
3675
|
});
|
|
@@ -2473,7 +3690,7 @@ async function production(directoryArgument, options) {
|
|
|
2473
3690
|
}
|
|
2474
3691
|
// src/config.ts
|
|
2475
3692
|
/** The options `config` can switch in an existing project. The rest follow with code generation. */
|
|
2476
|
-
const SWITCHABLE = ["storage"];
|
|
3693
|
+
const SWITCHABLE = ["storage", "analytics"];
|
|
2477
3694
|
const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
|
|
2478
3695
|
/**
|
|
2479
3696
|
* Applies a new stack to a project's `.env`. Throws with a one-line reason
|
|
@@ -2482,7 +3699,7 @@ const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key
|
|
|
2482
3699
|
function applyStack(envText, env, from, to) {
|
|
2483
3700
|
let text = envText;
|
|
2484
3701
|
const changes = [];
|
|
2485
|
-
|
|
3702
|
+
let profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
2486
3703
|
for (const option of STACK_OPTIONS) {
|
|
2487
3704
|
const key = option.key;
|
|
2488
3705
|
if (from[key] === to[key]) continue;
|
|
@@ -2493,14 +3710,33 @@ function applyStack(envText, env, from, to) {
|
|
|
2493
3710
|
if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
|
|
2494
3711
|
const endpoint = env.S3_ENDPOINT_URL ?? "";
|
|
2495
3712
|
if (endpoint && !endpoint.includes("s3:8333")) throw new Error(`S3_ENDPOINT_URL points at ${endpoint}; change the S3_* values in .env yourself.`);
|
|
2496
|
-
|
|
2497
|
-
if (to.storage === "s3")
|
|
3713
|
+
profiles = profiles.filter((profile) => profile !== "s3");
|
|
3714
|
+
if (to.storage === "s3") profiles.push("s3");
|
|
2498
3715
|
text = setEnvValues(text, {
|
|
2499
3716
|
...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
|
|
2500
|
-
COMPOSE_PROFILES:
|
|
3717
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
2501
3718
|
});
|
|
2502
3719
|
changes.push(to.storage === "s3" ? "Uploads go to the local S3 service (the s3 profile)." : "Uploads go to a Docker volume; the local S3 service is off.");
|
|
2503
3720
|
}
|
|
3721
|
+
if (key === "analytics") {
|
|
3722
|
+
profiles = profiles.filter((profile) => profile !== "analytics");
|
|
3723
|
+
if (to.analytics === "umami") {
|
|
3724
|
+
profiles.push("analytics");
|
|
3725
|
+
const fresh = umamiEnv(env.DATABASE_URL ?? "", generateProjectSecrets());
|
|
3726
|
+
const kept = Object.fromEntries(Object.entries(fresh).filter(([name]) => name === "UMAMI_URL" || !env[name]));
|
|
3727
|
+
text = setEnvValues(text, {
|
|
3728
|
+
...kept,
|
|
3729
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
3730
|
+
});
|
|
3731
|
+
changes.push("Page analytics with Umami are on (the analytics profile).");
|
|
3732
|
+
} else {
|
|
3733
|
+
text = setEnvValues(text, {
|
|
3734
|
+
UMAMI_URL: "",
|
|
3735
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
3736
|
+
});
|
|
3737
|
+
changes.push("Page analytics are off; Umami's data stays in the database.");
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
2504
3740
|
}
|
|
2505
3741
|
return {
|
|
2506
3742
|
env: text,
|
|
@@ -2539,7 +3775,7 @@ function exitWith(status) {
|
|
|
2539
3775
|
function ensureRunEnv(project) {
|
|
2540
3776
|
const path = join(project.directory, RUN_ENV);
|
|
2541
3777
|
if (!existsSync(path)) {
|
|
2542
|
-
const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
|
|
3778
|
+
const result = productionEnv$1(readFileSync(join(project.directory, ".env"), "utf8"), {
|
|
2543
3779
|
domain: RUN_DOMAIN,
|
|
2544
3780
|
emailUrl: "console://"
|
|
2545
3781
|
});
|
|
@@ -2931,6 +4167,10 @@ async function config(options) {
|
|
|
2931
4167
|
}))
|
|
2932
4168
|
}));
|
|
2933
4169
|
}
|
|
4170
|
+
if (next.analytics === "umami" && current.analytics !== "umami") {
|
|
4171
|
+
const compose = join(project.directory, "compose.yaml");
|
|
4172
|
+
if (!existsSync(compose) || !/^ {2}umami:/m.test(readFileSync(compose, "utf8"))) fail("compose.yaml has no umami service. Add it as the analytics docs describe, then run this again.");
|
|
4173
|
+
}
|
|
2934
4174
|
const envPath = join(project.directory, ".env");
|
|
2935
4175
|
let result;
|
|
2936
4176
|
try {
|
|
@@ -3001,7 +4241,7 @@ function createProgram() {
|
|
|
3001
4241
|
program.command("restore").description("Restore a backup over the database or the media (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("--media", "Also restore the media backed up with this database dump").option("-y, --yes", "Don't ask for confirmation").action(restore);
|
|
3002
4242
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
3003
4243
|
const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
|
|
3004
|
-
const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy or a server over SSH, and show the status");
|
|
4244
|
+
const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
|
|
3005
4245
|
deployCommand.command("start", { isDefault: true }).description("Start a deploy and follow it; the same as `dstack deploy`").addOption(dirOption()).option("--force", "Rebuild the images without cache").option("--no-wait", "Don't follow the deploy; return once it started").option("--no-push-check", "Deploy even when it would not include your local commits or changes").option("-y, --yes", "Don't ask; fail when no API key is saved").action(deploy);
|
|
3006
4246
|
const setupCommand = deployCommand.command("setup").description("Create the project on the platform and link it to this directory").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address, such as https://coolify.example.com, or the server, such as deploy@example.com").option("--repository <url>", "The git repository the platform builds (default: the origin remote)").option("--branch <branch>", "The branch to deploy (default: the current one)").option("--deploy", "Deploy when done").option("--no-deploy", "Don't offer to deploy when done").option("--force", `Set up again, replacing the link in .dstack/deploy.json`).option("-y, --yes", "Don't ask; use the flags and the defaults").action(deploySetup);
|
|
3007
4247
|
const seen = /* @__PURE__ */ new Set();
|