@django-stack/cli 0.5.0 → 0.7.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 +2161 -515
- package/dist/index.d.ts +54 -7
- package/dist/index.js +1 -1
- package/dist/{scaffold-DbgGYEsc.js → production-DBpWWx6i.js} +471 -103
- 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 +18 -4
- package/templates/base/compose.yaml +57 -9
- package/templates/base/env.example +37 -4
- 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 { A as generateSecret, B as isDockerDown, C as applies, D as resolvePreset, E as pendingChoices, F as portValues, H as quoteEnvValue, I as runDoctor, L as checkTools, M as chooseFreePorts, N as describeMove, O as stackOption, P as envPorts, R as dockerNotRunning, S as STACK_OPTIONS, T as encodePreset, U as setEnvValues, V as parseEnv, _ as validateTimeZone, a as LOCAL_S3, b as NAMED_PRESETS, c as lockBackend, d as scaffold, f as timeZones, g as validateProjectName, h as usesValkey, j as DEV_PORTS, k as generateProjectSecrets, m as umamiEnv, n as productionEnv$1, o as cacheEnv, p as toProjectName, r as validateDomain, s as localS3, t as missingS3, u as relativeDirectory, v as CLI_VERSION, w as decodePreset, x as PROMPT_ORDER, y as DEFAULT_STACK, z as dockerRunning } from "./production-DBpWWx6i.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, delimiter, dirname, join, relative, 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).filter(([, definition]) => definition.deploy?.replicas !== 0).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",
|
|
@@ -701,7 +751,7 @@ cat ${shellQuote(`${id}.log`)} 2>/dev/null
|
|
|
701
751
|
const record = JSON.parse(json);
|
|
702
752
|
const [status = "", alive = "0", updatedAt] = state.split(" ");
|
|
703
753
|
return {
|
|
704
|
-
...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
|
|
754
|
+
...toDeployment$2(record, recordStatus(status, alive === "1"), updatedAt || void 0),
|
|
705
755
|
log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
|
|
706
756
|
text,
|
|
707
757
|
stderr: false
|
|
@@ -710,7 +760,7 @@ cat ${shellQuote(`${id}.log`)} 2>/dev/null
|
|
|
710
760
|
}
|
|
711
761
|
async deployments(link, limit) {
|
|
712
762
|
return (await this.records(link, limit)).map((item) => ({
|
|
713
|
-
...toDeployment(item.record, item.status, item.updatedAt),
|
|
763
|
+
...toDeployment$2(item.record, item.status, item.updatedAt),
|
|
714
764
|
log: []
|
|
715
765
|
}));
|
|
716
766
|
}
|
|
@@ -729,7 +779,7 @@ sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/nul
|
|
|
729
779
|
};
|
|
730
780
|
}
|
|
731
781
|
};
|
|
732
|
-
function toDeployment(record, status, updatedAt) {
|
|
782
|
+
function toDeployment$2(record, status, updatedAt) {
|
|
733
783
|
return {
|
|
734
784
|
id: record.id,
|
|
735
785
|
status,
|
|
@@ -768,12 +818,1279 @@ const ssh = {
|
|
|
768
818
|
placeholder: "deploy@example.com",
|
|
769
819
|
normalize: (value) => formatTarget(parseTarget(value))
|
|
770
820
|
},
|
|
771
|
-
platformEnv: {},
|
|
772
|
-
setupFlags: [{
|
|
773
|
-
name: "path",
|
|
774
|
-
description: "The project directory on the server (default: /srv/<project>)"
|
|
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
|
+
if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
|
|
1130
|
+
const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
|
|
1131
|
+
throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
|
|
1132
|
+
}
|
|
1133
|
+
async git(directory, args) {
|
|
1134
|
+
return this.run("git", [
|
|
1135
|
+
"-C",
|
|
1136
|
+
directory,
|
|
1137
|
+
...args
|
|
1138
|
+
]);
|
|
1139
|
+
}
|
|
1140
|
+
/** The short commit and its subject. */
|
|
1141
|
+
async commit(directory) {
|
|
1142
|
+
const result = await this.git(directory, [
|
|
1143
|
+
"log",
|
|
1144
|
+
"-1",
|
|
1145
|
+
"--format=%h%n%s"
|
|
1146
|
+
]);
|
|
1147
|
+
if (result.code !== 0) throw new Error("This project has no commit yet. Commit it first: git add -A && git commit");
|
|
1148
|
+
const [commit = "", message = ""] = result.stdout.trim().split("\n");
|
|
1149
|
+
return {
|
|
1150
|
+
commit,
|
|
1151
|
+
message
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/** Extracts the local commit into a temporary directory, so the images match the commit. */
|
|
1155
|
+
async checkout(directory) {
|
|
1156
|
+
const target = mkdtempSync(join(tmpdir(), "dstack-bunny-"));
|
|
1157
|
+
const archive = await this.git(directory, [
|
|
1158
|
+
"archive",
|
|
1159
|
+
"--format=tar",
|
|
1160
|
+
"-o",
|
|
1161
|
+
join(target, ".archive.tar"),
|
|
1162
|
+
"HEAD"
|
|
1163
|
+
]);
|
|
1164
|
+
if (archive.code !== 0) {
|
|
1165
|
+
rmSync(target, {
|
|
1166
|
+
recursive: true,
|
|
1167
|
+
force: true
|
|
1168
|
+
});
|
|
1169
|
+
throw new Error(`git archive failed: ${archive.stderr.trim().split("\n").at(-1) ?? ""}`);
|
|
1170
|
+
}
|
|
1171
|
+
const extract = await this.run("tar", [
|
|
1172
|
+
"-x",
|
|
1173
|
+
"-f",
|
|
1174
|
+
".archive.tar"
|
|
1175
|
+
], { cwd: target });
|
|
1176
|
+
rmSync(join(target, ".archive.tar"), { force: true });
|
|
1177
|
+
if (extract.code !== 0) {
|
|
1178
|
+
rmSync(target, {
|
|
1179
|
+
recursive: true,
|
|
1180
|
+
force: true
|
|
1181
|
+
});
|
|
1182
|
+
throw new Error(`Could not extract the commit: ${extract.stderr.trim()}`);
|
|
1183
|
+
}
|
|
1184
|
+
return target;
|
|
1185
|
+
}
|
|
1186
|
+
/** The production Compose project, resolved with the variables, as JSON. */
|
|
1187
|
+
async composeConfig(directory, env) {
|
|
1188
|
+
const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
|
|
1189
|
+
const file = join(folder, ".env");
|
|
1190
|
+
writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
|
|
1191
|
+
try {
|
|
1192
|
+
const output = await this.docker([
|
|
1193
|
+
"compose",
|
|
1194
|
+
"--env-file",
|
|
1195
|
+
file,
|
|
1196
|
+
"config",
|
|
1197
|
+
"--format",
|
|
1198
|
+
"json"
|
|
1199
|
+
], {
|
|
1200
|
+
cwd: directory,
|
|
1201
|
+
env: {
|
|
1202
|
+
...env,
|
|
1203
|
+
ENV_FILE: file
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
return JSON.parse(output);
|
|
1207
|
+
} finally {
|
|
1208
|
+
rmSync(folder, {
|
|
1209
|
+
recursive: true,
|
|
1210
|
+
force: true
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
async plan(directory, env) {
|
|
1215
|
+
return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
|
|
1216
|
+
}
|
|
1217
|
+
/** Builds an image for linux/amd64 and pushes it to Bunny Container Registry. */
|
|
1218
|
+
async buildAndPush(build, reference, directory) {
|
|
1219
|
+
if (build.kind === "build") await this.docker([
|
|
1220
|
+
"build",
|
|
1221
|
+
"--platform",
|
|
1222
|
+
"linux/amd64",
|
|
1223
|
+
...build.target ? ["--target", build.target] : [],
|
|
1224
|
+
...build.dockerfile ? ["-f", resolve(build.context, build.dockerfile)] : [],
|
|
1225
|
+
...Object.entries(build.args ?? {}).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
|
|
1226
|
+
"-t",
|
|
1227
|
+
reference,
|
|
1228
|
+
build.context
|
|
1229
|
+
], {
|
|
1230
|
+
cwd: directory,
|
|
1231
|
+
stream: true
|
|
1232
|
+
});
|
|
1233
|
+
else {
|
|
1234
|
+
const context = mkdtempSync(join(tmpdir(), "dstack-bake-"));
|
|
1235
|
+
try {
|
|
1236
|
+
const lines = [`FROM ${build.from}`];
|
|
1237
|
+
build.files.forEach((file, index) => {
|
|
1238
|
+
writeFileSync(join(context, `file-${index}`), file.content);
|
|
1239
|
+
lines.push(`COPY file-${index} ${file.target}`);
|
|
1240
|
+
});
|
|
1241
|
+
writeFileSync(join(context, "Dockerfile"), `${lines.join("\n")}\n`);
|
|
1242
|
+
await this.docker([
|
|
1243
|
+
"build",
|
|
1244
|
+
"--platform",
|
|
1245
|
+
"linux/amd64",
|
|
1246
|
+
"-t",
|
|
1247
|
+
reference,
|
|
1248
|
+
context
|
|
1249
|
+
], { stream: true });
|
|
1250
|
+
} finally {
|
|
1251
|
+
rmSync(context, {
|
|
1252
|
+
recursive: true,
|
|
1253
|
+
force: true
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const config = mkdtempSync(join(tmpdir(), "dstack-docker-"));
|
|
1258
|
+
try {
|
|
1259
|
+
await this.docker([
|
|
1260
|
+
"login",
|
|
1261
|
+
BUNNY_REGISTRY,
|
|
1262
|
+
"--username",
|
|
1263
|
+
"token",
|
|
1264
|
+
"--password-stdin"
|
|
1265
|
+
], {
|
|
1266
|
+
env: { DOCKER_CONFIG: config },
|
|
1267
|
+
input: this.token
|
|
1268
|
+
});
|
|
1269
|
+
await this.docker(["push", reference], {
|
|
1270
|
+
env: { DOCKER_CONFIG: config },
|
|
1271
|
+
stream: true
|
|
1272
|
+
});
|
|
1273
|
+
} finally {
|
|
1274
|
+
rmSync(config, {
|
|
1275
|
+
recursive: true,
|
|
1276
|
+
force: true
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/** Builds and pushes every image of the local commit; returns the plan of the commit. */
|
|
1281
|
+
async pushImages(directory, env, namespace, tag, log) {
|
|
1282
|
+
const checkout = await this.checkout(directory);
|
|
1283
|
+
try {
|
|
1284
|
+
const plan = await this.plan(checkout, env);
|
|
1285
|
+
for (const build of plan.builds) {
|
|
1286
|
+
const reference = `${BUNNY_REGISTRY}/${namespace}/${build.repository}:${tag}`;
|
|
1287
|
+
log(`Building and pushing ${reference}`);
|
|
1288
|
+
await this.buildAndPush(build, reference, checkout);
|
|
1289
|
+
}
|
|
1290
|
+
return plan;
|
|
1291
|
+
} finally {
|
|
1292
|
+
rmSync(checkout, {
|
|
1293
|
+
recursive: true,
|
|
1294
|
+
force: true
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async registries() {
|
|
1299
|
+
const list = listOf(await this.api.mc("GET", "/registries"));
|
|
1300
|
+
const registries = {};
|
|
1301
|
+
let namespace;
|
|
1302
|
+
for (const item of list) {
|
|
1303
|
+
const type = (text(item.type) ?? "").toLowerCase();
|
|
1304
|
+
const id = text(item.id);
|
|
1305
|
+
if (!id) continue;
|
|
1306
|
+
if (type === "dockerhub" && !registries.dockerHub) registries.dockerHub = id;
|
|
1307
|
+
else if (type === "github" && !registries.gitHub) registries.gitHub = id;
|
|
1308
|
+
else if ((type === "bunnyregistry" || type === "bunny") && !registries.bunny) {
|
|
1309
|
+
registries.bunny = id;
|
|
1310
|
+
namespace = text(item.namespace ?? item.accountId ?? item.userName ?? item.username);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return {
|
|
1314
|
+
registries,
|
|
1315
|
+
namespace
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
async ensureRegistries() {
|
|
1319
|
+
const found = await this.registries();
|
|
1320
|
+
if (!found.registries.dockerHub) throw new Error("Bunny lists no public Docker Hub registry for this account. Check Magic Containers › Image Registries.");
|
|
1321
|
+
if (!found.registries.bunny) {
|
|
1322
|
+
const created = await this.api.mc("POST", "/registries", {
|
|
1323
|
+
displayName: "Bunny Container Registry",
|
|
1324
|
+
type: "bunnyRegistry"
|
|
1325
|
+
});
|
|
1326
|
+
const id = text(created?.id) ?? text(findKey(created, /^id$/i));
|
|
1327
|
+
if (!id) throw new Error("Could not add Bunny Container Registry to Magic Containers.");
|
|
1328
|
+
found.registries.bunny = id;
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
registries: found.registries,
|
|
1332
|
+
namespace: found.namespace
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
async app(link) {
|
|
1336
|
+
return await this.api.mc("GET", `/apps/${encodeURIComponent(link.project)}`) ?? {};
|
|
1337
|
+
}
|
|
1338
|
+
async defaultEdgeServers() {
|
|
1339
|
+
const addresses = [];
|
|
1340
|
+
for (const path of ["/system/edgeserverlist", "/system/edgeserverlist/ipv6"]) {
|
|
1341
|
+
const response = await this.fetchImpl(`${BUNNY_API}${path}`, { headers: { Accept: "application/json" } });
|
|
1342
|
+
if (!response.ok) throw new Error(`Bunny answered ${response.status} to ${path}`);
|
|
1343
|
+
const list = await response.json();
|
|
1344
|
+
if (Array.isArray(list)) {
|
|
1345
|
+
for (const item of list) if (typeof item === "string") addresses.push(item);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return addresses;
|
|
1349
|
+
}
|
|
1350
|
+
/** TRUSTED_PROXIES for Caddy: the addresses Bunny's CDN connects from. */
|
|
1351
|
+
async trustedProxies() {
|
|
1352
|
+
try {
|
|
1353
|
+
const addresses = await this.edgeServersImpl();
|
|
1354
|
+
return addresses.length ? addresses.join(" ") : void 0;
|
|
1355
|
+
} catch {
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async pullZoneId(appId) {
|
|
1360
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1361
|
+
const id = text(findKey(await this.api.mc("GET", `/apps/${encodeURIComponent(appId)}/endpoints`), /^pull_?zone_?id$/i));
|
|
1362
|
+
if (id && id !== "0") return id;
|
|
1363
|
+
await new Promise((done) => setTimeout(done, attempt ? 3e3 : 1e3));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async pullZone(id) {
|
|
1367
|
+
return await this.api.request("GET", `/pullzone/${encodeURIComponent(id)}`) ?? {};
|
|
1368
|
+
}
|
|
1369
|
+
/** The custom hostname, its certificate and a cache bypass for pages that must not be cached. */
|
|
1370
|
+
async setupPullZone(id, domain, step) {
|
|
1371
|
+
const zone = `/pullzone/${encodeURIComponent(id)}`;
|
|
1372
|
+
await this.api.request("POST", zone, { AddHostHeader: true });
|
|
1373
|
+
if (!listOf((await this.pullZone(id)).Hostnames).map((item) => text(item.Value)?.toLowerCase()).includes(domain.toLowerCase())) {
|
|
1374
|
+
await this.api.request("POST", `${zone}/addHostname`, { Hostname: domain });
|
|
1375
|
+
step(`Added ${domain} to the CDN pull zone`);
|
|
1376
|
+
}
|
|
1377
|
+
await this.api.request("POST", `${zone}/edgerules/addOrUpdate`, {
|
|
1378
|
+
ActionType: 3,
|
|
1379
|
+
ActionParameter1: "0",
|
|
1380
|
+
ActionParameter2: "0",
|
|
1381
|
+
Triggers: [{
|
|
1382
|
+
Type: 0,
|
|
1383
|
+
PatternMatches: [
|
|
1384
|
+
"*/admin",
|
|
1385
|
+
"*/admin/*",
|
|
1386
|
+
"*/api",
|
|
1387
|
+
"*/api/*",
|
|
1388
|
+
"*/_a/*"
|
|
1389
|
+
],
|
|
1390
|
+
PatternMatchingType: 0
|
|
1391
|
+
}],
|
|
1392
|
+
TriggerMatchingType: 0,
|
|
1393
|
+
Description: "dstack: do not cache the admin, the API and analytics",
|
|
1394
|
+
Enabled: true
|
|
1395
|
+
});
|
|
1396
|
+
await this.certificate(id, domain);
|
|
1397
|
+
}
|
|
1398
|
+
/** Requests the free certificate once DNS points at the CDN; returns whether it is active. */
|
|
1399
|
+
async certificate(id, domain) {
|
|
1400
|
+
if (listOf((await this.pullZone(id)).Hostnames).find((item) => text(item.Value)?.toLowerCase() === domain.toLowerCase())?.HasCertificate === true) return true;
|
|
1401
|
+
try {
|
|
1402
|
+
await this.api.request("GET", `/pullzone/loadFreeCertificate?hostname=${encodeURIComponent(domain)}`);
|
|
1403
|
+
await this.api.request("POST", `/pullzone/${encodeURIComponent(id)}/setForceSSL`, {
|
|
1404
|
+
Hostname: domain,
|
|
1405
|
+
ForceSSL: true
|
|
1406
|
+
});
|
|
1407
|
+
return true;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
async cname(id) {
|
|
1413
|
+
const name = text((await this.pullZone(id)).Name);
|
|
1414
|
+
return name ? `${name}.b-cdn.net` : void 0;
|
|
1415
|
+
}
|
|
1416
|
+
recordsDir(directory) {
|
|
1417
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1418
|
+
if (!existsSync(folder)) {
|
|
1419
|
+
mkdirSync(folder, { recursive: true });
|
|
1420
|
+
writeFileSync(join(folder, ".gitignore"), "*\n");
|
|
1421
|
+
}
|
|
1422
|
+
return folder;
|
|
1423
|
+
}
|
|
1424
|
+
writeRecord(directory, record, log) {
|
|
1425
|
+
const folder = this.recordsDir(directory);
|
|
1426
|
+
writeFileSync(join(folder, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`);
|
|
1427
|
+
writeFileSync(join(folder, `${record.id}.log`), log.length ? `${log.join("\n")}\n` : "");
|
|
1428
|
+
}
|
|
1429
|
+
records(directory) {
|
|
1430
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1431
|
+
if (!existsSync(folder)) return [];
|
|
1432
|
+
const records = [];
|
|
1433
|
+
for (const file of readdirSync(folder)) {
|
|
1434
|
+
if (!file.endsWith(".json")) continue;
|
|
1435
|
+
try {
|
|
1436
|
+
records.push(JSON.parse(readFileSync(join(folder, file), "utf8")));
|
|
1437
|
+
} catch {}
|
|
1438
|
+
}
|
|
1439
|
+
return records.sort((a, b) => b.id.localeCompare(a.id));
|
|
1440
|
+
}
|
|
1441
|
+
async setup(context) {
|
|
1442
|
+
const { ask, options } = context;
|
|
1443
|
+
const { directory } = context;
|
|
1444
|
+
const volumeSize = Number.parseInt(options.volumeSize ?? "10", 10);
|
|
1445
|
+
if (!Number.isFinite(volumeSize) || volumeSize < 1 || volumeSize > 100) throw new Error("Give --volume-size in GB, from 1 to 100.");
|
|
1446
|
+
const env = { ...context.env };
|
|
1447
|
+
const trusted = await this.trustedProxies();
|
|
1448
|
+
if (trusted) env.TRUSTED_PROXIES = trusted;
|
|
1449
|
+
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.");
|
|
1450
|
+
const local = await this.plan(directory, env);
|
|
1451
|
+
if (local.ignored.length) ask.step(`Bunny runs the app in a gVisor sandbox; not applied from compose.yaml: ${local.ignored.join(", ")}`);
|
|
1452
|
+
const { registries, namespace: found } = await this.ensureRegistries();
|
|
1453
|
+
let namespace = options.account ?? found;
|
|
1454
|
+
if (!namespace) namespace = (await ask.text("Bunny account id (Magic Containers › Image Registries, or bunny whoami)", { placeholder: "123456" })).trim();
|
|
1455
|
+
if (!/^[\w.-]+$/.test(namespace)) throw new Error(`${namespace} is not a Bunny account id.`);
|
|
1456
|
+
let region = options.region;
|
|
1457
|
+
if (!region) {
|
|
1458
|
+
const optimal = text(findKey(await this.api.mc("GET", "/regions/optimal").catch(() => void 0), /^id$|regionid/i));
|
|
1459
|
+
const regions = listOf(await this.api.mc("GET", "/regions")).map((item) => ({
|
|
1460
|
+
value: text(item.id) ?? "",
|
|
1461
|
+
label: text(item.name) ?? text(item.id) ?? ""
|
|
1462
|
+
})).filter((item) => item.value);
|
|
1463
|
+
if (regions.length) region = await ask.select("Region", regions, optimal ?? regions[0]?.value);
|
|
1464
|
+
else region = optimal;
|
|
1465
|
+
}
|
|
1466
|
+
if (!region) throw new Error("Pass --region, such as DE.");
|
|
1467
|
+
const { commit, message } = await this.commit(directory);
|
|
1468
|
+
const uncommitted = uncommittedReason(directory);
|
|
1469
|
+
if (uncommitted) ask.step(`Building the last commit, ${commit}: ${uncommitted.replace(/ Commit first\.$/, "")}`);
|
|
1470
|
+
const log = [];
|
|
1471
|
+
const plan = await this.pushImages(directory, env, namespace, commit, (line) => {
|
|
1472
|
+
log.push(line);
|
|
1473
|
+
ask.step(line);
|
|
1474
|
+
});
|
|
1475
|
+
const appId = text((await this.api.mc("POST", "/apps", appRequest(plan, {
|
|
1476
|
+
registries,
|
|
1477
|
+
namespace,
|
|
1478
|
+
tag: commit,
|
|
1479
|
+
region,
|
|
1480
|
+
volumeSize
|
|
1481
|
+
})))?.id);
|
|
1482
|
+
if (!appId) throw new Error("Bunny created the app but returned no id.");
|
|
1483
|
+
ask.step(`Created the app ${plan.name} in ${region}`);
|
|
1484
|
+
try {
|
|
1485
|
+
await this.api.mc("POST", `/apps/${encodeURIComponent(appId)}/deploy`);
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (!(error instanceof BunnyError && [400, 409].includes(error.status))) throw error;
|
|
1488
|
+
}
|
|
1489
|
+
const zone = await this.pullZoneId(appId);
|
|
1490
|
+
if (!zone) throw new Error(`Bunny has not created the CDN endpoint of app ${appId} yet. Run dstack deploy setup --force later.`);
|
|
1491
|
+
await this.setupPullZone(zone, context.domain, ask.step);
|
|
1492
|
+
const target = await this.cname(zone);
|
|
1493
|
+
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)" : ""}`);
|
|
1494
|
+
this.writeRecord(directory, {
|
|
1495
|
+
id: deployId(this.now(), commit),
|
|
1496
|
+
tag: commit,
|
|
1497
|
+
commit,
|
|
1498
|
+
message,
|
|
1499
|
+
createdAt: this.now().toISOString()
|
|
1500
|
+
}, log);
|
|
1501
|
+
return {
|
|
1502
|
+
project: appId,
|
|
1503
|
+
environment: namespace,
|
|
1504
|
+
application: zone
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
async pushEnv(link, env) {
|
|
1508
|
+
const directory = projectDirectory(link);
|
|
1509
|
+
const values = { ...env };
|
|
1510
|
+
const trusted = await this.trustedProxies();
|
|
1511
|
+
if (trusted) values.TRUSTED_PROXIES = trusted;
|
|
1512
|
+
const plan = await this.plan(directory, values);
|
|
1513
|
+
const containers = appContainers(await this.app(link));
|
|
1514
|
+
for (const container of plan.containers) {
|
|
1515
|
+
const remote = containers.find((item) => item.name === container.name);
|
|
1516
|
+
if (!remote) throw new Error(missingContainer(container.name));
|
|
1517
|
+
await this.api.mc("PUT", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(remote.id)}/env`, { environmentVariables: envList(container.env) });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
async deploy(link, options) {
|
|
1521
|
+
const { directory } = options;
|
|
1522
|
+
const { commit, message } = await this.commit(directory);
|
|
1523
|
+
const id = deployId(this.now(), commit);
|
|
1524
|
+
const log = [];
|
|
1525
|
+
const record = {
|
|
1526
|
+
id,
|
|
1527
|
+
tag: commit,
|
|
1528
|
+
commit,
|
|
1529
|
+
message,
|
|
1530
|
+
createdAt: this.now().toISOString()
|
|
1531
|
+
};
|
|
1532
|
+
const containers = appContainers(await this.app(link));
|
|
1533
|
+
const env = {
|
|
1534
|
+
...productionEnv(directory),
|
|
1535
|
+
...BUNNY_PLATFORM_ENV
|
|
1536
|
+
};
|
|
1537
|
+
try {
|
|
1538
|
+
const local = await this.plan(directory, env);
|
|
1539
|
+
const pushed = local.containers.filter((container) => container.image.source === "push");
|
|
1540
|
+
for (const container of local.containers) if (!containers.some((item) => item.name === container.name)) throw new Error(missingContainer(container.name));
|
|
1541
|
+
if (pushed.every((container) => containers.find((item) => item.name === container.name)?.imageTag === commit)) {
|
|
1542
|
+
if (options.force) {
|
|
1543
|
+
await this.api.mc("POST", `/apps/${encodeURIComponent(link.project)}/restart`);
|
|
1544
|
+
log.push(`Restarted the app on ${commit}`);
|
|
1545
|
+
} else log.push(`The app already runs ${commit}; nothing to deploy. Pass --force to restart it.`);
|
|
1546
|
+
} else {
|
|
1547
|
+
await this.pushImages(directory, env, link.environment, commit, (line) => {
|
|
1548
|
+
log.push(line);
|
|
1549
|
+
process.stderr.write(`${line}\n`);
|
|
1550
|
+
});
|
|
1551
|
+
await this.retag(link, containers, pushed.map((container) => container.name), commit);
|
|
1552
|
+
log.push(`Bunny is rolling out ${commit}`);
|
|
1553
|
+
}
|
|
1554
|
+
if (link.application) {
|
|
1555
|
+
const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
1556
|
+
if (domain) await this.certificate(link.application, domain).catch(() => false);
|
|
1557
|
+
}
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
log.push(error.message);
|
|
1560
|
+
this.writeRecord(directory, {
|
|
1561
|
+
...record,
|
|
1562
|
+
failed: true
|
|
1563
|
+
}, log);
|
|
1564
|
+
throw error;
|
|
1565
|
+
}
|
|
1566
|
+
this.writeRecord(directory, record, log);
|
|
1567
|
+
this.prune(directory);
|
|
1568
|
+
return id;
|
|
1569
|
+
}
|
|
1570
|
+
/** Points the containers of pushed images at a tag; Bunny starts a rolling update. */
|
|
1571
|
+
async retag(link, remote, names, tag) {
|
|
1572
|
+
for (const name of names) {
|
|
1573
|
+
const item = remote.find((entry) => entry.name === name);
|
|
1574
|
+
if (!item || item.imageTag === tag) continue;
|
|
1575
|
+
await this.api.mc("PATCH", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(item.id)}`, { imageTag: tag });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
/** Removes records beyond KEEP_DEPLOYS. Their images stay in the registry. */
|
|
1579
|
+
prune(directory) {
|
|
1580
|
+
const folder = join(directory, BUNNY_DEPLOYS_DIR);
|
|
1581
|
+
for (const record of this.records(directory).slice(20)) {
|
|
1582
|
+
rmSync(join(folder, `${record.id}.json`), { force: true });
|
|
1583
|
+
rmSync(join(folder, `${record.id}.log`), { force: true });
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async rollback(link, to) {
|
|
1587
|
+
const directory = projectDirectory(link);
|
|
1588
|
+
const records = this.records(directory);
|
|
1589
|
+
const containers = appContainers(await this.app(link));
|
|
1590
|
+
const pushed = containers.filter((item) => isPushed(item, link));
|
|
1591
|
+
const currentTag = pushed[0]?.imageTag;
|
|
1592
|
+
let target;
|
|
1593
|
+
if (to) {
|
|
1594
|
+
target = records.find((item) => item.id === to || item.tag === to);
|
|
1595
|
+
if (!target) throw new Error(`There is no deploy ${to} in ${BUNNY_DEPLOYS_DIR}.`);
|
|
1596
|
+
} else {
|
|
1597
|
+
target = records.find((item) => !item.failed && item.tag !== currentTag);
|
|
1598
|
+
if (!target) throw new Error("There is no earlier deploy to roll back to.");
|
|
1599
|
+
}
|
|
1600
|
+
if (target.failed) throw new Error(`Deploy ${target.id} failed, so its images may not exist.`);
|
|
1601
|
+
const id = deployId(this.now(), "rollback");
|
|
1602
|
+
await this.retag(link, containers, pushed.map((item) => item.name), target.tag);
|
|
1603
|
+
this.writeRecord(directory, {
|
|
1604
|
+
id,
|
|
1605
|
+
tag: target.tag,
|
|
1606
|
+
commit: target.commit,
|
|
1607
|
+
message: `Rollback to ${target.id}`,
|
|
1608
|
+
createdAt: this.now().toISOString(),
|
|
1609
|
+
rollbackOf: target.id
|
|
1610
|
+
}, [`Bunny is rolling out ${target.tag}`]);
|
|
1611
|
+
return id;
|
|
1612
|
+
}
|
|
1613
|
+
/** The tag the app's own images run, and Bunny's state. */
|
|
1614
|
+
async current(link) {
|
|
1615
|
+
const app = await this.app(link);
|
|
1616
|
+
return {
|
|
1617
|
+
tag: appContainers(app).filter((item) => isPushed(item, link))[0]?.imageTag,
|
|
1618
|
+
status: appStatusText(app)
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
statusOf(record, latest, current) {
|
|
1622
|
+
if (record.failed) return "failed";
|
|
1623
|
+
if (record.tag === current.tag && latest) {
|
|
1624
|
+
if (bunnyBusy(current.status)) return "building";
|
|
1625
|
+
const state = bunnyState(current.status);
|
|
1626
|
+
if (state === "running" || state === "unknown") return "finished";
|
|
1627
|
+
return "failed";
|
|
1628
|
+
}
|
|
1629
|
+
if (latest) return this.now().getTime() - new Date(record.createdAt).getTime() < 12e4 ? "queued" : "failed";
|
|
1630
|
+
return "finished";
|
|
1631
|
+
}
|
|
1632
|
+
async deployment(link, id) {
|
|
1633
|
+
const directory = projectDirectory(link);
|
|
1634
|
+
const records = this.records(directory);
|
|
1635
|
+
const record = records.find((item) => item.id === id);
|
|
1636
|
+
if (!record) throw new Error(`There is no deploy ${id} in ${BUNNY_DEPLOYS_DIR}.`);
|
|
1637
|
+
const current = await this.current(link);
|
|
1638
|
+
const logFile = join(directory, BUNNY_DEPLOYS_DIR, `${id}.log`);
|
|
1639
|
+
const log = existsSync(logFile) ? readFileSync(logFile, "utf8").replace(/\n$/, "") : "";
|
|
1640
|
+
return {
|
|
1641
|
+
...toDeployment$1(record, this.statusOf(record, records[0]?.id === id, current), link),
|
|
1642
|
+
log: log ? log.split("\n").map((line) => ({
|
|
1643
|
+
text: line,
|
|
1644
|
+
stderr: false
|
|
1645
|
+
})) : []
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
async deployments(link, limit) {
|
|
1649
|
+
const records = this.records(projectDirectory(link)).slice(0, Math.max(1, limit));
|
|
1650
|
+
if (!records.length) return [];
|
|
1651
|
+
const current = await this.current(link);
|
|
1652
|
+
return records.map((record, index) => ({
|
|
1653
|
+
...toDeployment$1(record, this.statusOf(record, index === 0, current), link),
|
|
1654
|
+
log: []
|
|
1655
|
+
}));
|
|
1656
|
+
}
|
|
1657
|
+
async status(link) {
|
|
1658
|
+
const word = appStatusText(await this.app(link));
|
|
1659
|
+
const state = bunnyBusy(word) ? "degraded" : bunnyState(word);
|
|
1660
|
+
const details = [word ?? "no status"];
|
|
1661
|
+
const domains = [];
|
|
1662
|
+
if (link.application) try {
|
|
1663
|
+
const zone = await this.pullZone(link.application);
|
|
1664
|
+
for (const item of listOf(zone.Hostnames)) {
|
|
1665
|
+
const host = text(item.Value);
|
|
1666
|
+
if (!host || item.IsSystemHostname === true || host.endsWith(".b-cdn.net")) continue;
|
|
1667
|
+
domains.push(`https://${host}`);
|
|
1668
|
+
if (item.HasCertificate !== true) details.push(`no certificate for ${host} yet: check its DNS record`);
|
|
1669
|
+
}
|
|
1670
|
+
} catch {
|
|
1671
|
+
details.push("could not read the CDN pull zone");
|
|
1672
|
+
}
|
|
1673
|
+
return {
|
|
1674
|
+
state,
|
|
1675
|
+
detail: details.join(", "),
|
|
1676
|
+
domains,
|
|
1677
|
+
url: dashboardUrl(link)
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
function missingContainer(name) {
|
|
1682
|
+
return `The app on Bunny has no container ${name}. compose.yaml changed since setup: run dstack deploy setup --force to create the app again.`;
|
|
1683
|
+
}
|
|
1684
|
+
/** Whether a Bunny container runs an image this project pushes: one in the account's namespace. */
|
|
1685
|
+
function isPushed(container, link) {
|
|
1686
|
+
return container.imageNamespace === link.environment;
|
|
1687
|
+
}
|
|
1688
|
+
/** The project directory, where the deploy records are. */
|
|
1689
|
+
function projectDirectory(link) {
|
|
1690
|
+
return link.directory ?? process.cwd();
|
|
1691
|
+
}
|
|
1692
|
+
function dashboardUrl(link) {
|
|
1693
|
+
return `https://dash.bunny.net/magic-containers/apps/${encodeURIComponent(link.project)}`;
|
|
1694
|
+
}
|
|
1695
|
+
function toDeployment$1(record, status, link) {
|
|
1696
|
+
return {
|
|
1697
|
+
id: record.id,
|
|
1698
|
+
status,
|
|
1699
|
+
commit: record.commit,
|
|
1700
|
+
message: record.message,
|
|
1701
|
+
createdAt: record.createdAt,
|
|
1702
|
+
url: dashboardUrl(link)
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
/** The variables of .env.production, or of .env on a machine where that is the production one. */
|
|
1706
|
+
function productionEnv(directory) {
|
|
1707
|
+
for (const name of [".env.production", ".env"]) {
|
|
1708
|
+
const file = join(directory, name);
|
|
1709
|
+
if (existsSync(file)) return parseEnv(readFileSync(file, "utf8"));
|
|
1710
|
+
}
|
|
1711
|
+
throw new Error(".env.production does not exist. Run dstack production --domain example.com first.");
|
|
1712
|
+
}
|
|
1713
|
+
const bunny = {
|
|
1714
|
+
id: "bunny",
|
|
1715
|
+
name: "Bunny Magic Containers",
|
|
1716
|
+
auth: {
|
|
1717
|
+
kind: "token",
|
|
1718
|
+
env: "DSTACK_BUNNY_TOKEN",
|
|
1719
|
+
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."
|
|
1720
|
+
},
|
|
1721
|
+
source: "local",
|
|
1722
|
+
target: {
|
|
1723
|
+
label: "Bunny API",
|
|
1724
|
+
placeholder: BUNNY_API,
|
|
1725
|
+
fixed: BUNNY_API,
|
|
1726
|
+
normalize: () => BUNNY_API
|
|
1727
|
+
},
|
|
1728
|
+
platformEnv: BUNNY_PLATFORM_ENV,
|
|
1729
|
+
setupFlags: [
|
|
1730
|
+
{
|
|
1731
|
+
name: "region",
|
|
1732
|
+
description: "Bunny: the region of the app, such as DE (default: the closest)"
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
name: "volumeSize",
|
|
1736
|
+
description: "Bunny: the size of the database volume in GB (default: 10)"
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
name: "account",
|
|
1740
|
+
description: "Bunny: the account id, the namespace in Bunny Container Registry"
|
|
1741
|
+
}
|
|
1742
|
+
],
|
|
1743
|
+
printsDns: true,
|
|
1744
|
+
connect: (_target, token) => {
|
|
1745
|
+
if (!token) throw new Error("Bunny needs an API key.");
|
|
1746
|
+
return new BunnyClient(token);
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
// src/deploy/provider.ts
|
|
1750
|
+
/** The target of a platform with a web API and an API key. */
|
|
1751
|
+
function urlTarget(name, placeholder) {
|
|
1752
|
+
return {
|
|
1753
|
+
label: `${name} URL`,
|
|
1754
|
+
placeholder,
|
|
1755
|
+
normalize: normalizeUrl
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
/** Normalizes an instance URL: https by default, no trailing slash or path. */
|
|
1759
|
+
function normalizeUrl(value) {
|
|
1760
|
+
const trimmed = value.trim();
|
|
1761
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1762
|
+
let parsed;
|
|
1763
|
+
try {
|
|
1764
|
+
parsed = new URL(withScheme);
|
|
1765
|
+
} catch {
|
|
1766
|
+
throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
|
|
1767
|
+
}
|
|
1768
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
1769
|
+
}
|
|
1770
|
+
// src/deploy/coolify.ts
|
|
1771
|
+
/**
|
|
1772
|
+
* Coolify (https://coolify.io), through its API at /api/v1 with a bearer
|
|
1773
|
+
* token. The project deploys as a Docker Compose application that Coolify
|
|
1774
|
+
* builds on the server from the git repository; Coolify's proxy gets the
|
|
1775
|
+
* certificate and forwards to the `proxy` service (Caddy) on port 80.
|
|
1776
|
+
*/
|
|
1777
|
+
/** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
|
|
1778
|
+
const PLATFORM_ENV = {
|
|
1779
|
+
CADDY_ADDRESS: ":80",
|
|
1780
|
+
TRUSTED_PROXIES: "private_ranges",
|
|
1781
|
+
NUM_PROXIES: "2",
|
|
1782
|
+
HTTP_PORT: "127.0.0.1:8080",
|
|
1783
|
+
HTTPS_PORT: "127.0.0.1:8443"
|
|
1784
|
+
};
|
|
1785
|
+
/** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
|
|
1786
|
+
const SHARED_SETUP_FLAGS = [
|
|
1787
|
+
{
|
|
1788
|
+
name: "server",
|
|
1789
|
+
description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
name: "project",
|
|
1793
|
+
description: "The platform's project, by name or id (default: the project name)"
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
name: "environment",
|
|
1797
|
+
description: "The platform's environment (default: production)"
|
|
1798
|
+
}
|
|
1799
|
+
];
|
|
1800
|
+
var CoolifyError = class extends Error {
|
|
1801
|
+
status;
|
|
1802
|
+
constructor(message, status) {
|
|
1803
|
+
super(message);
|
|
1804
|
+
this.status = status;
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
var CoolifyApi = class {
|
|
1808
|
+
url;
|
|
1809
|
+
token;
|
|
1810
|
+
fetchImpl;
|
|
1811
|
+
constructor(url, token, fetchImpl = fetch) {
|
|
1812
|
+
this.url = url;
|
|
1813
|
+
this.token = token;
|
|
1814
|
+
this.fetchImpl = fetchImpl;
|
|
1815
|
+
}
|
|
1816
|
+
async request(method, path, body) {
|
|
1817
|
+
let response;
|
|
1818
|
+
try {
|
|
1819
|
+
response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
|
|
1820
|
+
method,
|
|
1821
|
+
headers: {
|
|
1822
|
+
Authorization: `Bearer ${this.token}`,
|
|
1823
|
+
Accept: "application/json",
|
|
1824
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1825
|
+
},
|
|
1826
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
1827
|
+
signal: AbortSignal.timeout(3e4)
|
|
1828
|
+
});
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
1831
|
+
}
|
|
1832
|
+
const text = await response.text();
|
|
1833
|
+
if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
|
|
1834
|
+
if (!text) return void 0;
|
|
1835
|
+
try {
|
|
1836
|
+
return JSON.parse(text);
|
|
1837
|
+
} catch {
|
|
1838
|
+
return text;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
get = (path) => this.request("GET", path);
|
|
1842
|
+
post = (path, body) => this.request("POST", path, body ?? {});
|
|
1843
|
+
patch = (path, body) => this.request("PATCH", path, body);
|
|
1844
|
+
};
|
|
1845
|
+
function errorMessage$1(status, text, url) {
|
|
1846
|
+
if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
1847
|
+
if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
|
|
1848
|
+
if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
|
|
1849
|
+
let message = "";
|
|
1850
|
+
try {
|
|
1851
|
+
const body = JSON.parse(text);
|
|
1852
|
+
message = body.message ?? "";
|
|
1853
|
+
if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
|
|
1854
|
+
} catch {
|
|
1855
|
+
message = text.slice(0, 200);
|
|
1856
|
+
}
|
|
1857
|
+
return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
|
|
1858
|
+
}
|
|
1859
|
+
const enc = encodeURIComponent;
|
|
1860
|
+
function deploymentStatus(value) {
|
|
1861
|
+
if (value === "queued") return "queued";
|
|
1862
|
+
if (value === "in_progress") return "building";
|
|
1863
|
+
if (value === "finished") return "finished";
|
|
1864
|
+
if (value.startsWith("cancelled")) return "cancelled";
|
|
1865
|
+
return "failed";
|
|
1866
|
+
}
|
|
1867
|
+
function appState(value) {
|
|
1868
|
+
const [state = "", health = ""] = (value ?? "").split(/[:(]/);
|
|
1869
|
+
if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
|
|
1870
|
+
if (state === "degraded" || state === "restarting") return "degraded";
|
|
1871
|
+
if (state === "exited" || state === "stopped") return "stopped";
|
|
1872
|
+
return "unknown";
|
|
1873
|
+
}
|
|
1874
|
+
/** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
|
|
1875
|
+
function parseLog(logs) {
|
|
1876
|
+
if (!logs) return [];
|
|
1877
|
+
let entries;
|
|
1878
|
+
try {
|
|
1879
|
+
entries = JSON.parse(logs);
|
|
1880
|
+
} catch {
|
|
1881
|
+
return logs.split("\n").map((text) => ({
|
|
1882
|
+
text,
|
|
1883
|
+
stderr: false
|
|
1884
|
+
}));
|
|
1885
|
+
}
|
|
1886
|
+
if (!Array.isArray(entries)) return [];
|
|
1887
|
+
return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
|
|
1888
|
+
text,
|
|
1889
|
+
stderr: entry.type === "stderr"
|
|
1890
|
+
})));
|
|
1891
|
+
}
|
|
1892
|
+
/** The domains of a Compose application, by service. */
|
|
1893
|
+
function composeDomains(app) {
|
|
1894
|
+
let value = app.docker_compose_domains;
|
|
1895
|
+
if (typeof value === "string") try {
|
|
1896
|
+
value = JSON.parse(value);
|
|
1897
|
+
} catch {
|
|
1898
|
+
value = null;
|
|
1899
|
+
}
|
|
1900
|
+
const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
|
|
1901
|
+
if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
|
|
1902
|
+
return domains.map((domain) => domain.replace(/:\d+$/, ""));
|
|
1903
|
+
}
|
|
1904
|
+
function appUrl(api, link) {
|
|
1905
|
+
return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
|
|
1906
|
+
}
|
|
1907
|
+
function toDeployment(api, link, item) {
|
|
1908
|
+
const path = item.deployment_url;
|
|
1909
|
+
return {
|
|
1910
|
+
id: item.deployment_uuid,
|
|
1911
|
+
status: deploymentStatus(item.status),
|
|
1912
|
+
commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
|
|
1913
|
+
message: item.commit_message?.split("\n")[0] || void 0,
|
|
1914
|
+
createdAt: item.created_at,
|
|
1915
|
+
updatedAt: item.updated_at,
|
|
1916
|
+
url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
|
|
1917
|
+
log: parseLog(item.logs)
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
/** Finds a name or uuid in a list. */
|
|
1921
|
+
function find$1(items, wanted) {
|
|
1922
|
+
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
1923
|
+
}
|
|
1924
|
+
var CoolifyClient = class {
|
|
1925
|
+
api;
|
|
1926
|
+
constructor(url, token, fetchImpl) {
|
|
1927
|
+
this.api = new CoolifyApi(url, token, fetchImpl);
|
|
1928
|
+
}
|
|
1929
|
+
async verify() {
|
|
1930
|
+
const version = await this.api.get("/version");
|
|
1931
|
+
await this.api.get("/servers");
|
|
1932
|
+
return String(version).trim();
|
|
1933
|
+
}
|
|
1934
|
+
async setup(context) {
|
|
1935
|
+
const { ask, options } = context;
|
|
1936
|
+
if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
|
|
1937
|
+
const api = this.api;
|
|
1938
|
+
const servers = await api.get("/servers");
|
|
1939
|
+
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
1940
|
+
let server;
|
|
1941
|
+
if (options.server) {
|
|
1942
|
+
server = find$1(servers, options.server);
|
|
1943
|
+
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
1944
|
+
} else if (servers.length === 1) server = servers[0];
|
|
1945
|
+
else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
1946
|
+
value: item.uuid,
|
|
1947
|
+
label: item.name,
|
|
1948
|
+
hint: item.ip
|
|
1949
|
+
}))));
|
|
1950
|
+
if (!server) throw new Error("No server chosen.");
|
|
1951
|
+
const projects = await api.get("/projects");
|
|
1952
|
+
let project;
|
|
1953
|
+
const projectName = options.project ?? context.name;
|
|
1954
|
+
project = find$1(projects, projectName);
|
|
1955
|
+
if (!options.project && ask.interactive && projects.length) {
|
|
1956
|
+
const NEW = "";
|
|
1957
|
+
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
1958
|
+
value: NEW,
|
|
1959
|
+
label: `Create ${projectName}`
|
|
1960
|
+
}], ...projects.map((item) => ({
|
|
1961
|
+
value: item.uuid,
|
|
1962
|
+
label: item.name,
|
|
1963
|
+
hint: item.description ?? void 0
|
|
1964
|
+
}))], project?.uuid ?? NEW);
|
|
1965
|
+
project = uuid === NEW ? void 0 : find$1(projects, uuid);
|
|
1966
|
+
}
|
|
1967
|
+
if (!project) {
|
|
1968
|
+
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
1969
|
+
project = {
|
|
1970
|
+
uuid: (await api.post("/projects", {
|
|
1971
|
+
name: projectName,
|
|
1972
|
+
description: "DjangoStack"
|
|
1973
|
+
})).uuid,
|
|
1974
|
+
name: projectName
|
|
1975
|
+
};
|
|
1976
|
+
ask.step(`Created the Coolify project ${projectName}`);
|
|
1977
|
+
}
|
|
1978
|
+
const environmentName = options.environment ?? "production";
|
|
1979
|
+
let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
1980
|
+
if (!environment) {
|
|
1981
|
+
environment = {
|
|
1982
|
+
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
1983
|
+
name: environmentName
|
|
1984
|
+
};
|
|
1985
|
+
ask.step(`Created the environment ${environmentName}`);
|
|
1986
|
+
}
|
|
1987
|
+
let githubApp;
|
|
1988
|
+
if (options.githubApp && options.githubApp !== "public") {
|
|
1989
|
+
const app = find$1(await api.get("/github-apps"), options.githubApp);
|
|
1990
|
+
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
1991
|
+
githubApp = app.uuid;
|
|
1992
|
+
} else if (!options.githubApp && ask.interactive) {
|
|
1993
|
+
const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
|
|
1994
|
+
if (apps.length) {
|
|
1995
|
+
const choice = await ask.select("How Coolify gets the code", [{
|
|
1996
|
+
value: "public",
|
|
1997
|
+
label: "Public repository",
|
|
1998
|
+
hint: "no access needed"
|
|
1999
|
+
}, ...apps.map((app) => ({
|
|
2000
|
+
value: app.uuid,
|
|
2001
|
+
label: `GitHub App ${app.name}`,
|
|
2002
|
+
hint: "private repository"
|
|
2003
|
+
}))], "public");
|
|
2004
|
+
githubApp = choice === "public" ? void 0 : choice;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
const body = {
|
|
2008
|
+
project_uuid: project.uuid,
|
|
2009
|
+
server_uuid: server.uuid,
|
|
2010
|
+
environment_uuid: environment.uuid,
|
|
2011
|
+
git_repository: context.repository,
|
|
2012
|
+
git_branch: context.branch,
|
|
2013
|
+
build_pack: "dockercompose",
|
|
2014
|
+
docker_compose_location: "/compose.yaml",
|
|
2015
|
+
docker_compose_domains: [{
|
|
2016
|
+
name: "proxy",
|
|
2017
|
+
domain: `https://${context.domain}:80`
|
|
2018
|
+
}],
|
|
2019
|
+
name: context.name,
|
|
2020
|
+
description: "DjangoStack",
|
|
2021
|
+
instant_deploy: false,
|
|
2022
|
+
...githubApp ? { github_app_uuid: githubApp } : {}
|
|
2023
|
+
};
|
|
2024
|
+
const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
|
|
2025
|
+
ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
|
|
2026
|
+
const link = {
|
|
2027
|
+
provider: "coolify",
|
|
2028
|
+
url: api.url,
|
|
2029
|
+
project: project.uuid,
|
|
2030
|
+
environment: environment.uuid,
|
|
2031
|
+
application: created.uuid
|
|
2032
|
+
};
|
|
2033
|
+
await this.pushEnv(link, context.env);
|
|
2034
|
+
ask.step(`Set ${Object.keys(context.env).length} environment variables`);
|
|
2035
|
+
return {
|
|
2036
|
+
project: link.project,
|
|
2037
|
+
environment: link.environment,
|
|
2038
|
+
application: link.application
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
async pushEnv(link, env) {
|
|
2042
|
+
const data = Object.entries(env).map(([key, value]) => ({
|
|
2043
|
+
key,
|
|
2044
|
+
value,
|
|
2045
|
+
is_literal: true,
|
|
2046
|
+
is_preview: false
|
|
2047
|
+
}));
|
|
2048
|
+
await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
|
|
2049
|
+
}
|
|
2050
|
+
async deploy(link, options) {
|
|
2051
|
+
const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
|
|
2052
|
+
const result = await this.api.post(`/deploy?${query}`);
|
|
2053
|
+
const id = result?.deployments?.[0]?.deployment_uuid;
|
|
2054
|
+
if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
|
|
2055
|
+
return id;
|
|
2056
|
+
}
|
|
2057
|
+
async deployment(link, id) {
|
|
2058
|
+
return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
2059
|
+
}
|
|
2060
|
+
async deployments(link, limit) {
|
|
2061
|
+
const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
|
|
2062
|
+
return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
|
|
2063
|
+
}
|
|
2064
|
+
async status(link) {
|
|
2065
|
+
const app = await this.api.get(`/applications/${enc(link.application)}`);
|
|
2066
|
+
return {
|
|
2067
|
+
state: appState(app.status),
|
|
2068
|
+
detail: app.status ?? "unknown",
|
|
2069
|
+
domains: composeDomains(app),
|
|
2070
|
+
branch: app.git_branch,
|
|
2071
|
+
url: appUrl(this.api, link)
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
const coolify = {
|
|
2076
|
+
id: "coolify",
|
|
2077
|
+
name: "Coolify",
|
|
2078
|
+
auth: {
|
|
2079
|
+
kind: "token",
|
|
2080
|
+
env: "DSTACK_COOLIFY_TOKEN",
|
|
2081
|
+
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.`
|
|
2082
|
+
},
|
|
2083
|
+
source: "remote",
|
|
2084
|
+
target: urlTarget("Coolify", "https://coolify.example.com"),
|
|
2085
|
+
platformEnv: PLATFORM_ENV,
|
|
2086
|
+
setupFlags: [...SHARED_SETUP_FLAGS, {
|
|
2087
|
+
name: "githubApp",
|
|
2088
|
+
description: "A Coolify GitHub App for a private repository, or public"
|
|
775
2089
|
}],
|
|
776
|
-
connect: (
|
|
2090
|
+
connect: (url, token) => {
|
|
2091
|
+
if (!token) throw new Error("Coolify needs an API key.");
|
|
2092
|
+
return new CoolifyClient(url, token);
|
|
2093
|
+
}
|
|
777
2094
|
};
|
|
778
2095
|
// src/deploy/dokploy.ts
|
|
779
2096
|
var DokployError = class extends Error {
|
|
@@ -1164,123 +2481,14 @@ function deleteToken(provider, url) {
|
|
|
1164
2481
|
} catch {}
|
|
1165
2482
|
return removeFromFile(account) || removed;
|
|
1166
2483
|
}
|
|
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
2484
|
// src/deploy/index.ts
|
|
1278
2485
|
const DEFAULT_PROVIDER = coolify;
|
|
1279
2486
|
/** Every deploy provider. */
|
|
1280
2487
|
const PROVIDERS = [
|
|
1281
2488
|
coolify,
|
|
1282
2489
|
dokploy,
|
|
1283
|
-
ssh
|
|
2490
|
+
ssh,
|
|
2491
|
+
bunny
|
|
1284
2492
|
];
|
|
1285
2493
|
function getProvider(id) {
|
|
1286
2494
|
const provider = PROVIDERS.find((item) => item.id === id);
|
|
@@ -1362,17 +2570,45 @@ function projectManifest(project) {
|
|
|
1362
2570
|
/**
|
|
1363
2571
|
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
1364
2572
|
* for projects from before that, the defaults with the storage from `.env`.
|
|
2573
|
+
* Analytics follow the `analytics` profile: projects from before Umami was
|
|
2574
|
+
* generated have the default in their preset without running it.
|
|
1365
2575
|
*/
|
|
1366
2576
|
function projectStack(project) {
|
|
2577
|
+
const fromEnv = {
|
|
2578
|
+
analytics: project.profiles.includes("analytics") ? "umami" : "off",
|
|
2579
|
+
...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
|
|
2580
|
+
};
|
|
1367
2581
|
const { preset } = projectManifest(project);
|
|
1368
2582
|
if (preset) try {
|
|
1369
|
-
return
|
|
2583
|
+
return {
|
|
2584
|
+
...decodePreset(preset),
|
|
2585
|
+
...fromEnv
|
|
2586
|
+
};
|
|
1370
2587
|
} catch {}
|
|
1371
2588
|
return {
|
|
1372
2589
|
...DEFAULT_STACK,
|
|
2590
|
+
...fromEnv,
|
|
1373
2591
|
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
1374
2592
|
};
|
|
1375
2593
|
}
|
|
2594
|
+
const REDIS = /^(redis|rediss|valkey|valkeys):/;
|
|
2595
|
+
/**
|
|
2596
|
+
* The cache and the tasks a project's `.env` runs. Projects from before 0.7
|
|
2597
|
+
* have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
|
|
2598
|
+
*/
|
|
2599
|
+
function cacheStack(env) {
|
|
2600
|
+
const cacheUrl = env.CACHE_URL ?? "";
|
|
2601
|
+
const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
|
|
2602
|
+
const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
|
|
2603
|
+
if (tasksUrl.startsWith("immediate:")) return {
|
|
2604
|
+
cache,
|
|
2605
|
+
tasks: "off"
|
|
2606
|
+
};
|
|
2607
|
+
return {
|
|
2608
|
+
cache,
|
|
2609
|
+
tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
1376
2612
|
/** The URLs of a running project. */
|
|
1377
2613
|
function projectUrls(project) {
|
|
1378
2614
|
if (project.prod) {
|
|
@@ -1385,23 +2621,25 @@ function projectUrls(project) {
|
|
|
1385
2621
|
url: `${site}/admin/`
|
|
1386
2622
|
}];
|
|
1387
2623
|
}
|
|
2624
|
+
const ports = envPorts(project.env);
|
|
2625
|
+
const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
|
|
1388
2626
|
const urls = [
|
|
1389
2627
|
{
|
|
1390
2628
|
label: "Site",
|
|
1391
|
-
url:
|
|
2629
|
+
url: site
|
|
1392
2630
|
},
|
|
1393
2631
|
{
|
|
1394
2632
|
label: "Admin",
|
|
1395
|
-
url:
|
|
2633
|
+
url: `${site}/admin/`
|
|
1396
2634
|
},
|
|
1397
2635
|
{
|
|
1398
2636
|
label: "Mail",
|
|
1399
|
-
url:
|
|
2637
|
+
url: `http://localhost:${ports.MAIL_PORT}`
|
|
1400
2638
|
}
|
|
1401
2639
|
];
|
|
1402
2640
|
if (project.profiles.includes("s3")) urls.push({
|
|
1403
2641
|
label: "S3",
|
|
1404
|
-
url:
|
|
2642
|
+
url: `http://localhost:${ports.S3_PORT}`
|
|
1405
2643
|
});
|
|
1406
2644
|
return urls;
|
|
1407
2645
|
}
|
|
@@ -1650,7 +2888,7 @@ async function deploySetup(options) {
|
|
|
1650
2888
|
}))
|
|
1651
2889
|
}));
|
|
1652
2890
|
const provider = providerOrFail(providerId);
|
|
1653
|
-
let instance = options.url;
|
|
2891
|
+
let instance = options.url ?? provider.target.fixed;
|
|
1654
2892
|
if (!instance) {
|
|
1655
2893
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1656
2894
|
instance = unwrap(await p.text({
|
|
@@ -1693,6 +2931,7 @@ async function deploySetup(options) {
|
|
|
1693
2931
|
for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
|
|
1694
2932
|
const ids = await attempt(client.setup({
|
|
1695
2933
|
ask: promptAsk(interactive),
|
|
2934
|
+
directory: project.directory,
|
|
1696
2935
|
name: project.name,
|
|
1697
2936
|
repository,
|
|
1698
2937
|
branch,
|
|
@@ -1703,11 +2942,12 @@ async function deploySetup(options) {
|
|
|
1703
2942
|
const link = {
|
|
1704
2943
|
provider: provider.id,
|
|
1705
2944
|
url: instance,
|
|
1706
|
-
...ids
|
|
2945
|
+
...ids,
|
|
2946
|
+
directory: project.directory
|
|
1707
2947
|
};
|
|
1708
2948
|
writeLink(project.directory, link);
|
|
1709
2949
|
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
|
|
2950
|
+
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
2951
|
if (options.deploy ?? (interactive ? unwrap(await p.confirm({
|
|
1712
2952
|
message: "Deploy now?",
|
|
1713
2953
|
initialValue: true
|
|
@@ -1901,7 +3141,7 @@ async function loginTarget(options, interactive) {
|
|
|
1901
3141
|
} catch {}
|
|
1902
3142
|
const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
|
|
1903
3143
|
tokenAuth(provider);
|
|
1904
|
-
let instance = options.url ?? link?.url;
|
|
3144
|
+
let instance = options.url ?? link?.url ?? provider.target.fixed;
|
|
1905
3145
|
if (!instance) {
|
|
1906
3146
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1907
3147
|
instance = unwrap(await p.text({
|
|
@@ -2206,14 +3446,23 @@ async function offerGlobalInstall() {
|
|
|
2206
3446
|
return true;
|
|
2207
3447
|
}
|
|
2208
3448
|
// src/commands/init.ts
|
|
2209
|
-
/**
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
3449
|
+
/**
|
|
3450
|
+
* The prompt choices of an option; values this version does not generate yet
|
|
3451
|
+
* are shown but disabled. The tasks follow the cache chosen before them: Huey
|
|
3452
|
+
* keeps its queue in the database, or in Valkey with the Valkey cache.
|
|
3453
|
+
*/
|
|
3454
|
+
function choices(key, stack = {}) {
|
|
3455
|
+
return stackOption(key).values.flatMap((value, index) => {
|
|
3456
|
+
let hint = value.generated || index === 0 ? value.hint : "coming soon";
|
|
3457
|
+
if (key === "tasks" && value.value === "huey") hint = stack.cache === "valkey" ? "a worker container, the queue in Valkey" : "a worker container, the queue in Postgres";
|
|
3458
|
+
if (key === "tasks" && value.value === "huey-valkey" && stack.cache === "valkey") return [];
|
|
3459
|
+
return [{
|
|
3460
|
+
value: value.value,
|
|
3461
|
+
label: value.label,
|
|
3462
|
+
hint,
|
|
3463
|
+
disabled: !value.generated && index > 0
|
|
3464
|
+
}];
|
|
3465
|
+
});
|
|
2217
3466
|
}
|
|
2218
3467
|
/**
|
|
2219
3468
|
* The stack from, in order of precedence, the flags, the preset, the prompts
|
|
@@ -2229,8 +3478,8 @@ async function chooseStack(options, interactive, askDatabase) {
|
|
|
2229
3478
|
}
|
|
2230
3479
|
const stack = { ...DEFAULT_STACK };
|
|
2231
3480
|
let databaseUrl;
|
|
2232
|
-
for (const
|
|
2233
|
-
const
|
|
3481
|
+
for (const key of PROMPT_ORDER) {
|
|
3482
|
+
const option = stackOption(key);
|
|
2234
3483
|
if (options.fromCli(key)) stack[key] = options[key];
|
|
2235
3484
|
else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
|
|
2236
3485
|
else if (preset) stack[key] = preset[key];
|
|
@@ -2240,11 +3489,14 @@ async function chooseStack(options, interactive, askDatabase) {
|
|
|
2240
3489
|
if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
|
|
2241
3490
|
else stack[key] = answer.postgres;
|
|
2242
3491
|
} else if (key === "postgres" && options.databaseUrl) continue;
|
|
2243
|
-
else
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
3492
|
+
else {
|
|
3493
|
+
const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
|
|
3494
|
+
stack[key] = unwrap(await p.select({
|
|
3495
|
+
message: option.message,
|
|
3496
|
+
options: choices(key, stack),
|
|
3497
|
+
initialValue
|
|
3498
|
+
}));
|
|
3499
|
+
}
|
|
2248
3500
|
}
|
|
2249
3501
|
return {
|
|
2250
3502
|
stack,
|
|
@@ -2339,6 +3591,8 @@ async function init(directoryArgument, options) {
|
|
|
2339
3591
|
message: "Add demo content? (example pages and posts)",
|
|
2340
3592
|
initialValue: true
|
|
2341
3593
|
}));
|
|
3594
|
+
const ports = await chooseFreePorts({}, stack.storage === "s3" ? ["s3"] : []);
|
|
3595
|
+
for (const move of ports.moves) p.log.info(describeMove(move));
|
|
2342
3596
|
const spinner = p.spinner();
|
|
2343
3597
|
spinner.start("Creating the project");
|
|
2344
3598
|
let result;
|
|
@@ -2350,8 +3604,12 @@ async function init(directoryArgument, options) {
|
|
|
2350
3604
|
demoContent,
|
|
2351
3605
|
databaseUrl,
|
|
2352
3606
|
localS3: stack.storage === "s3",
|
|
3607
|
+
analytics: stack.analytics === "umami",
|
|
3608
|
+
cache: stack.cache,
|
|
3609
|
+
tasks: stack.tasks,
|
|
2353
3610
|
preset: encodePreset(stack),
|
|
2354
3611
|
timeZone,
|
|
3612
|
+
ports: ports.ports,
|
|
2355
3613
|
link: options.link
|
|
2356
3614
|
});
|
|
2357
3615
|
} catch (error) {
|
|
@@ -2386,22 +3644,24 @@ async function init(directoryArgument, options) {
|
|
|
2386
3644
|
} catch {
|
|
2387
3645
|
p.log.warn("Could not commit the project with git. Commit it yourself.");
|
|
2388
3646
|
}
|
|
3647
|
+
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
3648
|
+
const startCommand = isOnPath() ? "dstack dev" : "docker compose up";
|
|
2389
3649
|
p.note([
|
|
2390
3650
|
command(`cd ${relativeDirectory(target)}`),
|
|
2391
|
-
command(
|
|
3651
|
+
command(startCommand),
|
|
2392
3652
|
"",
|
|
2393
|
-
`${pc.bold("Site")} ${url(
|
|
2394
|
-
`${pc.bold("Admin")} ${url(
|
|
2395
|
-
`${pc.bold("Mail")} ${url(
|
|
3653
|
+
`${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
|
|
3654
|
+
`${pc.bold("Admin")} ${url(`http://localhost:${ports.ports.SITE_PORT}/admin/`)}`,
|
|
3655
|
+
`${pc.bold("Mail")} ${url(`http://localhost:${ports.ports.MAIL_PORT}`)}`,
|
|
2396
3656
|
"",
|
|
2397
3657
|
`Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
|
|
2398
3658
|
pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
|
|
2399
3659
|
].join("\n"), "Next steps");
|
|
2400
3660
|
if (pending.length) p.log.info(`Coming in a later version, so not in this project yet: ${pending.map((item) => `${stackOption(item.key).message.toLowerCase()} (${stack[item.key]})`).join(", ")}.`);
|
|
3661
|
+
if (stack.media === "s3") p.log.info(`Uploads go to S3 in production. Before the first deploy, ${command("dstack production")} asks for the S3 settings; ${command("dstack settings")} changes them later.`);
|
|
2401
3662
|
p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
|
|
2402
|
-
if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command(
|
|
3663
|
+
if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command(startCommand)}.`));
|
|
2403
3664
|
else if (options.start) {
|
|
2404
|
-
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
2405
3665
|
p.outro(`Starting the project with ${command("docker compose up")}`);
|
|
2406
3666
|
execFileSync("docker", ["compose", "up"], {
|
|
2407
3667
|
cwd: target,
|
|
@@ -2420,8 +3680,9 @@ async function preset(code, options) {
|
|
|
2420
3680
|
} catch (error) {
|
|
2421
3681
|
fail(error.message);
|
|
2422
3682
|
}
|
|
2423
|
-
const lines =
|
|
2424
|
-
const
|
|
3683
|
+
const lines = PROMPT_ORDER.filter((key) => applies(key, stack)).map((key) => {
|
|
3684
|
+
const option = stackOption(key);
|
|
3685
|
+
const value = option.values.find((candidate) => candidate.value === stack[key]);
|
|
2425
3686
|
return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
|
|
2426
3687
|
});
|
|
2427
3688
|
p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
|
|
@@ -2432,6 +3693,295 @@ async function preset(code, options) {
|
|
|
2432
3693
|
const encoded = encodePreset(stack);
|
|
2433
3694
|
p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
|
|
2434
3695
|
}
|
|
3696
|
+
// src/settings.ts
|
|
3697
|
+
function validateEmail(value) {
|
|
3698
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? void 0 : "Enter an email address, such as you@example.com.";
|
|
3699
|
+
}
|
|
3700
|
+
function validateHost(value) {
|
|
3701
|
+
return /^[a-z0-9.-]+$/i.test(value) && value.includes(".") ? void 0 : "Enter a host name, such as smtp.example.com.";
|
|
3702
|
+
}
|
|
3703
|
+
function validatePort(value) {
|
|
3704
|
+
const port = Number(value);
|
|
3705
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 587.";
|
|
3706
|
+
}
|
|
3707
|
+
function validateUrl(value) {
|
|
3708
|
+
if (!value) return void 0;
|
|
3709
|
+
return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
|
|
3710
|
+
}
|
|
3711
|
+
const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
|
|
3712
|
+
/**
|
|
3713
|
+
* An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
|
|
3714
|
+
* smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
|
|
3715
|
+
*/
|
|
3716
|
+
function smtpUrl(parts) {
|
|
3717
|
+
const port = Number(parts.port || "587");
|
|
3718
|
+
const scheme = port === 465 ? "smtps" : port === 25 ? "smtp" : "smtp+tls";
|
|
3719
|
+
const user = parts.username ? encodeURIComponent(parts.username) : "";
|
|
3720
|
+
const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
|
|
3721
|
+
return `${scheme}://${user ? `${user}${password}@` : ""}${parts.host}:${port}`;
|
|
3722
|
+
}
|
|
3723
|
+
/** The parts of an SMTP URL, for the current answers. */
|
|
3724
|
+
function smtpParts(value) {
|
|
3725
|
+
try {
|
|
3726
|
+
const url = new URL(value ?? "");
|
|
3727
|
+
return {
|
|
3728
|
+
host: url.hostname,
|
|
3729
|
+
port: url.port,
|
|
3730
|
+
username: decodeURIComponent(url.username),
|
|
3731
|
+
password: decodeURIComponent(url.password)
|
|
3732
|
+
};
|
|
3733
|
+
} catch {
|
|
3734
|
+
return {};
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
const placeholderMail = (value) => !value || value.includes("mailpit") || value.includes("example.com") || value.startsWith("console:");
|
|
3738
|
+
const SETTING_GROUPS = [
|
|
3739
|
+
{
|
|
3740
|
+
title: "Admin email",
|
|
3741
|
+
names: ["ADMIN_EMAIL"],
|
|
3742
|
+
applies: () => true,
|
|
3743
|
+
missing: (env) => !env.ADMIN_EMAIL || env.ADMIN_EMAIL.endsWith("@example.com") ? ["ADMIN_EMAIL"] : [],
|
|
3744
|
+
fields: [{
|
|
3745
|
+
name: "ADMIN_EMAIL",
|
|
3746
|
+
message: "Your email address, for the admin user",
|
|
3747
|
+
placeholder: "you@example.com",
|
|
3748
|
+
validate: validateEmail
|
|
3749
|
+
}],
|
|
3750
|
+
values: (answers) => ({ ADMIN_EMAIL: answers.ADMIN_EMAIL ?? "" })
|
|
3751
|
+
},
|
|
3752
|
+
{
|
|
3753
|
+
title: "Outgoing mail",
|
|
3754
|
+
names: ["EMAIL_URL"],
|
|
3755
|
+
applies: () => true,
|
|
3756
|
+
missing: (env) => placeholderMail(env.EMAIL_URL) ? ["EMAIL_URL"] : [],
|
|
3757
|
+
fields: [
|
|
3758
|
+
{
|
|
3759
|
+
name: "host",
|
|
3760
|
+
message: "SMTP server",
|
|
3761
|
+
placeholder: "smtp.example.com",
|
|
3762
|
+
validate: validateHost
|
|
3763
|
+
},
|
|
3764
|
+
{
|
|
3765
|
+
name: "port",
|
|
3766
|
+
message: "SMTP port",
|
|
3767
|
+
defaultValue: "587",
|
|
3768
|
+
validate: validatePort
|
|
3769
|
+
},
|
|
3770
|
+
{
|
|
3771
|
+
name: "username",
|
|
3772
|
+
message: "SMTP user name",
|
|
3773
|
+
optional: true
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
name: "password",
|
|
3777
|
+
message: "SMTP password",
|
|
3778
|
+
secret: true,
|
|
3779
|
+
optional: true
|
|
3780
|
+
}
|
|
3781
|
+
],
|
|
3782
|
+
current: (env, field) => placeholderMail(env.EMAIL_URL) ? void 0 : smtpParts(env.EMAIL_URL)[field],
|
|
3783
|
+
values: (answers) => ({ EMAIL_URL: smtpUrl({
|
|
3784
|
+
host: answers.host ?? "",
|
|
3785
|
+
port: answers.port ?? "587",
|
|
3786
|
+
username: answers.username,
|
|
3787
|
+
password: answers.password
|
|
3788
|
+
}) })
|
|
3789
|
+
},
|
|
3790
|
+
{
|
|
3791
|
+
title: "S3 for uploads",
|
|
3792
|
+
names: [
|
|
3793
|
+
"S3_BUCKET",
|
|
3794
|
+
"S3_ENDPOINT_URL",
|
|
3795
|
+
"S3_REGION",
|
|
3796
|
+
"S3_ACCESS_KEY_ID",
|
|
3797
|
+
"S3_SECRET_ACCESS_KEY",
|
|
3798
|
+
"S3_PUBLIC_URL"
|
|
3799
|
+
],
|
|
3800
|
+
applies: (env, context) => context.media === "s3" || Boolean(env.S3_BUCKET),
|
|
3801
|
+
missing: missingS3,
|
|
3802
|
+
fields: [
|
|
3803
|
+
{
|
|
3804
|
+
name: "S3_BUCKET",
|
|
3805
|
+
message: "S3 bucket",
|
|
3806
|
+
placeholder: "example-media",
|
|
3807
|
+
validate: required("bucket")
|
|
3808
|
+
},
|
|
3809
|
+
{
|
|
3810
|
+
name: "S3_ENDPOINT_URL",
|
|
3811
|
+
message: "S3 endpoint, empty for AWS",
|
|
3812
|
+
placeholder: "https://fsn1.your-objectstorage.com",
|
|
3813
|
+
optional: true,
|
|
3814
|
+
validate: validateUrl
|
|
3815
|
+
},
|
|
3816
|
+
{
|
|
3817
|
+
name: "S3_REGION",
|
|
3818
|
+
message: "S3 region",
|
|
3819
|
+
placeholder: "eu-central-1",
|
|
3820
|
+
validate: required("region")
|
|
3821
|
+
},
|
|
3822
|
+
{
|
|
3823
|
+
name: "S3_ACCESS_KEY_ID",
|
|
3824
|
+
message: "S3 access key ID",
|
|
3825
|
+
validate: required("access key ID")
|
|
3826
|
+
},
|
|
3827
|
+
{
|
|
3828
|
+
name: "S3_SECRET_ACCESS_KEY",
|
|
3829
|
+
message: "S3 secret access key",
|
|
3830
|
+
secret: true,
|
|
3831
|
+
validate: required("secret access key")
|
|
3832
|
+
},
|
|
3833
|
+
{
|
|
3834
|
+
name: "S3_PUBLIC_URL",
|
|
3835
|
+
message: "Public URL of the files, empty when it is the endpoint",
|
|
3836
|
+
placeholder: "https://media.example.com",
|
|
3837
|
+
optional: true,
|
|
3838
|
+
validate: validateUrl
|
|
3839
|
+
}
|
|
3840
|
+
],
|
|
3841
|
+
current: (env, field) => env[field],
|
|
3842
|
+
values: (answers) => Object.fromEntries([
|
|
3843
|
+
"S3_BUCKET",
|
|
3844
|
+
"S3_ENDPOINT_URL",
|
|
3845
|
+
"S3_REGION",
|
|
3846
|
+
"S3_ACCESS_KEY_ID",
|
|
3847
|
+
"S3_SECRET_ACCESS_KEY",
|
|
3848
|
+
"S3_PUBLIC_URL"
|
|
3849
|
+
].map((name) => [name, answers[name] ?? ""]))
|
|
3850
|
+
}
|
|
3851
|
+
];
|
|
3852
|
+
/** The groups of a production `.env` to ask for: the missing ones, or with `all` every one that applies. */
|
|
3853
|
+
function settingsToAsk(env, context, all = false) {
|
|
3854
|
+
return SETTING_GROUPS.filter((group) => group.applies(env, context) && (all || group.missing(env).length));
|
|
3855
|
+
}
|
|
3856
|
+
/** Every `.env` variable `--set` takes. */
|
|
3857
|
+
const SETTING_NAMES = SETTING_GROUPS.flatMap((group) => group.names);
|
|
3858
|
+
/** Parses `--set NAME=value` arguments. Throws with a one-line reason. */
|
|
3859
|
+
function parseAssignments(items) {
|
|
3860
|
+
const values = {};
|
|
3861
|
+
for (const item of items) {
|
|
3862
|
+
const index = item.indexOf("=");
|
|
3863
|
+
const name = index > 0 ? item.slice(0, index) : "";
|
|
3864
|
+
if (!SETTING_NAMES.includes(name)) throw new Error(`${JSON.stringify(item)} is not NAME=value for one of ${SETTING_NAMES.join(", ")}.`);
|
|
3865
|
+
const value = item.slice(index + 1);
|
|
3866
|
+
if (/[\r\n]/.test(value)) throw new Error(`The value of ${name} has a line break.`);
|
|
3867
|
+
if (name === "ADMIN_EMAIL" && validateEmail(value)) throw new Error(`ADMIN_EMAIL: ${validateEmail(value)}`);
|
|
3868
|
+
values[name] = value;
|
|
3869
|
+
}
|
|
3870
|
+
return values;
|
|
3871
|
+
}
|
|
3872
|
+
/** The items of `production`'s to-do list that the settings now in `env` have done. */
|
|
3873
|
+
function remainingTodo(todo, env) {
|
|
3874
|
+
return todo.filter((item) => {
|
|
3875
|
+
const group = SETTING_GROUPS.find((candidate) => candidate.names.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)));
|
|
3876
|
+
return !group || group.missing(env).length > 0;
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
// src/commands/settings.ts
|
|
3880
|
+
/** The `media` stack option of the project, from the preset in package.json. */
|
|
3881
|
+
function projectContext(directory) {
|
|
3882
|
+
try {
|
|
3883
|
+
return { media: projectStack(readProject(directory)).media };
|
|
3884
|
+
} catch {
|
|
3885
|
+
return {};
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
/** Asks for each field of the groups; returns the `.env` values. */
|
|
3889
|
+
async function askSettings(groups, env) {
|
|
3890
|
+
const values = {};
|
|
3891
|
+
for (const group of groups) {
|
|
3892
|
+
p.log.step(pc.bold(group.title));
|
|
3893
|
+
const answers = {};
|
|
3894
|
+
for (const field of group.fields) {
|
|
3895
|
+
const current = group.current?.(env, field.name);
|
|
3896
|
+
const check = (value) => {
|
|
3897
|
+
const text = (value ?? "").trim();
|
|
3898
|
+
if (!text && (field.optional || field.defaultValue || field.secret && current)) return void 0;
|
|
3899
|
+
return field.validate?.(text) ?? (text ? void 0 : `Enter the ${field.message.toLowerCase()}.`);
|
|
3900
|
+
};
|
|
3901
|
+
let answer;
|
|
3902
|
+
if (field.secret) {
|
|
3903
|
+
answer = unwrap(await p.password({
|
|
3904
|
+
message: current ? `${field.message} (empty keeps the current one)` : field.message,
|
|
3905
|
+
validate: check
|
|
3906
|
+
}));
|
|
3907
|
+
if (!answer && current) answer = current;
|
|
3908
|
+
} else answer = unwrap(await p.text({
|
|
3909
|
+
message: field.message,
|
|
3910
|
+
placeholder: field.placeholder ?? field.defaultValue,
|
|
3911
|
+
initialValue: current,
|
|
3912
|
+
defaultValue: field.defaultValue,
|
|
3913
|
+
validate: check
|
|
3914
|
+
}));
|
|
3915
|
+
answers[field.name] = (answer ?? field.defaultValue ?? "").trim();
|
|
3916
|
+
}
|
|
3917
|
+
Object.assign(values, group.values(answers));
|
|
3918
|
+
}
|
|
3919
|
+
return values;
|
|
3920
|
+
}
|
|
3921
|
+
/** Writes values to a `.env` file, quoted where Compose needs it. Throws for a value it can't write. */
|
|
3922
|
+
function writeSettings(path, values) {
|
|
3923
|
+
const quoted = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, quoteEnvValue(value)]));
|
|
3924
|
+
writeFileSync(path, setEnvValues(readFileSync(path, "utf8"), quoted), { mode: 384 });
|
|
3925
|
+
}
|
|
3926
|
+
/** The production `.env` of a project: `--file`, `.env.production`, or `.env` on a server. */
|
|
3927
|
+
function targetFile(directory, file) {
|
|
3928
|
+
if (file) {
|
|
3929
|
+
const path = join(directory, file);
|
|
3930
|
+
if (!existsSync(path)) fail(`${path} does not exist.`);
|
|
3931
|
+
return path;
|
|
3932
|
+
}
|
|
3933
|
+
const production = join(directory, ".env.production");
|
|
3934
|
+
if (existsSync(production)) return production;
|
|
3935
|
+
const env = join(directory, ".env");
|
|
3936
|
+
if (existsSync(env) && parseProfiles(parseEnv(readFileSync(env, "utf8")).COMPOSE_PROFILES).includes("prod")) return env;
|
|
3937
|
+
fail(`There is no .env.production yet. Write it with ${command("dstack production")}.`);
|
|
3938
|
+
}
|
|
3939
|
+
/** `dstack settings`: ask for the missing production settings and credentials, and save them. */
|
|
3940
|
+
async function settings(options) {
|
|
3941
|
+
const interactive = isInteractive(options.yes);
|
|
3942
|
+
intro("Production settings");
|
|
3943
|
+
const directory = findProjectDirectory(options.dir ?? ".");
|
|
3944
|
+
if (!directory) fail("No DjangoStack project here: run this where compose.yaml and .env are.");
|
|
3945
|
+
const target = targetFile(directory, options.file);
|
|
3946
|
+
const name = relative(directory, target);
|
|
3947
|
+
let assigned;
|
|
3948
|
+
try {
|
|
3949
|
+
assigned = parseAssignments(options.set ?? []);
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
fail(error.message);
|
|
3952
|
+
}
|
|
3953
|
+
const env = {
|
|
3954
|
+
...parseEnv(readFileSync(target, "utf8")),
|
|
3955
|
+
...assigned
|
|
3956
|
+
};
|
|
3957
|
+
const context = projectContext(directory);
|
|
3958
|
+
const missing = settingsToAsk(env, context);
|
|
3959
|
+
let values = assigned;
|
|
3960
|
+
if (interactive) {
|
|
3961
|
+
const groups = settingsToAsk(env, context, options.all);
|
|
3962
|
+
if (!groups.length && !Object.keys(assigned).length) {
|
|
3963
|
+
p.outro(pc.green(`Nothing is missing in ${name}`));
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
if (groups.length && !options.all) p.log.info(`${name} needs ${groups.map((group) => group.title.toLowerCase()).join(", ")}.`);
|
|
3967
|
+
values = {
|
|
3968
|
+
...assigned,
|
|
3969
|
+
...await askSettings(groups, env)
|
|
3970
|
+
};
|
|
3971
|
+
} else if (missing.length) fail(`${name} misses ${missing.flatMap((group) => group.missing(env)).join(", ")}. Pass them with --set NAME=value.`);
|
|
3972
|
+
else if (!Object.keys(assigned).length) {
|
|
3973
|
+
p.outro(pc.green(`Nothing is missing in ${name}`));
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
try {
|
|
3977
|
+
writeSettings(target, values);
|
|
3978
|
+
} catch (error) {
|
|
3979
|
+
fail(error.message);
|
|
3980
|
+
}
|
|
3981
|
+
p.log.success(`Saved ${Object.keys(values).join(", ")} in ${pc.bold(name)}`);
|
|
3982
|
+
if (existsSync(join(directory, ".dstack", "deploy.json")) && target.endsWith(".env.production")) p.outro(`Send them to the platform with ${command("dstack deploy env push")}`);
|
|
3983
|
+
else p.outro(pc.green("Done"));
|
|
3984
|
+
}
|
|
2435
3985
|
// src/commands/production.ts
|
|
2436
3986
|
async function production(directoryArgument, options) {
|
|
2437
3987
|
const interactive = isInteractive(options.yes);
|
|
@@ -2450,18 +4000,36 @@ async function production(directoryArgument, options) {
|
|
|
2450
4000
|
validate: (v) => validateDomain(v ?? "")
|
|
2451
4001
|
}));
|
|
2452
4002
|
}
|
|
4003
|
+
const context = projectContext(directory);
|
|
2453
4004
|
let result;
|
|
2454
4005
|
try {
|
|
2455
|
-
result = productionEnv(readFileSync(envPath, "utf8"), {
|
|
4006
|
+
result = productionEnv$1(readFileSync(envPath, "utf8"), {
|
|
2456
4007
|
domain,
|
|
2457
|
-
emailUrl: options.emailUrl
|
|
4008
|
+
emailUrl: options.emailUrl,
|
|
4009
|
+
media: context.media
|
|
2458
4010
|
});
|
|
2459
4011
|
} catch (error) {
|
|
2460
4012
|
fail(error.message);
|
|
2461
4013
|
}
|
|
2462
4014
|
writeFileSync(target, result.text, { mode: 384 });
|
|
2463
4015
|
p.log.success(`Wrote ${pc.bold(relativeDirectory(target))} with new secrets for the server.`);
|
|
2464
|
-
|
|
4016
|
+
let todo = result.todo;
|
|
4017
|
+
const missing = settingsToAsk(parseEnv(result.text), context);
|
|
4018
|
+
if (interactive && missing.length) {
|
|
4019
|
+
if (unwrap(await p.confirm({
|
|
4020
|
+
message: `Set the ${missing.map((group) => group.title.toLowerCase()).join(", ")} now?`,
|
|
4021
|
+
initialValue: true
|
|
4022
|
+
}))) {
|
|
4023
|
+
try {
|
|
4024
|
+
writeSettings(target, await askSettings(missing, parseEnv(result.text)));
|
|
4025
|
+
} catch (error) {
|
|
4026
|
+
fail(error.message);
|
|
4027
|
+
}
|
|
4028
|
+
todo = remainingTodo(todo, parseEnv(readFileSync(target, "utf8")));
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
if (todo.some((item) => SETTING_NAMES.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)))) todo = [...todo, `Run ${command("dstack settings")} to set them.`];
|
|
4032
|
+
if (todo.length) p.note(todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
|
|
2465
4033
|
p.note([
|
|
2466
4034
|
`Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
|
|
2467
4035
|
command("docker compose up -d --build"),
|
|
@@ -2473,7 +4041,13 @@ async function production(directoryArgument, options) {
|
|
|
2473
4041
|
}
|
|
2474
4042
|
// src/config.ts
|
|
2475
4043
|
/** The options `config` can switch in an existing project. The rest follow with code generation. */
|
|
2476
|
-
const SWITCHABLE = [
|
|
4044
|
+
const SWITCHABLE = [
|
|
4045
|
+
"storage",
|
|
4046
|
+
"analytics",
|
|
4047
|
+
"media",
|
|
4048
|
+
"cache",
|
|
4049
|
+
"tasks"
|
|
4050
|
+
];
|
|
2477
4051
|
const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
|
|
2478
4052
|
/**
|
|
2479
4053
|
* Applies a new stack to a project's `.env`. Throws with a one-line reason
|
|
@@ -2482,7 +4056,7 @@ const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key
|
|
|
2482
4056
|
function applyStack(envText, env, from, to) {
|
|
2483
4057
|
let text = envText;
|
|
2484
4058
|
const changes = [];
|
|
2485
|
-
|
|
4059
|
+
let profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
2486
4060
|
for (const option of STACK_OPTIONS) {
|
|
2487
4061
|
const key = option.key;
|
|
2488
4062
|
if (from[key] === to[key]) continue;
|
|
@@ -2493,14 +4067,53 @@ function applyStack(envText, env, from, to) {
|
|
|
2493
4067
|
if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
|
|
2494
4068
|
const endpoint = env.S3_ENDPOINT_URL ?? "";
|
|
2495
4069
|
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")
|
|
4070
|
+
profiles = profiles.filter((profile) => profile !== "s3");
|
|
4071
|
+
if (to.storage === "s3") profiles.push("s3");
|
|
2498
4072
|
text = setEnvValues(text, {
|
|
2499
|
-
...to.storage === "s3" ?
|
|
2500
|
-
COMPOSE_PROFILES:
|
|
4073
|
+
...to.storage === "s3" ? localS3(envPorts(env).S3_PORT) : EMPTY_S3,
|
|
4074
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
2501
4075
|
});
|
|
2502
4076
|
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
4077
|
}
|
|
4078
|
+
if (key === "cache") {
|
|
4079
|
+
text = setEnvValues(text, { CACHE_URL: cacheEnv(to).CACHE_URL ?? "" });
|
|
4080
|
+
changes.push({
|
|
4081
|
+
database: "The cache is a table in the database.",
|
|
4082
|
+
valkey: "The cache is in Valkey (the valkey profile).",
|
|
4083
|
+
off: "The cache is per process; nothing is shared."
|
|
4084
|
+
}[to.cache]);
|
|
4085
|
+
}
|
|
4086
|
+
if (key === "tasks" || key === "cache" && from.tasks === "huey" && to.tasks === "huey") {
|
|
4087
|
+
const { CACHE_URL: _, ...tasks } = cacheEnv(to);
|
|
4088
|
+
const before = parseEnv(text).TASKS_URL;
|
|
4089
|
+
text = setEnvValues(text, tasks);
|
|
4090
|
+
if (before !== tasks.TASKS_URL) changes.push(to.tasks === "off" ? "Tasks run in the request; the worker is off, so there are no daily backups." : `The worker keeps its task queue in ${tasks.TASKS_URL === "db://" ? "the database" : "Valkey"}.`);
|
|
4091
|
+
}
|
|
4092
|
+
if (key === "cache" || key === "tasks") {
|
|
4093
|
+
profiles = profiles.filter((profile) => profile !== "valkey");
|
|
4094
|
+
if (usesValkey(parseEnv(text))) profiles.push("valkey");
|
|
4095
|
+
text = setEnvValues(text, { COMPOSE_PROFILES: profiles.join(",") });
|
|
4096
|
+
}
|
|
4097
|
+
if (key === "analytics") {
|
|
4098
|
+
profiles = profiles.filter((profile) => profile !== "analytics");
|
|
4099
|
+
if (to.analytics === "umami") {
|
|
4100
|
+
profiles.push("analytics");
|
|
4101
|
+
const fresh = umamiEnv(env.DATABASE_URL ?? "", generateProjectSecrets());
|
|
4102
|
+
const kept = Object.fromEntries(Object.entries(fresh).filter(([name]) => name === "UMAMI_URL" || !env[name]));
|
|
4103
|
+
text = setEnvValues(text, {
|
|
4104
|
+
...kept,
|
|
4105
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
4106
|
+
});
|
|
4107
|
+
changes.push("Page analytics with Umami are on (the analytics profile).");
|
|
4108
|
+
} else {
|
|
4109
|
+
text = setEnvValues(text, {
|
|
4110
|
+
UMAMI_URL: "",
|
|
4111
|
+
COMPOSE_PROFILES: profiles.join(",")
|
|
4112
|
+
});
|
|
4113
|
+
changes.push("Page analytics are off; Umami's data stays in the database.");
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
if (key === "media") changes.push(to.media === "s3" ? "Uploads in production go to S3; dstack settings asks for the S3 settings." : "Uploads in production stay on the server.");
|
|
2504
4117
|
}
|
|
2505
4118
|
return {
|
|
2506
4119
|
env: text,
|
|
@@ -2519,6 +4132,7 @@ function setManifestPreset(manifestText, preset) {
|
|
|
2519
4132
|
// src/commands/project.ts
|
|
2520
4133
|
function open(options) {
|
|
2521
4134
|
if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
|
|
4135
|
+
if (!dockerRunning()) fail(dockerNotRunning());
|
|
2522
4136
|
try {
|
|
2523
4137
|
return readProject(options.dir ?? ".");
|
|
2524
4138
|
} catch (error) {
|
|
@@ -2539,7 +4153,7 @@ function exitWith(status) {
|
|
|
2539
4153
|
function ensureRunEnv(project) {
|
|
2540
4154
|
const path = join(project.directory, RUN_ENV);
|
|
2541
4155
|
if (!existsSync(path)) {
|
|
2542
|
-
const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
|
|
4156
|
+
const result = productionEnv$1(readFileSync(join(project.directory, ".env"), "utf8"), {
|
|
2543
4157
|
domain: RUN_DOMAIN,
|
|
2544
4158
|
emailUrl: "console://"
|
|
2545
4159
|
});
|
|
@@ -2569,11 +4183,33 @@ async function run(options) {
|
|
|
2569
4183
|
if (project.localRun) p.log.message(pc.dim(`Caddy signs ${RUN_DOMAIN} with its own local CA, so the browser warns once. The admin password is in ${RUN_ENV}.`));
|
|
2570
4184
|
p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
|
|
2571
4185
|
}
|
|
4186
|
+
/**
|
|
4187
|
+
* Moves the development services to free ports when theirs are in use, in
|
|
4188
|
+
* `.env`, unless the project already runs. compose.yaml from before the
|
|
4189
|
+
* *_PORT variables has fixed ports, so it can only say which one is in use.
|
|
4190
|
+
*/
|
|
4191
|
+
async function freeDevPorts(project) {
|
|
4192
|
+
if (runningServices(project).length) return project;
|
|
4193
|
+
const { ports, moves, stuck } = await chooseFreePorts(project.env, project.profiles);
|
|
4194
|
+
const [blocked] = stuck;
|
|
4195
|
+
if (blocked) fail(`Port ${envPorts(project.env)[blocked.name]} of the ${blocked.label} is in use. Stop what uses it.`);
|
|
4196
|
+
if (!moves.length) return project;
|
|
4197
|
+
const composeText = readFileSync(join(project.directory, "compose.yaml"), "utf8");
|
|
4198
|
+
for (const move of moves) {
|
|
4199
|
+
const item = DEV_PORTS.find((candidate) => candidate.label === move.label);
|
|
4200
|
+
if (item && !composeText.includes(`\${${item.name}`)) fail(`Port ${move.from} of the ${move.label} is in use. Stop what uses it, or publish another port in compose.yaml.`);
|
|
4201
|
+
}
|
|
4202
|
+
const envPath = join(project.directory, ".env");
|
|
4203
|
+
writeFileSync(envPath, setEnvValues(readFileSync(envPath, "utf8"), portValues(project.env, ports)), { mode: 384 });
|
|
4204
|
+
for (const move of moves) p.log.info(`${describeMove(move)} Saved in .env.`);
|
|
4205
|
+
return readProject(project.directory);
|
|
4206
|
+
}
|
|
2572
4207
|
/** `dstack dev`: the dev profile in the foreground, with hot reload. */
|
|
2573
4208
|
async function dev(options) {
|
|
2574
|
-
|
|
4209
|
+
let project = open(options);
|
|
2575
4210
|
if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
|
|
2576
4211
|
intro("Develop");
|
|
4212
|
+
project = await freeDevPorts(project);
|
|
2577
4213
|
urlsNote(project, "Starting");
|
|
2578
4214
|
p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
|
|
2579
4215
|
exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
|
|
@@ -2621,23 +4257,34 @@ async function shell(options) {
|
|
|
2621
4257
|
return manage(["shell"], options);
|
|
2622
4258
|
}
|
|
2623
4259
|
/**
|
|
2624
|
-
* The project and
|
|
4260
|
+
* The project and how to run a backup command: in the prod worker, on a
|
|
2625
4261
|
* server or the local run, or the `backup` service of projects from 0.1.
|
|
4262
|
+
* Without a running worker (tasks off, TASK_WORKERS=0) in a one-off worker
|
|
4263
|
+
* container, while the backend runs.
|
|
2626
4264
|
*/
|
|
2627
4265
|
function backupProject(options) {
|
|
2628
4266
|
const project = activeProject(open(options));
|
|
2629
4267
|
if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
|
|
2630
4268
|
const running = runningServices(project);
|
|
2631
4269
|
const service = ["backup", project.services.worker].find((name) => running.includes(name));
|
|
2632
|
-
if (
|
|
2633
|
-
|
|
4270
|
+
if (service) return {
|
|
4271
|
+
project,
|
|
4272
|
+
run: ["exec", service]
|
|
4273
|
+
};
|
|
4274
|
+
if (project.env.TASK_WORKERS === "0" && running.includes(project.services.backend)) return {
|
|
2634
4275
|
project,
|
|
2635
|
-
|
|
4276
|
+
run: [
|
|
4277
|
+
"run",
|
|
4278
|
+
"--rm",
|
|
4279
|
+
"--no-deps",
|
|
4280
|
+
project.services.worker
|
|
4281
|
+
]
|
|
2636
4282
|
};
|
|
4283
|
+
fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
|
|
2637
4284
|
}
|
|
2638
4285
|
/** `dstack backup`: back up the database and the media now, or list the backups. */
|
|
2639
4286
|
async function backup(options) {
|
|
2640
|
-
const { project,
|
|
4287
|
+
const { project, run } = backupProject(options);
|
|
2641
4288
|
const args = [
|
|
2642
4289
|
"python",
|
|
2643
4290
|
"manage.py",
|
|
@@ -2646,9 +4293,9 @@ async function backup(options) {
|
|
|
2646
4293
|
if (options.list) args.push("--list");
|
|
2647
4294
|
else if (!options.media) args.push("--no-media");
|
|
2648
4295
|
exitWith(compose(project, [
|
|
2649
|
-
|
|
4296
|
+
run[0],
|
|
2650
4297
|
"-T",
|
|
2651
|
-
|
|
4298
|
+
...run.slice(1),
|
|
2652
4299
|
...args
|
|
2653
4300
|
]));
|
|
2654
4301
|
}
|
|
@@ -2673,13 +4320,13 @@ function restoreArgs(name, options) {
|
|
|
2673
4320
|
}
|
|
2674
4321
|
/** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
|
|
2675
4322
|
async function restore(name, options) {
|
|
2676
|
-
const { project,
|
|
4323
|
+
const { project, run } = backupProject(options);
|
|
2677
4324
|
const args = restoreArgs(name, options);
|
|
2678
4325
|
if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
|
|
2679
4326
|
exitWith(compose(project, [
|
|
2680
|
-
|
|
4327
|
+
run[0],
|
|
2681
4328
|
...options.yes ? ["-T"] : [],
|
|
2682
|
-
|
|
4329
|
+
...run.slice(1),
|
|
2683
4330
|
...args
|
|
2684
4331
|
]));
|
|
2685
4332
|
}
|
|
@@ -2917,19 +4564,15 @@ async function config(options) {
|
|
|
2917
4564
|
intro("Configure");
|
|
2918
4565
|
const current = projectStack(project);
|
|
2919
4566
|
const next = { ...current };
|
|
2920
|
-
for (const
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
hint: value.generated ? value.hint : "coming soon",
|
|
2930
|
-
disabled: !value.generated && value.value !== current[key]
|
|
2931
|
-
}))
|
|
2932
|
-
}));
|
|
4567
|
+
for (const key of PROMPT_ORDER) if (options.fromCli(key)) next[key] = options[key];
|
|
4568
|
+
else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
|
|
4569
|
+
message: stackOption(key).message,
|
|
4570
|
+
initialValue: current[key],
|
|
4571
|
+
options: choices(key, next)
|
|
4572
|
+
}));
|
|
4573
|
+
if (next.analytics === "umami" && current.analytics !== "umami") {
|
|
4574
|
+
const compose = join(project.directory, "compose.yaml");
|
|
4575
|
+
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.");
|
|
2933
4576
|
}
|
|
2934
4577
|
const envPath = join(project.directory, ".env");
|
|
2935
4578
|
let result;
|
|
@@ -2942,11 +4585,13 @@ async function config(options) {
|
|
|
2942
4585
|
p.outro("Nothing changed");
|
|
2943
4586
|
return;
|
|
2944
4587
|
}
|
|
2945
|
-
|
|
4588
|
+
const envText = readFileSync(envPath, "utf8");
|
|
4589
|
+
if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
|
|
2946
4590
|
const manifestPath = join(project.directory, "package.json");
|
|
2947
4591
|
if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
|
|
2948
4592
|
for (const change of result.changes) p.log.success(change);
|
|
2949
|
-
|
|
4593
|
+
if (result.env === envText) p.outro(pc.green("Done"));
|
|
4594
|
+
else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
|
|
2950
4595
|
}
|
|
2951
4596
|
// src/program.ts
|
|
2952
4597
|
/** Commands that print machine-readable output, so no update notice. */
|
|
@@ -3001,7 +4646,7 @@ function createProgram() {
|
|
|
3001
4646
|
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
4647
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
3003
4648
|
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");
|
|
4649
|
+
const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
|
|
3005
4650
|
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
4651
|
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
4652
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -3023,6 +4668,7 @@ function createProgram() {
|
|
|
3023
4668
|
}));
|
|
3024
4669
|
for (const flag of stackFlags()) presetCommand.addOption(flag);
|
|
3025
4670
|
program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
|
|
4671
|
+
program.command("settings").description("Ask for the missing production settings and credentials, and save them").addOption(dirOption()).option("--file <file>", "The file to change (default: .env.production, or .env on a server)").option("--set <NAME=value>", "Set a value without asking, such as --set ADMIN_EMAIL=you@example.com", (value, previous = []) => [...previous, value]).option("--all", "Ask for every setting, also the ones that are set").option("-y, --yes", "Don't ask; fail when a setting is still missing").action(settings);
|
|
3026
4672
|
program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
|
|
3027
4673
|
program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
|
|
3028
4674
|
tab(program, { completionCommandName: "completion" });
|