@django-stack/cli 0.8.1 → 0.9.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 +814 -53
- package/dist/index.d.ts +32 -1
- package/dist/index.js +1 -1
- package/dist/{production-CGkI7oNe.js → production-M2SsuuBb.js} +61 -3
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -5
- package/templates/backend/config/settings.py +2 -0
- package/templates/backend/config/studio.py +4 -0
- package/templates/backend/content/apps.py +5 -1
- package/templates/backend/content/notifications.py +36 -0
- package/templates/backend/content/tests/test_api.py +18 -0
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/Caddyfile +7 -1
- package/templates/base/compose.yaml +30 -2
- package/templates/base/env.example +17 -0
- package/templates/frontend-astro/Dockerfile +2 -2
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-next/Dockerfile +2 -2
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-nuxt/Dockerfile +2 -2
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-sveltekit/Dockerfile +2 -2
- package/templates/frontend-sveltekit/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as dockerRunning, A as projectStack, C as RUN_DOMAIN, D as composeOutput, E as compose, F as DEFAULT_STACK, G as DEV_PORTS, H as pendingChoices, I as NAMED_PRESETS, J as envPorts, K as chooseFreePorts, L as PROMPT_ORDER, M as readLocalRun, N as readProject, O as findProjectDirectory, P as runningServices, Q as dockerNotRunning, R as STACK_OPTIONS, S as generateSecret, T as activeProject, U as resolvePreset, V as encodePreset, W as stackOption, X as runDoctor, Y as portValues, Z as checkTools, _ as validateProjectName, a as LOCAL_S3, b as PACKAGE_NAME, c as lockBackend, d as scaffold, et as isDockerDown, f as setNotificationsApp, g as usesValkey, h as umamiEnv, j as projectUrls, k as parseProfiles, m as toProjectName, n as productionEnv$1, nt as quoteEnvValue, o as cacheEnv, p as timeZones, q as describeMove, r as validateDomain, rt as setEnvValues, s as localS3, t as missingS3, tt as parseEnv, u as relativeDirectory, v as validateTimeZone, w as RUN_ENV, x as generateProjectSecrets, y as CLI_VERSION, z as applies } from "./production-M2SsuuBb.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename, delimiter, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { basename, delimiter, dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import pc from "picocolors";
|
|
@@ -19,7 +19,7 @@ const DROPPED_VOLUMES = /* @__PURE__ */ new Set([
|
|
|
19
19
|
"media"
|
|
20
20
|
]);
|
|
21
21
|
/** Compose keys that do not apply on Bunny: its gVisor sandbox isolates the app instead. */
|
|
22
|
-
const IGNORED_KEYS = [
|
|
22
|
+
const IGNORED_KEYS$1 = [
|
|
23
23
|
"read_only",
|
|
24
24
|
"tmpfs",
|
|
25
25
|
"cap_add",
|
|
@@ -81,15 +81,15 @@ function parsePublicImage(reference) {
|
|
|
81
81
|
function repositoryName(value) {
|
|
82
82
|
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "app";
|
|
83
83
|
}
|
|
84
|
-
function escapeRegExp(value) {
|
|
84
|
+
function escapeRegExp$1(value) {
|
|
85
85
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
86
|
}
|
|
87
87
|
/** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
|
|
88
|
-
function hostPattern(name) {
|
|
89
|
-
return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
|
|
88
|
+
function hostPattern$1(name) {
|
|
89
|
+
return new RegExp(`(^|[^\\w.-])${escapeRegExp$1(name)}:(\\d{1,5})(?!\\d)`, "g");
|
|
90
90
|
}
|
|
91
91
|
/** The names other services reach a service by: its name and its network aliases. */
|
|
92
|
-
function hostNames(service, definition) {
|
|
92
|
+
function hostNames$1(service, definition) {
|
|
93
93
|
const names = /* @__PURE__ */ new Set([service]);
|
|
94
94
|
for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
|
|
95
95
|
return [...names];
|
|
@@ -97,7 +97,7 @@ function hostNames(service, definition) {
|
|
|
97
97
|
/** Replaces service host names with 127.0.0.1, which is the pod on Bunny. */
|
|
98
98
|
function rewriteHosts(text, names) {
|
|
99
99
|
let result = text;
|
|
100
|
-
for (const name of names) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
|
|
100
|
+
for (const name of names) result = result.replace(hostPattern$1(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
|
|
101
101
|
return result;
|
|
102
102
|
}
|
|
103
103
|
function containerName(service, taken) {
|
|
@@ -140,7 +140,7 @@ function planApp(project, options) {
|
|
|
140
140
|
const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
|
|
141
141
|
if (!entries.length) throw new Error("compose.yaml has no services for production.");
|
|
142
142
|
const names = /* @__PURE__ */ new Map();
|
|
143
|
-
for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
|
|
143
|
+
for (const [service, definition] of entries) names.set(service, hostNames$1(service, definition));
|
|
144
144
|
const allNames = [...names.values()].flat();
|
|
145
145
|
const builds = /* @__PURE__ */ new Map();
|
|
146
146
|
const baked = /* @__PURE__ */ new Map();
|
|
@@ -178,7 +178,7 @@ function planApp(project, options) {
|
|
|
178
178
|
const portsOf = /* @__PURE__ */ new Map();
|
|
179
179
|
for (const [service, definition] of entries) {
|
|
180
180
|
const found = /* @__PURE__ */ new Set();
|
|
181
|
-
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
|
+
for (const name of names.get(service) ?? []) for (const text of texts) for (const match of text.matchAll(hostPattern$1(name))) found.add(Number(match[2]));
|
|
182
182
|
const own = definition.healthcheck?.test ? healthUrl(definition.healthcheck.test) : void 0;
|
|
183
183
|
if (own) found.add(own.port);
|
|
184
184
|
for (const port of definition.ports ?? []) found.add(port.target);
|
|
@@ -212,7 +212,7 @@ function planApp(project, options) {
|
|
|
212
212
|
const endpointPort = entryPorts.includes(80) ? 80 : entryPorts[0];
|
|
213
213
|
const rewrite = (text) => {
|
|
214
214
|
let result = text;
|
|
215
|
-
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
|
+
for (const [service, move] of moved) for (const name of names.get(service) ?? []) result = result.replace(hostPattern$1(name), (whole, before, port) => Number(port) === move.from ? `${before}${name}:${move.to}` : whole);
|
|
216
216
|
return rewriteHosts(result, allNames);
|
|
217
217
|
};
|
|
218
218
|
for (const build of builds.values()) if (build.kind === "bake") for (const file of build.files) file.content = rewrite(file.content);
|
|
@@ -220,7 +220,7 @@ function planApp(project, options) {
|
|
|
220
220
|
const taken = /* @__PURE__ */ new Set();
|
|
221
221
|
const containers = [];
|
|
222
222
|
for (const [service, definition] of entries) {
|
|
223
|
-
for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
|
|
223
|
+
for (const key of IGNORED_KEYS$1) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
|
|
224
224
|
let image;
|
|
225
225
|
const bake = baked.get(service);
|
|
226
226
|
if (bake) image = {
|
|
@@ -883,6 +883,40 @@ const systemRunner = (command, args, options = {}) => new Promise((resolvePromis
|
|
|
883
883
|
child.stdin.on("error", () => {});
|
|
884
884
|
child.stdin.end(options.input);
|
|
885
885
|
});
|
|
886
|
+
/** The production Compose project, resolved with the variables, as JSON. */
|
|
887
|
+
async function resolveCompose(run, directory, env) {
|
|
888
|
+
const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
|
|
889
|
+
const file = join(folder, ".env");
|
|
890
|
+
writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
|
|
891
|
+
const args = [
|
|
892
|
+
"compose",
|
|
893
|
+
"--env-file",
|
|
894
|
+
file,
|
|
895
|
+
"config",
|
|
896
|
+
"--format",
|
|
897
|
+
"json"
|
|
898
|
+
];
|
|
899
|
+
try {
|
|
900
|
+
const result = await run("docker", args, {
|
|
901
|
+
cwd: directory,
|
|
902
|
+
env: {
|
|
903
|
+
...env,
|
|
904
|
+
ENV_FILE: file
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
if (result.code !== 0) {
|
|
908
|
+
if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
|
|
909
|
+
const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
|
|
910
|
+
throw new Error(`docker compose failed (exit ${result.code})${last ? `: ${last}` : "."}`);
|
|
911
|
+
}
|
|
912
|
+
return JSON.parse(result.stdout);
|
|
913
|
+
} finally {
|
|
914
|
+
rmSync(folder, {
|
|
915
|
+
recursive: true,
|
|
916
|
+
force: true
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
886
920
|
var BunnyError = class extends Error {
|
|
887
921
|
status;
|
|
888
922
|
constructor(message, status) {
|
|
@@ -1186,31 +1220,7 @@ var BunnyClient = class {
|
|
|
1186
1220
|
}
|
|
1187
1221
|
/** The production Compose project, resolved with the variables, as JSON. */
|
|
1188
1222
|
async composeConfig(directory, env) {
|
|
1189
|
-
|
|
1190
|
-
const file = join(folder, ".env");
|
|
1191
|
-
writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
|
|
1192
|
-
try {
|
|
1193
|
-
const output = await this.docker([
|
|
1194
|
-
"compose",
|
|
1195
|
-
"--env-file",
|
|
1196
|
-
file,
|
|
1197
|
-
"config",
|
|
1198
|
-
"--format",
|
|
1199
|
-
"json"
|
|
1200
|
-
], {
|
|
1201
|
-
cwd: directory,
|
|
1202
|
-
env: {
|
|
1203
|
-
...env,
|
|
1204
|
-
ENV_FILE: file
|
|
1205
|
-
}
|
|
1206
|
-
});
|
|
1207
|
-
return JSON.parse(output);
|
|
1208
|
-
} finally {
|
|
1209
|
-
rmSync(folder, {
|
|
1210
|
-
recursive: true,
|
|
1211
|
-
force: true
|
|
1212
|
-
});
|
|
1213
|
-
}
|
|
1223
|
+
return resolveCompose(this.run, directory, env);
|
|
1214
1224
|
}
|
|
1215
1225
|
async plan(directory, env) {
|
|
1216
1226
|
return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
|
|
@@ -1919,7 +1929,7 @@ function toDeployment(api, link, item) {
|
|
|
1919
1929
|
};
|
|
1920
1930
|
}
|
|
1921
1931
|
/** Finds a name or uuid in a list. */
|
|
1922
|
-
function find$
|
|
1932
|
+
function find$2(items, wanted) {
|
|
1923
1933
|
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
1924
1934
|
}
|
|
1925
1935
|
var CoolifyClient = class {
|
|
@@ -1940,10 +1950,10 @@ var CoolifyClient = class {
|
|
|
1940
1950
|
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
1941
1951
|
let server;
|
|
1942
1952
|
if (options.server) {
|
|
1943
|
-
server = find$
|
|
1953
|
+
server = find$2(servers, options.server);
|
|
1944
1954
|
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
1945
1955
|
} else if (servers.length === 1) server = servers[0];
|
|
1946
|
-
else server = find$
|
|
1956
|
+
else server = find$2(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
1947
1957
|
value: item.uuid,
|
|
1948
1958
|
label: item.name,
|
|
1949
1959
|
hint: item.ip
|
|
@@ -1952,7 +1962,7 @@ var CoolifyClient = class {
|
|
|
1952
1962
|
const projects = await api.get("/projects");
|
|
1953
1963
|
let project;
|
|
1954
1964
|
const projectName = options.project ?? context.name;
|
|
1955
|
-
project = find$
|
|
1965
|
+
project = find$2(projects, projectName);
|
|
1956
1966
|
if (!options.project && ask.interactive && projects.length) {
|
|
1957
1967
|
const NEW = "";
|
|
1958
1968
|
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
@@ -1963,7 +1973,7 @@ var CoolifyClient = class {
|
|
|
1963
1973
|
label: item.name,
|
|
1964
1974
|
hint: item.description ?? void 0
|
|
1965
1975
|
}))], project?.uuid ?? NEW);
|
|
1966
|
-
project = uuid === NEW ? void 0 : find$
|
|
1976
|
+
project = uuid === NEW ? void 0 : find$2(projects, uuid);
|
|
1967
1977
|
}
|
|
1968
1978
|
if (!project) {
|
|
1969
1979
|
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
@@ -1977,7 +1987,7 @@ var CoolifyClient = class {
|
|
|
1977
1987
|
ask.step(`Created the Coolify project ${projectName}`);
|
|
1978
1988
|
}
|
|
1979
1989
|
const environmentName = options.environment ?? "production";
|
|
1980
|
-
let environment = find$
|
|
1990
|
+
let environment = find$2(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
1981
1991
|
if (!environment) {
|
|
1982
1992
|
environment = {
|
|
1983
1993
|
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
@@ -1987,7 +1997,7 @@ var CoolifyClient = class {
|
|
|
1987
1997
|
}
|
|
1988
1998
|
let githubApp;
|
|
1989
1999
|
if (options.githubApp && options.githubApp !== "public") {
|
|
1990
|
-
const app = find$
|
|
2000
|
+
const app = find$2(await api.get("/github-apps"), options.githubApp);
|
|
1991
2001
|
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
1992
2002
|
githubApp = app.uuid;
|
|
1993
2003
|
} else if (!options.githubApp && ask.interactive) {
|
|
@@ -2180,7 +2190,7 @@ function composeUrl(api, link) {
|
|
|
2180
2190
|
return `${api.url}/dashboard/project/${link.project}/environment/${link.environment}/services/compose/${link.application}`;
|
|
2181
2191
|
}
|
|
2182
2192
|
/** Finds a name or id in a list. */
|
|
2183
|
-
function find(items, wanted, id, name) {
|
|
2193
|
+
function find$1(items, wanted, id, name) {
|
|
2184
2194
|
return items.find((item) => id(item) === wanted) ?? items.find((item) => name(item) === wanted);
|
|
2185
2195
|
}
|
|
2186
2196
|
const LOCAL_SERVER = "dokploy";
|
|
@@ -2201,7 +2211,7 @@ var DokployClient = class {
|
|
|
2201
2211
|
const servers = await api.get("server.all").catch(() => []) ?? [];
|
|
2202
2212
|
let serverId;
|
|
2203
2213
|
if (options.server && options.server !== LOCAL_SERVER) {
|
|
2204
|
-
const server = find(servers, options.server, (item) => item.serverId, (item) => item.name);
|
|
2214
|
+
const server = find$1(servers, options.server, (item) => item.serverId, (item) => item.name);
|
|
2205
2215
|
if (!server) throw new Error(`Dokploy has no server ${options.server}.`);
|
|
2206
2216
|
serverId = server.serverId;
|
|
2207
2217
|
} else if (!options.server && servers.length && ask.interactive) {
|
|
@@ -2217,7 +2227,7 @@ var DokployClient = class {
|
|
|
2217
2227
|
}
|
|
2218
2228
|
const projects = await api.get("project.all") ?? [];
|
|
2219
2229
|
const projectName = options.project ?? context.name;
|
|
2220
|
-
let project = find(projects, projectName, (item) => item.projectId, (item) => item.name);
|
|
2230
|
+
let project = find$1(projects, projectName, (item) => item.projectId, (item) => item.name);
|
|
2221
2231
|
if (!options.project && ask.interactive && projects.length) {
|
|
2222
2232
|
const NEW = "";
|
|
2223
2233
|
const id = await ask.select("Dokploy project", [...project ? [] : [{
|
|
@@ -2228,7 +2238,7 @@ var DokployClient = class {
|
|
|
2228
2238
|
label: item.name,
|
|
2229
2239
|
hint: item.description ?? void 0
|
|
2230
2240
|
}))], project?.projectId ?? NEW);
|
|
2231
|
-
project = id === NEW ? void 0 : find(projects, id, (item) => item.projectId, (item) => item.name);
|
|
2241
|
+
project = id === NEW ? void 0 : find$1(projects, id, (item) => item.projectId, (item) => item.name);
|
|
2232
2242
|
}
|
|
2233
2243
|
if (!project) {
|
|
2234
2244
|
const created = await api.post("project.create", {
|
|
@@ -2244,7 +2254,7 @@ var DokployClient = class {
|
|
|
2244
2254
|
ask.step(`Created the Dokploy project ${projectName}`);
|
|
2245
2255
|
}
|
|
2246
2256
|
const environmentName = options.environment ?? "production";
|
|
2247
|
-
let environment = find(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
|
|
2257
|
+
let environment = find$1(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
|
|
2248
2258
|
if (error.status === 404) throw new Error("This Dokploy has no environments; update Dokploy to a version with environments.");
|
|
2249
2259
|
throw error;
|
|
2250
2260
|
}) ?? [], environmentName, (item) => item.environmentId, (item) => item.name);
|
|
@@ -2380,6 +2390,726 @@ const dokploy = {
|
|
|
2380
2390
|
return new DokployClient(url, token);
|
|
2381
2391
|
}
|
|
2382
2392
|
};
|
|
2393
|
+
// src/deploy/railway-compose.ts
|
|
2394
|
+
/**
|
|
2395
|
+
* Turns the resolved Compose project (`docker compose config --format json`)
|
|
2396
|
+
* into Railway services. Railway runs each service on its own and reaches it
|
|
2397
|
+
* at `<name>.railway.internal` on the private network; a service has at most
|
|
2398
|
+
* one volume and no bind mounts, and Railway builds a service from a
|
|
2399
|
+
* directory of the git repository. This module is pure, so it is tested on
|
|
2400
|
+
* the template's output.
|
|
2401
|
+
*/
|
|
2402
|
+
/** Volumes not needed on Railway: its edge holds the certificate, uploads and backups go to S3. */
|
|
2403
|
+
const RAILWAY_DROPPED_VOLUMES = /* @__PURE__ */ new Set([
|
|
2404
|
+
"caddy_data",
|
|
2405
|
+
"caddy_config",
|
|
2406
|
+
"backups",
|
|
2407
|
+
"media"
|
|
2408
|
+
]);
|
|
2409
|
+
/** Compose keys that do not apply on Railway. */
|
|
2410
|
+
const IGNORED_KEYS = [
|
|
2411
|
+
"read_only",
|
|
2412
|
+
"tmpfs",
|
|
2413
|
+
"cap_add",
|
|
2414
|
+
"cap_drop",
|
|
2415
|
+
"security_opt",
|
|
2416
|
+
"user",
|
|
2417
|
+
"restart",
|
|
2418
|
+
"depends_on"
|
|
2419
|
+
];
|
|
2420
|
+
/** Railway's Postgres image, which its own Postgres template runs. */
|
|
2421
|
+
const RAILWAY_POSTGRES_IMAGE = "ghcr.io/railwayapp-templates/postgres-ssl";
|
|
2422
|
+
/**
|
|
2423
|
+
* The default command of images whose bind-mounted file the CLI writes before
|
|
2424
|
+
* it starts them; Railway's start command replaces the image's command.
|
|
2425
|
+
*/
|
|
2426
|
+
const IMAGE_COMMANDS = [{
|
|
2427
|
+
pattern: /^(docker\.io\/)?(library\/)?caddy(:|@|$)/,
|
|
2428
|
+
command: [
|
|
2429
|
+
"caddy",
|
|
2430
|
+
"run",
|
|
2431
|
+
"--config",
|
|
2432
|
+
"/etc/caddy/Caddyfile",
|
|
2433
|
+
"--adapter",
|
|
2434
|
+
"caddyfile"
|
|
2435
|
+
]
|
|
2436
|
+
}];
|
|
2437
|
+
function escapeRegExp(value) {
|
|
2438
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2439
|
+
}
|
|
2440
|
+
/** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
|
|
2441
|
+
function hostPattern(name) {
|
|
2442
|
+
return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
|
|
2443
|
+
}
|
|
2444
|
+
/** Replaces Compose host names with the private host names of the Railway services. */
|
|
2445
|
+
function railwayHosts(text, hosts) {
|
|
2446
|
+
let result = text;
|
|
2447
|
+
for (const [name, service] of Object.entries(hosts)) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}${service}.railway.internal:${port}`);
|
|
2448
|
+
return result;
|
|
2449
|
+
}
|
|
2450
|
+
/** A Railway service name: lower case letters, digits and dashes. */
|
|
2451
|
+
function railwayName(service) {
|
|
2452
|
+
return service.replace(/-prod$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "service";
|
|
2453
|
+
}
|
|
2454
|
+
/** Quotes a word of a start command only when it needs it. */
|
|
2455
|
+
function commandWord(value) {
|
|
2456
|
+
if (/^[\w@%+=:,./-]+$/.test(value)) return value;
|
|
2457
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2458
|
+
}
|
|
2459
|
+
/** The variable that holds a bind-mounted file, such as DSTACK_FILE_CADDYFILE. */
|
|
2460
|
+
function fileVariable(target) {
|
|
2461
|
+
return `DSTACK_FILE_${(posix.basename(target) || "file").toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
2462
|
+
}
|
|
2463
|
+
/** The last stage of a Dockerfile, which Railway builds. */
|
|
2464
|
+
function lastStage(dockerfile) {
|
|
2465
|
+
let stage;
|
|
2466
|
+
for (const match of dockerfile.matchAll(/^\s*FROM\s+(?:--\S+\s+)*\S+(?:\s+AS\s+(\S+))?\s*$/gim)) stage = match[1]?.toLowerCase() ?? "";
|
|
2467
|
+
return stage;
|
|
2468
|
+
}
|
|
2469
|
+
function healthPath(definition) {
|
|
2470
|
+
if (definition.healthcheck?.disable) return void 0;
|
|
2471
|
+
const match = (definition.healthcheck?.test ?? []).join(" ").match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::(\d+))?(\/[^\s'"]*)?/);
|
|
2472
|
+
if (!match) return void 0;
|
|
2473
|
+
return {
|
|
2474
|
+
port: Number(match[1] ?? 80),
|
|
2475
|
+
path: match[2] ?? "/"
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
/** The names other services reach a service by: its name and its network aliases. */
|
|
2479
|
+
function hostNames(service, definition) {
|
|
2480
|
+
const names = /* @__PURE__ */ new Set([service]);
|
|
2481
|
+
for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
|
|
2482
|
+
return [...names];
|
|
2483
|
+
}
|
|
2484
|
+
/** Plans the Railway services for a resolved Compose project. */
|
|
2485
|
+
function planRailway(project, options) {
|
|
2486
|
+
const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
|
|
2487
|
+
if (!entries.length) throw new Error("compose.yaml has no services for production.");
|
|
2488
|
+
const names = /* @__PURE__ */ new Map();
|
|
2489
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2490
|
+
for (const [service] of entries) {
|
|
2491
|
+
let name = railwayName(service);
|
|
2492
|
+
for (let index = 2; taken.has(name); index++) name = `${railwayName(service).slice(0, 29)}-${index}`;
|
|
2493
|
+
taken.add(name);
|
|
2494
|
+
names.set(service, name);
|
|
2495
|
+
}
|
|
2496
|
+
const hosts = {};
|
|
2497
|
+
for (const [service, definition] of entries) for (const host of hostNames(service, definition)) hosts[host] = names.get(service);
|
|
2498
|
+
const texts = [];
|
|
2499
|
+
for (const [, definition] of entries) for (const value of Object.values(definition.environment ?? {})) if (value) texts.push(value);
|
|
2500
|
+
const files = /* @__PURE__ */ new Map();
|
|
2501
|
+
for (const [service, definition] of entries) {
|
|
2502
|
+
const binds = (definition.volumes ?? []).filter((mount) => mount.type === "bind");
|
|
2503
|
+
if (!binds.length) continue;
|
|
2504
|
+
files.set(service, binds.map((mount) => {
|
|
2505
|
+
let content;
|
|
2506
|
+
try {
|
|
2507
|
+
content = options.readFile(mount.source ?? "");
|
|
2508
|
+
} catch {
|
|
2509
|
+
throw new Error(`${service} bind-mounts ${mount.source}, which is not a file; Railway has no bind mounts, so only files can be passed to the service.`);
|
|
2510
|
+
}
|
|
2511
|
+
texts.push(content);
|
|
2512
|
+
return {
|
|
2513
|
+
target: mount.target,
|
|
2514
|
+
content
|
|
2515
|
+
};
|
|
2516
|
+
}));
|
|
2517
|
+
}
|
|
2518
|
+
const published = entries.filter(([, definition]) => (definition.ports ?? []).length);
|
|
2519
|
+
if (published.length !== 1) throw new Error(published.length ? `${published.map(([service]) => service).join(" and ")} publish ports; Railway gives the site one domain, so only the proxy may.` : "No service publishes a port, so there is nothing for Railway's domain to send requests to.");
|
|
2520
|
+
const publicService = published[0]?.[0];
|
|
2521
|
+
const s3 = entries.some(([, definition]) => Boolean(definition.environment?.S3_BUCKET));
|
|
2522
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
2523
|
+
const services = [];
|
|
2524
|
+
for (const [service, definition] of entries) {
|
|
2525
|
+
const name = names.get(service);
|
|
2526
|
+
for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
|
|
2527
|
+
if (definition.deploy?.resources) ignored.add("deploy.resources");
|
|
2528
|
+
const found = /* @__PURE__ */ new Set();
|
|
2529
|
+
for (const host of hostNames(service, definition)) for (const text of texts) for (const match of text.matchAll(hostPattern(host))) found.add(Number(match[2]));
|
|
2530
|
+
const health = healthPath(definition);
|
|
2531
|
+
if (health) found.add(health.port);
|
|
2532
|
+
const ports = (definition.ports ?? []).map((port) => port.target);
|
|
2533
|
+
let port;
|
|
2534
|
+
if (service === publicService) port = ports.includes(80) ? 80 : ports[0];
|
|
2535
|
+
else if (found.size > 1) throw new Error(`${service} is reached on ports ${[...found].join(" and ")}; Railway routes one port per service.`);
|
|
2536
|
+
else port = [...found][0];
|
|
2537
|
+
const env = {};
|
|
2538
|
+
for (const [key, value] of Object.entries(definition.environment ?? {})) {
|
|
2539
|
+
if (value === null || value === void 0 || key === "COMPOSE_PROFILES" || key === "ENV_FILE") continue;
|
|
2540
|
+
env[key] = railwayHosts(value, hosts);
|
|
2541
|
+
}
|
|
2542
|
+
if (port !== void 0 && !env.PORT) env.PORT = String(port);
|
|
2543
|
+
let source;
|
|
2544
|
+
let watchPatterns;
|
|
2545
|
+
if (definition.build) {
|
|
2546
|
+
const context = relative(options.directory, definition.build.context).split(sep).join("/");
|
|
2547
|
+
if (context.startsWith("..")) throw new Error(`${service} builds ${definition.build.context}, outside the project.`);
|
|
2548
|
+
const dockerfile = definition.build.dockerfile ?? "Dockerfile";
|
|
2549
|
+
const target = definition.build.target;
|
|
2550
|
+
if (target) {
|
|
2551
|
+
let text;
|
|
2552
|
+
try {
|
|
2553
|
+
text = options.readFile(posix.join(definition.build.context, dockerfile));
|
|
2554
|
+
} catch {}
|
|
2555
|
+
const last = text === void 0 ? void 0 : lastStage(text);
|
|
2556
|
+
if (last !== void 0 && last !== target.toLowerCase()) throw new Error(`${service} builds the ${target} stage of ${context || "."}/${dockerfile}, and Railway builds the last stage only. Make ${target} the last stage.`);
|
|
2557
|
+
}
|
|
2558
|
+
for (const [key, value] of Object.entries(definition.build.args ?? {})) if (env[key] === void 0 && value != null) env[key] = String(value);
|
|
2559
|
+
const rootDirectory = `/${context}`.replace(/\/$/, "") || "/";
|
|
2560
|
+
source = {
|
|
2561
|
+
kind: "repo",
|
|
2562
|
+
rootDirectory,
|
|
2563
|
+
...dockerfile !== "Dockerfile" ? { dockerfile } : {}
|
|
2564
|
+
};
|
|
2565
|
+
watchPatterns = [rootDirectory === "/" ? "/**" : `${rootDirectory}/**`];
|
|
2566
|
+
} else if (definition.image) {
|
|
2567
|
+
let image = definition.image;
|
|
2568
|
+
const postgres = image.match(/^(?:docker\.io\/)?(?:library\/)?postgres:(\d+)(?:[.-][\w.-]*)?$/);
|
|
2569
|
+
if (postgres) image = `${RAILWAY_POSTGRES_IMAGE}:${postgres[1]}`;
|
|
2570
|
+
source = {
|
|
2571
|
+
kind: "image",
|
|
2572
|
+
image
|
|
2573
|
+
};
|
|
2574
|
+
} else throw new Error(`${service} has neither an image nor a build.`);
|
|
2575
|
+
let startCommand = definition.entrypoint?.length || definition.command?.length ? [...definition.entrypoint ?? [], ...definition.command ?? []] : void 0;
|
|
2576
|
+
const bound = files.get(service);
|
|
2577
|
+
if (bound) {
|
|
2578
|
+
if (definition.build) throw new Error(`${service} bind-mounts a file; Railway has no bind mounts. Copy the file into the image in its Dockerfile.`);
|
|
2579
|
+
const command = startCommand ?? IMAGE_COMMANDS.find((item) => item.pattern.test(definition.image ?? ""))?.command;
|
|
2580
|
+
if (!command) throw new Error(`${service} bind-mounts ${bound[0]?.target}; Railway has no bind mounts. Give the service a command in compose.yaml, or build an image with the file.`);
|
|
2581
|
+
startCommand = [
|
|
2582
|
+
"/bin/sh",
|
|
2583
|
+
"-c",
|
|
2584
|
+
[...bound.map((file) => {
|
|
2585
|
+
const variable = fileVariable(file.target);
|
|
2586
|
+
env[variable] = railwayHosts(file.content, hosts);
|
|
2587
|
+
return `printf %s "$${variable}" > ${commandWord(file.target)}`;
|
|
2588
|
+
}), `exec ${command.map(commandWord).join(" ")}`].join(" && ")
|
|
2589
|
+
];
|
|
2590
|
+
}
|
|
2591
|
+
let volume;
|
|
2592
|
+
for (const mount of definition.volumes ?? []) {
|
|
2593
|
+
if (mount.type === "tmpfs" || mount.type === "bind") continue;
|
|
2594
|
+
if (mount.type !== "volume" || !mount.source) throw new Error(`${service} mounts a ${mount.type} volume, which Railway does not have.`);
|
|
2595
|
+
if (mount.source === "media" && !s3) throw new Error("Uploads must be in S3 on Railway: a Railway volume belongs to one service, and the backend, the worker and Caddy all need the uploads. Set S3_BUCKET and the other S3_ variables in .env.production (see the docs on media).");
|
|
2596
|
+
if (RAILWAY_DROPPED_VOLUMES.has(mount.source)) continue;
|
|
2597
|
+
if (volume) throw new Error(`${service} mounts two volumes, ${volume} and ${mount.target}; Railway gives a service one volume.`);
|
|
2598
|
+
volume = mount.target;
|
|
2599
|
+
if (mount.target === "/var/lib/postgresql/data" && !env.PGDATA) env.PGDATA = `${mount.target}/pgdata`;
|
|
2600
|
+
}
|
|
2601
|
+
const replicas = definition.deploy?.replicas ?? 1;
|
|
2602
|
+
services.push({
|
|
2603
|
+
service,
|
|
2604
|
+
name,
|
|
2605
|
+
source,
|
|
2606
|
+
env,
|
|
2607
|
+
...startCommand ? { startCommand: startCommand.map(commandWord).join(" ") } : {},
|
|
2608
|
+
...port !== void 0 ? { port } : {},
|
|
2609
|
+
...health && health.port === port ? { healthcheckPath: health.path } : {},
|
|
2610
|
+
replicas: volume ? 1 : Math.max(1, replicas),
|
|
2611
|
+
...volume ? { volume } : {},
|
|
2612
|
+
...watchPatterns ? { watchPatterns } : {},
|
|
2613
|
+
public: service === publicService
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
return {
|
|
2617
|
+
name: project.name,
|
|
2618
|
+
services,
|
|
2619
|
+
ignored: [...ignored].sort()
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
// src/deploy/railway.ts
|
|
2623
|
+
/**
|
|
2624
|
+
* Railway (https://railway.com), through its GraphQL API with an account or
|
|
2625
|
+
* workspace token. Railway has no Compose: the CLI reads the resolved
|
|
2626
|
+
* compose.yaml and creates one Railway service per production service in a
|
|
2627
|
+
* Railway project. Railway builds the backend and the frontend from their
|
|
2628
|
+
* directories in the GitHub repository (through the Railway GitHub App) and
|
|
2629
|
+
* deploys again on every push; Postgres runs Railway's own Postgres image
|
|
2630
|
+
* with a volume, and Caddy gets the Caddyfile through a variable. Railway's
|
|
2631
|
+
* edge holds the certificate of the site's domain and forwards to Caddy on
|
|
2632
|
+
* port 80, which routes to the other services on the private network.
|
|
2633
|
+
*
|
|
2634
|
+
* Link ids: project = the Railway project, environment = its environment,
|
|
2635
|
+
* application = the service with the domain (the proxy).
|
|
2636
|
+
*/
|
|
2637
|
+
const RAILWAY_API = "https://backboard.railway.com/graphql/v2";
|
|
2638
|
+
/** Variables that put Caddy behind Railway's edge. */
|
|
2639
|
+
const RAILWAY_PLATFORM_ENV = {
|
|
2640
|
+
CADDY_ADDRESS: ":80",
|
|
2641
|
+
TRUSTED_PROXIES: "0.0.0.0/0 ::/0",
|
|
2642
|
+
NUM_PROXIES: "2"
|
|
2643
|
+
};
|
|
2644
|
+
var RailwayError = class extends Error {
|
|
2645
|
+
status;
|
|
2646
|
+
constructor(message, status) {
|
|
2647
|
+
super(message);
|
|
2648
|
+
this.status = status;
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
var RailwayApi = class {
|
|
2652
|
+
token;
|
|
2653
|
+
fetchImpl;
|
|
2654
|
+
url;
|
|
2655
|
+
constructor(token, fetchImpl = fetch, url = RAILWAY_API) {
|
|
2656
|
+
this.token = token;
|
|
2657
|
+
this.fetchImpl = fetchImpl;
|
|
2658
|
+
this.url = url;
|
|
2659
|
+
}
|
|
2660
|
+
async query(query, variables = {}) {
|
|
2661
|
+
let response;
|
|
2662
|
+
try {
|
|
2663
|
+
response = await this.fetchImpl(this.url, {
|
|
2664
|
+
method: "POST",
|
|
2665
|
+
headers: {
|
|
2666
|
+
Authorization: `Bearer ${this.token}`,
|
|
2667
|
+
"Content-Type": "application/json",
|
|
2668
|
+
Accept: "application/json"
|
|
2669
|
+
},
|
|
2670
|
+
body: JSON.stringify({
|
|
2671
|
+
query,
|
|
2672
|
+
variables
|
|
2673
|
+
}),
|
|
2674
|
+
signal: AbortSignal.timeout(3e4)
|
|
2675
|
+
});
|
|
2676
|
+
} catch (error) {
|
|
2677
|
+
throw new RailwayError(`Could not reach Railway: ${error.message}`, 0);
|
|
2678
|
+
}
|
|
2679
|
+
const raw = await response.text();
|
|
2680
|
+
let body;
|
|
2681
|
+
try {
|
|
2682
|
+
body = raw ? JSON.parse(raw) : void 0;
|
|
2683
|
+
} catch {}
|
|
2684
|
+
const message = body?.errors?.map((item) => item.message).filter(Boolean).join("; ");
|
|
2685
|
+
if (response.status === 401 || response.status === 403 || /not authori[sz]ed/i.test(message ?? "")) throw new RailwayError("Railway refused the token. Create an account or workspace token at https://railway.com/account/tokens and run dstack deploy login.", response.status);
|
|
2686
|
+
if (!response.ok || message || !body?.data) throw new RailwayError(`Railway answered ${response.status}: ${message || raw.slice(0, 200) || "no details"}`, response.status);
|
|
2687
|
+
return body.data;
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
/** A deployment's status in Railway's words. */
|
|
2691
|
+
function railwayDeploymentStatus(value) {
|
|
2692
|
+
switch (value) {
|
|
2693
|
+
case "SUCCESS":
|
|
2694
|
+
case "SLEEPING":
|
|
2695
|
+
case "REMOVED":
|
|
2696
|
+
case "REMOVING": return "finished";
|
|
2697
|
+
case "BUILDING":
|
|
2698
|
+
case "DEPLOYING": return "building";
|
|
2699
|
+
case "QUEUED":
|
|
2700
|
+
case "WAITING":
|
|
2701
|
+
case "INITIALIZING":
|
|
2702
|
+
case "NEEDS_APPROVAL": return "queued";
|
|
2703
|
+
case "SKIPPED": return "cancelled";
|
|
2704
|
+
default: return "failed";
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
/** The state of one deploy across services: failed when one failed, done when all are. */
|
|
2708
|
+
function groupStatus(statuses) {
|
|
2709
|
+
if (statuses.includes("failed")) return "failed";
|
|
2710
|
+
if (statuses.includes("building")) return "building";
|
|
2711
|
+
if (statuses.includes("queued")) return "queued";
|
|
2712
|
+
if (statuses.length && statuses.every((status) => status === "cancelled")) return "cancelled";
|
|
2713
|
+
return "finished";
|
|
2714
|
+
}
|
|
2715
|
+
const DEPLOYMENT_FIELDS = "id status createdAt updatedAt meta";
|
|
2716
|
+
const PROJECT_QUERY = `query Project($id: String!) {
|
|
2717
|
+
project(id: $id) {
|
|
2718
|
+
id name
|
|
2719
|
+
services { edges { node { id name } } }
|
|
2720
|
+
environments { edges { node { id name serviceInstances { edges { node {
|
|
2721
|
+
serviceId serviceName
|
|
2722
|
+
source { repo image }
|
|
2723
|
+
latestDeployment { ${DEPLOYMENT_FIELDS} }
|
|
2724
|
+
domains { customDomains { domain } serviceDomains { domain } }
|
|
2725
|
+
} } } } } }
|
|
2726
|
+
}
|
|
2727
|
+
}`;
|
|
2728
|
+
/** owner/name of a GitHub repository URL; Railway builds from GitHub only. */
|
|
2729
|
+
function githubRepository(url) {
|
|
2730
|
+
const match = url.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/);
|
|
2731
|
+
if (!match?.[1]) throw new Error(`Railway builds from GitHub repositories only, and ${url} is not one. Push the project to GitHub first.`);
|
|
2732
|
+
return match[1];
|
|
2733
|
+
}
|
|
2734
|
+
function commitOf(deployment) {
|
|
2735
|
+
const value = deployment.meta?.commitHash;
|
|
2736
|
+
return typeof value === "string" && value ? value : void 0;
|
|
2737
|
+
}
|
|
2738
|
+
function metaText(deployment, key) {
|
|
2739
|
+
const value = deployment.meta?.[key];
|
|
2740
|
+
return typeof value === "string" && value ? value : void 0;
|
|
2741
|
+
}
|
|
2742
|
+
function railwayDashboardUrl(link) {
|
|
2743
|
+
return `https://railway.com/project/${encodeURIComponent(link.project)}?environmentId=${encodeURIComponent(link.environment)}`;
|
|
2744
|
+
}
|
|
2745
|
+
function find(items, wanted) {
|
|
2746
|
+
return items.find((item) => item.id === wanted) ?? items.find((item) => item.name === wanted);
|
|
2747
|
+
}
|
|
2748
|
+
var RailwayClient = class {
|
|
2749
|
+
api;
|
|
2750
|
+
run;
|
|
2751
|
+
readFile;
|
|
2752
|
+
constructor(token, fetchImpl, options = {}) {
|
|
2753
|
+
this.api = new RailwayApi(token, fetchImpl);
|
|
2754
|
+
this.run = options.run ?? systemRunner;
|
|
2755
|
+
this.readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
2756
|
+
}
|
|
2757
|
+
/** How long `deploy` and `rollback` wait between looks for a new deployment; tests set it to 0. */
|
|
2758
|
+
pollMs = 1e3;
|
|
2759
|
+
async verify() {
|
|
2760
|
+
const data = await this.api.query("query { me { name email } }");
|
|
2761
|
+
const who = data.me?.email ?? data.me?.name;
|
|
2762
|
+
return who ? `API (${who})` : "API";
|
|
2763
|
+
}
|
|
2764
|
+
async plan(directory, env) {
|
|
2765
|
+
return planRailway(await resolveCompose(this.run, directory, env), {
|
|
2766
|
+
directory,
|
|
2767
|
+
readFile: this.readFile
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
async project(id) {
|
|
2771
|
+
return (await this.api.query(PROJECT_QUERY, { id })).project;
|
|
2772
|
+
}
|
|
2773
|
+
/** The service instances of the link's environment. */
|
|
2774
|
+
async instances(link) {
|
|
2775
|
+
const environment = (await this.project(link.project)).environments.edges.find((edge) => edge.node.id === link.environment);
|
|
2776
|
+
if (!environment) throw new Error(`The Railway project has no environment ${link.environment}.`);
|
|
2777
|
+
return environment.node.serviceInstances.edges.map((edge) => edge.node);
|
|
2778
|
+
}
|
|
2779
|
+
/** The services Railway builds from the repository; the first one is the one deploys are listed by. */
|
|
2780
|
+
async built(link) {
|
|
2781
|
+
const items = (await this.instances(link)).filter((item) => item.source?.repo);
|
|
2782
|
+
if (!items.length) throw new Error("No service of the Railway project builds from the repository.");
|
|
2783
|
+
return items.sort((a, b) => a.serviceName === "backend" ? -1 : b.serviceName === "backend" ? 1 : a.serviceName.localeCompare(b.serviceName));
|
|
2784
|
+
}
|
|
2785
|
+
async workspace(wanted, context) {
|
|
2786
|
+
const workspaces = (await this.api.query("query { me { workspaces { id name } } }")).me.workspaces ?? [];
|
|
2787
|
+
if (wanted) {
|
|
2788
|
+
const workspace = find(workspaces, wanted);
|
|
2789
|
+
if (!workspace) throw new Error(`Railway has no workspace ${wanted}.`);
|
|
2790
|
+
return workspace.id;
|
|
2791
|
+
}
|
|
2792
|
+
if (workspaces.length === 1) return workspaces[0]?.id;
|
|
2793
|
+
if (!workspaces.length) throw new Error("This Railway token sees no workspace; pass --workspace.");
|
|
2794
|
+
return context.ask.select("Railway workspace", workspaces.map((item) => ({
|
|
2795
|
+
value: item.id,
|
|
2796
|
+
label: item.name
|
|
2797
|
+
})), workspaces[0]?.id);
|
|
2798
|
+
}
|
|
2799
|
+
async projects(workspaceId) {
|
|
2800
|
+
return (((await this.api.query("query { me { workspaces { id projects(first: 500) { edges { node { id name } } } } } }")).me.workspaces ?? []).find((item) => item.id === workspaceId)?.projects?.edges ?? []).map((edge) => edge.node);
|
|
2801
|
+
}
|
|
2802
|
+
async setup(context) {
|
|
2803
|
+
const { ask, options, directory } = context;
|
|
2804
|
+
if (!context.repository) throw new Error("Railway builds from a GitHub repository; pass --repository.");
|
|
2805
|
+
const repo = githubRepository(context.repository);
|
|
2806
|
+
const plan = await this.plan(directory, context.env);
|
|
2807
|
+
if (plan.ignored.length) ask.step(`Railway does not apply these settings of compose.yaml: ${plan.ignored.join(", ")}`);
|
|
2808
|
+
const workspaceId = await this.workspace(options.workspace, context);
|
|
2809
|
+
let projectId;
|
|
2810
|
+
if (options.project) {
|
|
2811
|
+
const project = find(await this.projects(workspaceId), options.project);
|
|
2812
|
+
if (!project) throw new Error(`The Railway workspace has no project ${options.project}.`);
|
|
2813
|
+
projectId = project.id;
|
|
2814
|
+
} else {
|
|
2815
|
+
projectId = (await this.api.query(`mutation ($input: ProjectCreateInput!) { projectCreate(input: $input) { id } }`, { input: {
|
|
2816
|
+
name: context.name,
|
|
2817
|
+
description: "DjangoStack",
|
|
2818
|
+
workspaceId
|
|
2819
|
+
} })).projectCreate.id;
|
|
2820
|
+
ask.step(`Created the Railway project ${context.name}`);
|
|
2821
|
+
}
|
|
2822
|
+
const environmentName = options.environment ?? "production";
|
|
2823
|
+
let project = await this.project(projectId);
|
|
2824
|
+
let environmentId = project.environments.edges.find((edge) => edge.node.id === environmentName || edge.node.name === environmentName)?.node.id;
|
|
2825
|
+
if (!environmentId) {
|
|
2826
|
+
environmentId = (await this.api.query(`mutation ($input: EnvironmentCreateInput!) { environmentCreate(input: $input) { id } }`, { input: {
|
|
2827
|
+
projectId,
|
|
2828
|
+
name: environmentName
|
|
2829
|
+
} })).environmentCreate.id;
|
|
2830
|
+
ask.step(`Created the environment ${environmentName}`);
|
|
2831
|
+
project = await this.project(projectId);
|
|
2832
|
+
}
|
|
2833
|
+
const existing = project.services.edges.map((edge) => edge.node);
|
|
2834
|
+
for (const name of plan.services.map((item) => item.name)) if (existing.some((item) => item.name === name)) throw new Error(`The Railway project already has a service ${name}. Set up a new project, or delete the service in Railway first.`);
|
|
2835
|
+
let publicId;
|
|
2836
|
+
for (const service of plan.services) {
|
|
2837
|
+
const serviceId = (await this.api.query(`mutation ($input: ServiceCreateInput!) { serviceCreate(input: $input) { id } }`, { input: {
|
|
2838
|
+
projectId,
|
|
2839
|
+
environmentId,
|
|
2840
|
+
name: service.name
|
|
2841
|
+
} })).serviceCreate.id;
|
|
2842
|
+
if (service.public) publicId = serviceId;
|
|
2843
|
+
const env = { ...service.env };
|
|
2844
|
+
if (service.source.kind === "repo" && service.source.dockerfile) env.RAILWAY_DOCKERFILE_PATH = service.source.dockerfile;
|
|
2845
|
+
await this.upsertVariables(projectId, environmentId, serviceId, env, true);
|
|
2846
|
+
await this.api.query(`mutation ($serviceId: String!, $environmentId: String, $input: ServiceInstanceUpdateInput!) {
|
|
2847
|
+
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
|
|
2848
|
+
}`, {
|
|
2849
|
+
serviceId,
|
|
2850
|
+
environmentId,
|
|
2851
|
+
input: {
|
|
2852
|
+
...service.source.kind === "repo" ? { rootDirectory: service.source.rootDirectory } : {},
|
|
2853
|
+
...service.startCommand ? { startCommand: service.startCommand } : {},
|
|
2854
|
+
...service.healthcheckPath ? { healthcheckPath: service.healthcheckPath } : {},
|
|
2855
|
+
...service.watchPatterns ? { watchPatterns: service.watchPatterns } : {},
|
|
2856
|
+
numReplicas: service.replicas
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
if (service.volume) await this.api.query(`mutation ($input: VolumeCreateInput!) { volumeCreate(input: $input) { id } }`, { input: {
|
|
2860
|
+
projectId,
|
|
2861
|
+
environmentId,
|
|
2862
|
+
serviceId,
|
|
2863
|
+
mountPath: service.volume
|
|
2864
|
+
} });
|
|
2865
|
+
await this.api.query(`mutation ($id: String!, $input: ServiceConnectInput!) { serviceConnect(id: $id, input: $input) { id } }`, {
|
|
2866
|
+
id: serviceId,
|
|
2867
|
+
input: service.source.kind === "repo" ? {
|
|
2868
|
+
repo,
|
|
2869
|
+
branch: context.branch
|
|
2870
|
+
} : { image: service.source.image }
|
|
2871
|
+
});
|
|
2872
|
+
ask.step(`Created the service ${service.name} ${service.source.kind === "repo" ? `from ${repo}${service.source.rootDirectory} (${context.branch})` : `from ${service.source.image}`}${service.volume ? ` with a volume at ${service.volume}` : ""}`);
|
|
2873
|
+
}
|
|
2874
|
+
if (!publicId) throw new Error("The plan has no public service.");
|
|
2875
|
+
const domain = await this.api.query(`mutation ($input: CustomDomainCreateInput!) {
|
|
2876
|
+
customDomainCreate(input: $input) { id status { dnsRecords { hostlabel fqdn recordType requiredValue } } }
|
|
2877
|
+
}`, { input: {
|
|
2878
|
+
projectId,
|
|
2879
|
+
environmentId,
|
|
2880
|
+
serviceId: publicId,
|
|
2881
|
+
domain: context.domain,
|
|
2882
|
+
targetPort: plan.services.find((item) => item.public)?.port ?? 80
|
|
2883
|
+
} });
|
|
2884
|
+
ask.step(`Gave the ${plan.services.find((item) => item.public)?.name} service https://${context.domain}`);
|
|
2885
|
+
const records = domain.customDomainCreate.status?.dnsRecords ?? [];
|
|
2886
|
+
if (records.length) for (const record of records) ask.step(`Add this DNS record: ${record.fqdn ?? record.hostlabel ?? context.domain} ${record.recordType?.replace(/^DNS_RECORD_TYPE_/, "") ?? "CNAME"} ${record.requiredValue ?? ""}`.trim());
|
|
2887
|
+
else ask.step(`Add the DNS record Railway shows for ${context.domain} under the proxy service's settings.`);
|
|
2888
|
+
return {
|
|
2889
|
+
project: projectId,
|
|
2890
|
+
environment: environmentId,
|
|
2891
|
+
application: publicId
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
async upsertVariables(projectId, environmentId, serviceId, variables, skipDeploys) {
|
|
2895
|
+
await this.api.query(`mutation ($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`, { input: {
|
|
2896
|
+
projectId,
|
|
2897
|
+
environmentId,
|
|
2898
|
+
serviceId,
|
|
2899
|
+
variables,
|
|
2900
|
+
skipDeploys
|
|
2901
|
+
} });
|
|
2902
|
+
}
|
|
2903
|
+
async pushEnv(link, env) {
|
|
2904
|
+
const directory = link.directory ?? process.cwd();
|
|
2905
|
+
const plan = await this.plan(directory, env);
|
|
2906
|
+
const instances = await this.instances(link);
|
|
2907
|
+
for (const service of plan.services) {
|
|
2908
|
+
const instance = instances.find((item) => item.serviceName === service.name);
|
|
2909
|
+
if (!instance) throw new Error(missingService(service.name));
|
|
2910
|
+
await this.upsertVariables(link.project, link.environment, instance.serviceId, service.env, false);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
async deploy(link) {
|
|
2914
|
+
const services = await this.built(link);
|
|
2915
|
+
let primary;
|
|
2916
|
+
for (const service of services) {
|
|
2917
|
+
const data = await this.api.query(`mutation ($serviceId: String!, $environmentId: String!) {
|
|
2918
|
+
serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId)
|
|
2919
|
+
}`, {
|
|
2920
|
+
serviceId: service.serviceId,
|
|
2921
|
+
environmentId: link.environment
|
|
2922
|
+
});
|
|
2923
|
+
primary ??= typeof data.serviceInstanceDeployV2 === "string" ? data.serviceInstanceDeployV2 : void 0;
|
|
2924
|
+
}
|
|
2925
|
+
if (primary) return primary;
|
|
2926
|
+
return this.newest(link, services[0]?.serviceId, services[0]?.latestDeployment?.id);
|
|
2927
|
+
}
|
|
2928
|
+
/** Waits for a deployment newer than `before` of a service; returns its id. */
|
|
2929
|
+
async newest(link, serviceId, before) {
|
|
2930
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
2931
|
+
const [latest] = await this.raw(link, serviceId, 1);
|
|
2932
|
+
if (latest && latest.id !== before) return latest.id;
|
|
2933
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollMs));
|
|
2934
|
+
}
|
|
2935
|
+
throw new Error(`Railway took the deploy but shows no deployment for it yet. See ${railwayDashboardUrl(link)}`);
|
|
2936
|
+
}
|
|
2937
|
+
/** A service's deployments, newest first. */
|
|
2938
|
+
async raw(link, serviceId, limit) {
|
|
2939
|
+
return (await this.api.query(`query ($input: DeploymentListInput!, $first: Int) {
|
|
2940
|
+
deployments(input: $input, first: $first) { edges { node { ${DEPLOYMENT_FIELDS} } } }
|
|
2941
|
+
}`, {
|
|
2942
|
+
input: {
|
|
2943
|
+
projectId: link.project,
|
|
2944
|
+
environmentId: link.environment,
|
|
2945
|
+
serviceId
|
|
2946
|
+
},
|
|
2947
|
+
first: limit
|
|
2948
|
+
})).deployments.edges.map((edge) => edge.node).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
|
|
2949
|
+
}
|
|
2950
|
+
/** The deployments of the other built services that belong to a deployment of the first one. */
|
|
2951
|
+
siblings(primary, others) {
|
|
2952
|
+
const commit = commitOf(primary);
|
|
2953
|
+
const time = new Date(primary.createdAt ?? 0).getTime();
|
|
2954
|
+
const result = [];
|
|
2955
|
+
for (const { service, deployments } of others) {
|
|
2956
|
+
const match = commit ? deployments.find((item) => commitOf(item) === commit) : deployments.filter((item) => Math.abs(new Date(item.createdAt ?? 0).getTime() - time) < 6e5).sort((a, b) => Math.abs(new Date(a.createdAt ?? 0).getTime() - time) - Math.abs(new Date(b.createdAt ?? 0).getTime() - time))[0];
|
|
2957
|
+
if (match) result.push({
|
|
2958
|
+
service,
|
|
2959
|
+
deployment: match
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
return result;
|
|
2963
|
+
}
|
|
2964
|
+
async group(link, limit) {
|
|
2965
|
+
const [first, ...rest] = await this.built(link);
|
|
2966
|
+
if (!first) throw new Error("No service of the Railway project builds from the repository.");
|
|
2967
|
+
return {
|
|
2968
|
+
first,
|
|
2969
|
+
primary: await this.raw(link, first.serviceId, limit),
|
|
2970
|
+
others: await Promise.all(rest.map(async (service) => ({
|
|
2971
|
+
service,
|
|
2972
|
+
deployments: await this.raw(link, service.serviceId, Math.max(limit, 10) * 2)
|
|
2973
|
+
})))
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
toDeployment(link, primary, siblings) {
|
|
2977
|
+
const statuses = [primary, ...siblings.map((item) => item.deployment)].map((item) => railwayDeploymentStatus(item.status));
|
|
2978
|
+
const commit = commitOf(primary);
|
|
2979
|
+
return {
|
|
2980
|
+
id: primary.id,
|
|
2981
|
+
status: groupStatus(statuses),
|
|
2982
|
+
commit: commit?.slice(0, 7),
|
|
2983
|
+
message: metaText(primary, "commitMessage")?.split("\n")[0],
|
|
2984
|
+
createdAt: primary.createdAt,
|
|
2985
|
+
updatedAt: primary.updatedAt,
|
|
2986
|
+
url: railwayDashboardUrl(link),
|
|
2987
|
+
log: []
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
async deployment(link, id) {
|
|
2991
|
+
const { first, primary, others } = await this.group(link, 20);
|
|
2992
|
+
let item = primary.find((deployment) => deployment.id === id);
|
|
2993
|
+
if (!item) item = (await this.api.query(`query ($id: String!) { deployment(id: $id) { ${DEPLOYMENT_FIELDS} } }`, { id }).catch(() => void 0))?.deployment;
|
|
2994
|
+
if (!item) throw new Error(`Railway has no deployment ${id} for this project.`);
|
|
2995
|
+
const siblings = this.siblings(item, others);
|
|
2996
|
+
const deployment = this.toDeployment(link, item, siblings);
|
|
2997
|
+
const log = [];
|
|
2998
|
+
for (const [service, part] of [[first, item], ...siblings.map((sibling) => [sibling.service, sibling.deployment])]) {
|
|
2999
|
+
const lines = await this.logs(part).catch(() => []);
|
|
3000
|
+
for (const line of lines) log.push({
|
|
3001
|
+
...line,
|
|
3002
|
+
text: `${service.serviceName} | ${line.text}`
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
return {
|
|
3006
|
+
...deployment,
|
|
3007
|
+
log
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
/** The build log of a deployment, and its deploy log once it runs. */
|
|
3011
|
+
async logs(deployment) {
|
|
3012
|
+
const toLines = (items) => items.map((item) => ({
|
|
3013
|
+
text: item.message ?? "",
|
|
3014
|
+
stderr: /error/i.test(item.severity ?? "")
|
|
3015
|
+
}));
|
|
3016
|
+
const lines = toLines((await this.api.query(`query ($deploymentId: String!, $limit: Int) { buildLogs(deploymentId: $deploymentId, limit: $limit) { message severity } }`, {
|
|
3017
|
+
deploymentId: deployment.id,
|
|
3018
|
+
limit: 500
|
|
3019
|
+
})).buildLogs ?? []);
|
|
3020
|
+
if ([
|
|
3021
|
+
"DEPLOYING",
|
|
3022
|
+
"SUCCESS",
|
|
3023
|
+
"CRASHED",
|
|
3024
|
+
"FAILED"
|
|
3025
|
+
].includes(deployment.status ?? "")) {
|
|
3026
|
+
const run = await this.api.query(`query ($deploymentId: String!, $limit: Int) { deploymentLogs(deploymentId: $deploymentId, limit: $limit) { message severity } }`, {
|
|
3027
|
+
deploymentId: deployment.id,
|
|
3028
|
+
limit: 200
|
|
3029
|
+
}).catch(() => ({ deploymentLogs: [] }));
|
|
3030
|
+
lines.push(...toLines(run.deploymentLogs ?? []));
|
|
3031
|
+
}
|
|
3032
|
+
return lines;
|
|
3033
|
+
}
|
|
3034
|
+
async deployments(link, limit) {
|
|
3035
|
+
const { primary, others } = await this.group(link, Math.max(1, limit));
|
|
3036
|
+
return primary.slice(0, limit).map((item) => this.toDeployment(link, item, this.siblings(item, others)));
|
|
3037
|
+
}
|
|
3038
|
+
async rollback(link, to) {
|
|
3039
|
+
const { first, primary, others } = await this.group(link, 20);
|
|
3040
|
+
let target;
|
|
3041
|
+
if (to) {
|
|
3042
|
+
target = primary.find((item) => item.id === to);
|
|
3043
|
+
if (!target) throw new Error(`Railway has no deployment ${to} among the last 20.`);
|
|
3044
|
+
} else {
|
|
3045
|
+
const running = primary.findIndex((item) => item.status === "SUCCESS");
|
|
3046
|
+
target = primary.slice(running + 1).find((item) => ["SUCCESS", "REMOVED"].includes(item.status ?? "") && running >= 0);
|
|
3047
|
+
if (!target) throw new Error("There is no earlier deploy to roll back to.");
|
|
3048
|
+
}
|
|
3049
|
+
if (railwayDeploymentStatus(target.status) !== "finished") throw new Error(`Deploy ${target.id} did not finish, so it cannot be rolled back to.`);
|
|
3050
|
+
const before = primary[0]?.id;
|
|
3051
|
+
for (const deployment of [target, ...this.siblings(target, others).map((item) => item.deployment)]) await this.api.query(`mutation ($id: String!) { deploymentRollback(id: $id) }`, { id: deployment.id });
|
|
3052
|
+
return this.newest(link, first.serviceId, before);
|
|
3053
|
+
}
|
|
3054
|
+
async status(link) {
|
|
3055
|
+
const instances = await this.instances(link);
|
|
3056
|
+
const states = instances.map((item) => item.latestDeployment?.status);
|
|
3057
|
+
let state = "unknown";
|
|
3058
|
+
if (!states.some(Boolean)) state = "stopped";
|
|
3059
|
+
else if (states.every((value) => value === "SUCCESS" || value === "SLEEPING")) state = "running";
|
|
3060
|
+
else if (states.some((value) => value === "FAILED" || value === "CRASHED" || value === "REMOVED")) state = "degraded";
|
|
3061
|
+
else if (states.some((value) => railwayDeploymentStatus(value ?? "") !== "finished")) state = "degraded";
|
|
3062
|
+
const detail = instances.map((item) => `${item.serviceName}:${(item.latestDeployment?.status ?? "none").toLowerCase()}`).join(", ");
|
|
3063
|
+
const domains = (instances.find((item) => item.serviceId === link.application)?.domains?.customDomains ?? []).map((item) => `https://${item.domain}`);
|
|
3064
|
+
const branch = (instances.find((item) => item.source?.repo && item.serviceName === "backend") ?? instances.find((item) => item.source?.repo))?.latestDeployment;
|
|
3065
|
+
return {
|
|
3066
|
+
state,
|
|
3067
|
+
detail: detail || "no services",
|
|
3068
|
+
domains,
|
|
3069
|
+
branch: branch ? metaText(branch, "branch") : void 0,
|
|
3070
|
+
url: railwayDashboardUrl(link)
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
};
|
|
3074
|
+
function missingService(name) {
|
|
3075
|
+
return `The Railway project has no service ${name}. compose.yaml changed since setup: add the service in Railway, or run dstack deploy setup --provider railway --force to create a new project.`;
|
|
3076
|
+
}
|
|
3077
|
+
const railway = {
|
|
3078
|
+
id: "railway",
|
|
3079
|
+
name: "Railway",
|
|
3080
|
+
auth: {
|
|
3081
|
+
kind: "token",
|
|
3082
|
+
env: "DSTACK_RAILWAY_TOKEN",
|
|
3083
|
+
help: () => "Create an account token, or a token of the workspace to deploy to, at https://railway.com/account/tokens. Connect the Railway GitHub App to the repository too: Railway builds from GitHub."
|
|
3084
|
+
},
|
|
3085
|
+
source: "remote",
|
|
3086
|
+
target: {
|
|
3087
|
+
label: "Railway API",
|
|
3088
|
+
placeholder: RAILWAY_API,
|
|
3089
|
+
fixed: RAILWAY_API,
|
|
3090
|
+
normalize: () => RAILWAY_API
|
|
3091
|
+
},
|
|
3092
|
+
platformEnv: RAILWAY_PLATFORM_ENV,
|
|
3093
|
+
setupFlags: [
|
|
3094
|
+
{
|
|
3095
|
+
name: "workspace",
|
|
3096
|
+
description: "Railway: the workspace, by name or id (default: the only one)"
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
name: "project",
|
|
3100
|
+
description: "The platform's project, by name or id (default: the project name)"
|
|
3101
|
+
},
|
|
3102
|
+
{
|
|
3103
|
+
name: "environment",
|
|
3104
|
+
description: "The platform's environment (default: production)"
|
|
3105
|
+
}
|
|
3106
|
+
],
|
|
3107
|
+
printsDns: true,
|
|
3108
|
+
connect: (_target, token) => {
|
|
3109
|
+
if (!token) throw new Error("Railway needs an API key.");
|
|
3110
|
+
return new RailwayClient(token);
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
2383
3113
|
// src/deploy/credentials.ts
|
|
2384
3114
|
/**
|
|
2385
3115
|
* API keys of deploy providers, never in the project: the OS keychain
|
|
@@ -2489,7 +3219,8 @@ const PROVIDERS = [
|
|
|
2489
3219
|
coolify,
|
|
2490
3220
|
dokploy,
|
|
2491
3221
|
ssh,
|
|
2492
|
-
bunny
|
|
3222
|
+
bunny,
|
|
3223
|
+
railway
|
|
2493
3224
|
];
|
|
2494
3225
|
function getProvider(id) {
|
|
2495
3226
|
const provider = PROVIDERS.find((item) => item.id === id);
|
|
@@ -3338,6 +4069,8 @@ async function init(directoryArgument, options) {
|
|
|
3338
4069
|
localS3: stack.storage === "s3",
|
|
3339
4070
|
analytics: stack.analytics === "umami",
|
|
3340
4071
|
cache: stack.cache,
|
|
4072
|
+
notifications: stack.notifications === "on",
|
|
4073
|
+
errors: stack.errors,
|
|
3341
4074
|
tasks: stack.tasks,
|
|
3342
4075
|
preset: encodePreset(stack),
|
|
3343
4076
|
timeZone,
|
|
@@ -3778,7 +4511,8 @@ const SWITCHABLE = [
|
|
|
3778
4511
|
"analytics",
|
|
3779
4512
|
"media",
|
|
3780
4513
|
"cache",
|
|
3781
|
-
"tasks"
|
|
4514
|
+
"tasks",
|
|
4515
|
+
"notifications"
|
|
3782
4516
|
];
|
|
3783
4517
|
const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
|
|
3784
4518
|
/**
|
|
@@ -3845,6 +4579,7 @@ function applyStack(envText, env, from, to) {
|
|
|
3845
4579
|
changes.push("Page analytics are off; Umami's data stays in the database.");
|
|
3846
4580
|
}
|
|
3847
4581
|
}
|
|
4582
|
+
if (key === "notifications") changes.push(to.notifications === "on" ? "Notifications are on: content_studio.notifications is in INSTALLED_APPS." : "Notifications are off: content_studio.notifications is out of INSTALLED_APPS; its tables stay.");
|
|
3848
4583
|
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.");
|
|
3849
4584
|
}
|
|
3850
4585
|
return {
|
|
@@ -3984,6 +4719,20 @@ async function manage(args, options) {
|
|
|
3984
4719
|
...args
|
|
3985
4720
|
])));
|
|
3986
4721
|
}
|
|
4722
|
+
/** `dstack status`: the containers, then the database, cache, worker and backup checks. */
|
|
4723
|
+
async function status(options) {
|
|
4724
|
+
const project = activeProject(open(options));
|
|
4725
|
+
if (!runningServices(project).includes(project.services.backend)) fail(`The ${project.services.backend} service is not running.`);
|
|
4726
|
+
compose(project, ["ps"]);
|
|
4727
|
+
exitWith(compose(project, [
|
|
4728
|
+
"exec",
|
|
4729
|
+
"-T",
|
|
4730
|
+
project.services.backend,
|
|
4731
|
+
"python",
|
|
4732
|
+
"manage.py",
|
|
4733
|
+
"health"
|
|
4734
|
+
]));
|
|
4735
|
+
}
|
|
3987
4736
|
/** `dstack shell`: Django's shell in the backend container. */
|
|
3988
4737
|
async function shell(options) {
|
|
3989
4738
|
return manage(["shell"], options);
|
|
@@ -4308,6 +5057,16 @@ async function config(options) {
|
|
|
4308
5057
|
const compose = join(project.directory, "compose.yaml");
|
|
4309
5058
|
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.");
|
|
4310
5059
|
}
|
|
5060
|
+
const settingsPath = join(project.directory, "backend", "config", "settings.py");
|
|
5061
|
+
let settingsText;
|
|
5062
|
+
if (next.notifications !== current.notifications) {
|
|
5063
|
+
if (!existsSync(settingsPath)) fail("This project has no backend/config/settings.py.");
|
|
5064
|
+
try {
|
|
5065
|
+
settingsText = setNotificationsApp(readFileSync(settingsPath, "utf8"), next.notifications === "on");
|
|
5066
|
+
} catch (error) {
|
|
5067
|
+
fail(error.message);
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
4311
5070
|
const envPath = join(project.directory, ".env");
|
|
4312
5071
|
let result;
|
|
4313
5072
|
try {
|
|
@@ -4321,10 +5080,11 @@ async function config(options) {
|
|
|
4321
5080
|
}
|
|
4322
5081
|
const envText = readFileSync(envPath, "utf8");
|
|
4323
5082
|
if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
|
|
5083
|
+
if (settingsText !== void 0) writeFileSync(settingsPath, settingsText);
|
|
4324
5084
|
const manifestPath = join(project.directory, "package.json");
|
|
4325
5085
|
if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
|
|
4326
5086
|
for (const change of result.changes) p.log.success(change);
|
|
4327
|
-
if (result.env === envText) p.outro(pc.green("Done"));
|
|
5087
|
+
if (result.env === envText && settingsText === void 0) p.outro(pc.green("Done"));
|
|
4328
5088
|
else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
|
|
4329
5089
|
}
|
|
4330
5090
|
// src/update-check.ts
|
|
@@ -4539,6 +5299,7 @@ function createProgram() {
|
|
|
4539
5299
|
program.command("dev").description("Run the development servers with hot reload").addOption(dirOption()).option("--no-build", "Don't build the images first").action(dev);
|
|
4540
5300
|
program.command("stop").description("Stop the project; the data stays").addOption(dirOption()).action(stop);
|
|
4541
5301
|
program.command("logs").description("Show the logs of the services").argument("[services...]", "Only these services, such as backend").addOption(dirOption()).option("--no-follow", "Print the logs and exit").option("-n, --tail <lines>", "Only the last lines of each service").action(logs);
|
|
5302
|
+
program.command("status").description("Show the containers and whether the database, cache, worker and backups are healthy").addOption(dirOption()).action(status);
|
|
4542
5303
|
program.command("check").description("Check the project: setup, Django checks, migrations and linters").addOption(dirOption()).option("--tests", "Run the backend and frontend tests too").action(check);
|
|
4543
5304
|
program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every backend package in uv.lock, not only django-stack").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
|
|
4544
5305
|
const configCommand = program.command("config").description("Change the stack options of a project").addOption(dirOption()).option("-y, --yes", "Don't ask; change only what the flags set").action((options, cmd) => config({
|
|
@@ -4552,7 +5313,7 @@ function createProgram() {
|
|
|
4552
5313
|
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);
|
|
4553
5314
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
4554
5315
|
const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
|
|
4555
|
-
const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
|
|
5316
|
+
const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny, Railway or a server over SSH, and show the status");
|
|
4556
5317
|
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);
|
|
4557
5318
|
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);
|
|
4558
5319
|
const seen = /* @__PURE__ */ new Set();
|