@django-stack/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +2002 -247
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{stack-OTrWHXrO.js → stack-CTyqYWCV.js} +122 -34
- package/package.json +2 -1
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +2 -1
- package/templates/base/README.md +4 -4
- package/templates/base/compose.yaml +7 -28
- package/templates/base/env.example +7 -4
- package/templates/base/gitignore +1 -0
- package/templates/frontend-astro/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,13 +1,1484 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { D as
|
|
3
|
-
import {
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { D as parseEnv, E as checkTools, O as setEnvValues, S as validateDomain, T as runDoctor, _ as toProjectName, a as decodePreset, b as CLI_VERSION, c as resolvePreset, d as LOCAL_S3, f as lockBackend, g as timeZones, h as scaffold, i as applies, l as stackOption, m as relativeDirectory, n as NAMED_PRESETS, o as encodePreset, r as STACK_OPTIONS, s as pendingChoices, t as DEFAULT_STACK, v as validateProjectName, w as generateSecret, x as productionEnv, y as validateTimeZone } from "./stack-CTyqYWCV.js";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
6
7
|
import pc from "picocolors";
|
|
7
8
|
import tab from "@bomb.sh/tab/commander";
|
|
8
9
|
import * as p from "@clack/prompts";
|
|
9
10
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
10
11
|
import { homedir } from "node:os";
|
|
12
|
+
// src/deploy/provider.ts
|
|
13
|
+
/** The target of a platform with a web API and an API key. */
|
|
14
|
+
function urlTarget(name, placeholder) {
|
|
15
|
+
return {
|
|
16
|
+
label: `${name} URL`,
|
|
17
|
+
placeholder,
|
|
18
|
+
normalize: normalizeUrl
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Normalizes an instance URL: https by default, no trailing slash or path. */
|
|
22
|
+
function normalizeUrl(value) {
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(withScheme);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
|
|
30
|
+
}
|
|
31
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
32
|
+
}
|
|
33
|
+
// src/deploy/coolify.ts
|
|
34
|
+
/**
|
|
35
|
+
* Coolify (https://coolify.io), through its API at /api/v1 with a bearer
|
|
36
|
+
* token. The project deploys as a Docker Compose application that Coolify
|
|
37
|
+
* builds on the server from the git repository; Coolify's proxy gets the
|
|
38
|
+
* certificate and forwards to the `proxy` service (Caddy) on port 80.
|
|
39
|
+
*/
|
|
40
|
+
/** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
|
|
41
|
+
const PLATFORM_ENV = {
|
|
42
|
+
CADDY_ADDRESS: ":80",
|
|
43
|
+
TRUSTED_PROXIES: "private_ranges",
|
|
44
|
+
NUM_PROXIES: "2",
|
|
45
|
+
HTTP_PORT: "127.0.0.1:8080",
|
|
46
|
+
HTTPS_PORT: "127.0.0.1:8443"
|
|
47
|
+
};
|
|
48
|
+
/** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
|
|
49
|
+
const SHARED_SETUP_FLAGS = [
|
|
50
|
+
{
|
|
51
|
+
name: "server",
|
|
52
|
+
description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "project",
|
|
56
|
+
description: "The platform's project, by name or id (default: the project name)"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "environment",
|
|
60
|
+
description: "The platform's environment (default: production)"
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
var CoolifyError = class extends Error {
|
|
64
|
+
status;
|
|
65
|
+
constructor(message, status) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.status = status;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var CoolifyApi = class {
|
|
71
|
+
url;
|
|
72
|
+
token;
|
|
73
|
+
fetchImpl;
|
|
74
|
+
constructor(url, token, fetchImpl = fetch) {
|
|
75
|
+
this.url = url;
|
|
76
|
+
this.token = token;
|
|
77
|
+
this.fetchImpl = fetchImpl;
|
|
78
|
+
}
|
|
79
|
+
async request(method, path, body) {
|
|
80
|
+
let response;
|
|
81
|
+
try {
|
|
82
|
+
response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${this.token}`,
|
|
86
|
+
Accept: "application/json",
|
|
87
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
88
|
+
},
|
|
89
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
90
|
+
signal: AbortSignal.timeout(3e4)
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
94
|
+
}
|
|
95
|
+
const text = await response.text();
|
|
96
|
+
if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
|
|
97
|
+
if (!text) return void 0;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
get = (path) => this.request("GET", path);
|
|
105
|
+
post = (path, body) => this.request("POST", path, body ?? {});
|
|
106
|
+
patch = (path, body) => this.request("PATCH", path, body);
|
|
107
|
+
};
|
|
108
|
+
function errorMessage$1(status, text, url) {
|
|
109
|
+
if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
110
|
+
if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
|
|
111
|
+
if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
|
|
112
|
+
let message = "";
|
|
113
|
+
try {
|
|
114
|
+
const body = JSON.parse(text);
|
|
115
|
+
message = body.message ?? "";
|
|
116
|
+
if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
|
|
117
|
+
} catch {
|
|
118
|
+
message = text.slice(0, 200);
|
|
119
|
+
}
|
|
120
|
+
return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
|
|
121
|
+
}
|
|
122
|
+
const enc = encodeURIComponent;
|
|
123
|
+
function deploymentStatus(value) {
|
|
124
|
+
if (value === "queued") return "queued";
|
|
125
|
+
if (value === "in_progress") return "building";
|
|
126
|
+
if (value === "finished") return "finished";
|
|
127
|
+
if (value.startsWith("cancelled")) return "cancelled";
|
|
128
|
+
return "failed";
|
|
129
|
+
}
|
|
130
|
+
function appState(value) {
|
|
131
|
+
const [state = "", health = ""] = (value ?? "").split(/[:(]/);
|
|
132
|
+
if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
|
|
133
|
+
if (state === "degraded" || state === "restarting") return "degraded";
|
|
134
|
+
if (state === "exited" || state === "stopped") return "stopped";
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
/** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
|
|
138
|
+
function parseLog(logs) {
|
|
139
|
+
if (!logs) return [];
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = JSON.parse(logs);
|
|
143
|
+
} catch {
|
|
144
|
+
return logs.split("\n").map((text) => ({
|
|
145
|
+
text,
|
|
146
|
+
stderr: false
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(entries)) return [];
|
|
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
|
+
})));
|
|
154
|
+
}
|
|
155
|
+
/** The domains of a Compose application, by service. */
|
|
156
|
+
function composeDomains(app) {
|
|
157
|
+
let value = app.docker_compose_domains;
|
|
158
|
+
if (typeof value === "string") try {
|
|
159
|
+
value = JSON.parse(value);
|
|
160
|
+
} catch {
|
|
161
|
+
value = null;
|
|
162
|
+
}
|
|
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
|
+
}
|
|
170
|
+
function toDeployment$1(api, link, item) {
|
|
171
|
+
const path = item.deployment_url;
|
|
172
|
+
return {
|
|
173
|
+
id: item.deployment_uuid,
|
|
174
|
+
status: deploymentStatus(item.status),
|
|
175
|
+
commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
|
|
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
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Finds a name or uuid in a list. */
|
|
184
|
+
function find$1(items, wanted) {
|
|
185
|
+
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
186
|
+
}
|
|
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
|
+
const systemSsh = (args, input) => new Promise((resolve, reject) => {
|
|
359
|
+
const child = spawn("ssh", args, { stdio: [
|
|
360
|
+
"pipe",
|
|
361
|
+
"pipe",
|
|
362
|
+
"pipe"
|
|
363
|
+
] });
|
|
364
|
+
const stdout = [];
|
|
365
|
+
const stderr = [];
|
|
366
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
367
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
368
|
+
child.on("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "There is no ssh command here. Install OpenSSH." : `Could not run ssh: ${error.message}`)));
|
|
369
|
+
child.on("close", (code) => resolve({
|
|
370
|
+
code: code ?? 1,
|
|
371
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
372
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
373
|
+
}));
|
|
374
|
+
child.stdin.on("error", () => {});
|
|
375
|
+
child.stdin.end(input);
|
|
376
|
+
});
|
|
377
|
+
const USER = /^[a-z_][\w.-]*$/i;
|
|
378
|
+
const HOST = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$|^\[[0-9a-f:.]+\]$/i;
|
|
379
|
+
/** Reads deploy@example.com, example.com:2222 or ssh://deploy@example.com:2222. */
|
|
380
|
+
function parseTarget(value) {
|
|
381
|
+
const match = value.trim().replace(/^ssh:\/\//i, "").replace(/\/$/, "").match(/^(?:([^@\s]+)@)?(\[[^\]]+\]|[^:@\s]+)(?::(\d+))?$/);
|
|
382
|
+
const reason = `${value} is not an SSH target. Enter one such as deploy@example.com or deploy@example.com:2222.`;
|
|
383
|
+
if (!match) throw new Error(reason);
|
|
384
|
+
const [, user, host = "", port] = match;
|
|
385
|
+
if (user && !USER.test(user) || !HOST.test(host)) throw new Error(reason);
|
|
386
|
+
const number = port ? Number(port) : void 0;
|
|
387
|
+
if (number !== void 0 && (number < 1 || number > 65535)) throw new Error(reason);
|
|
388
|
+
return {
|
|
389
|
+
user,
|
|
390
|
+
host,
|
|
391
|
+
port: number
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function formatTarget(target) {
|
|
395
|
+
return `ssh://${target.user ? `${target.user}@` : ""}${target.host}${target.port ? `:${target.port}` : ""}`;
|
|
396
|
+
}
|
|
397
|
+
/** Quotes a value for the remote POSIX shell. */
|
|
398
|
+
function shellQuote(value) {
|
|
399
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
400
|
+
}
|
|
401
|
+
/** A Compose project name: lower case letters, digits, dashes and underscores. */
|
|
402
|
+
function composeName(name) {
|
|
403
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+|-+$/g, "") || "site";
|
|
404
|
+
}
|
|
405
|
+
/** A deploy id that sorts by time: 20261105-183000-abc1234. */
|
|
406
|
+
function deployId(date, suffix) {
|
|
407
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
408
|
+
return `${`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`}-${`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`}-${suffix}`;
|
|
409
|
+
}
|
|
410
|
+
const ID = /^[\w.-]+$/;
|
|
411
|
+
/** The status file: building, finished or failed, and whether the deploy's process lives. */
|
|
412
|
+
function recordStatus(status, alive) {
|
|
413
|
+
const value = status.trim();
|
|
414
|
+
if (value === "finished") return "finished";
|
|
415
|
+
if (value === "failed") return "failed";
|
|
416
|
+
if (value === "queued") return alive ? "queued" : "failed";
|
|
417
|
+
if (value === "building") return alive ? "building" : "failed";
|
|
418
|
+
return "failed";
|
|
419
|
+
}
|
|
420
|
+
/** `docker compose ps --format json` prints an array (older) or one object per line (newer). */
|
|
421
|
+
function parseComposePs(output) {
|
|
422
|
+
const text = output.trim();
|
|
423
|
+
if (!text) return [];
|
|
424
|
+
if (text.startsWith("[")) return JSON.parse(text);
|
|
425
|
+
return text.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
426
|
+
}
|
|
427
|
+
function composeState(containers) {
|
|
428
|
+
if (!containers.length) return {
|
|
429
|
+
state: "stopped",
|
|
430
|
+
detail: "no containers"
|
|
431
|
+
};
|
|
432
|
+
const running = containers.filter((item) => item.State === "running");
|
|
433
|
+
const unhealthy = containers.filter((item) => item.Health === "unhealthy" || item.State === "restarting");
|
|
434
|
+
const summary = `${running.length}/${containers.length} running`;
|
|
435
|
+
if (!running.length) return {
|
|
436
|
+
state: "stopped",
|
|
437
|
+
detail: summary
|
|
438
|
+
};
|
|
439
|
+
if (unhealthy.length) return {
|
|
440
|
+
state: "degraded",
|
|
441
|
+
detail: `${summary}, unhealthy: ${unhealthy.map((item) => item.Service).join(", ")}`
|
|
442
|
+
};
|
|
443
|
+
if (running.length < containers.length) return {
|
|
444
|
+
state: "degraded",
|
|
445
|
+
detail: `${summary}, not running: ${containers.filter((item) => item.State !== "running").map((item) => item.Service).join(", ")}`
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
state: "running",
|
|
449
|
+
detail: summary
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** Splits the output of a remote command whose parts are separated by MARK lines. */
|
|
453
|
+
const MARK = "--dstack-8f2c--";
|
|
454
|
+
function parts(output) {
|
|
455
|
+
return output.split(`\n${MARK}\n`);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The script that runs a deploy on the server, detached. It builds and
|
|
459
|
+
* starts the release, moves `current` to it on success and removes the
|
|
460
|
+
* oldest releases beyond KEEP_RELEASES.
|
|
461
|
+
*/
|
|
462
|
+
function deployScript(path, project, id, release, force) {
|
|
463
|
+
const q = shellQuote;
|
|
464
|
+
const compose = `docker compose -p ${q(project)}`;
|
|
465
|
+
return `#!/bin/sh
|
|
466
|
+
set -u
|
|
467
|
+
app=${q(path)}
|
|
468
|
+
status="$app/deploys/${id}.status"
|
|
469
|
+
trap 'rm -rf "$app/deploy.lock"' EXIT
|
|
470
|
+
cd "$app/releases/${release}" || { echo failed > "$status"; exit 1; }
|
|
471
|
+
echo building > "$status"
|
|
472
|
+
echo "Deploy ${id}: release ${release}"
|
|
473
|
+
${force ? `${compose} build --no-cache --pull && ` : ""}${compose} up -d --build --remove-orphans --wait --wait-timeout 600
|
|
474
|
+
code=$?
|
|
475
|
+
if [ "$code" -ne 0 ]; then
|
|
476
|
+
echo "docker compose exited with $code"
|
|
477
|
+
${compose} ps --all
|
|
478
|
+
echo failed > "$status"
|
|
479
|
+
exit "$code"
|
|
480
|
+
fi
|
|
481
|
+
ln -sfn "releases/${release}" "$app/current"
|
|
482
|
+
cd "$app/releases"
|
|
483
|
+
ls -1 | sort -r | tail -n +6 | while read -r old; do
|
|
484
|
+
[ "$old" = ${q(release)} ] || rm -rf -- "$old"
|
|
485
|
+
done
|
|
486
|
+
echo "Started release ${release}"
|
|
487
|
+
echo finished > "$status"
|
|
488
|
+
`;
|
|
489
|
+
}
|
|
490
|
+
var SshClient = class {
|
|
491
|
+
run;
|
|
492
|
+
git;
|
|
493
|
+
target;
|
|
494
|
+
constructor(target, run = systemSsh, git = defaultGit) {
|
|
495
|
+
this.run = run;
|
|
496
|
+
this.git = git;
|
|
497
|
+
this.target = parseTarget(target);
|
|
498
|
+
}
|
|
499
|
+
get destination() {
|
|
500
|
+
return `${this.target.user ? `${this.target.user}@` : ""}${this.target.host.replace(/^\[|\]$/g, "")}`;
|
|
501
|
+
}
|
|
502
|
+
/** Runs a POSIX shell script on the server; fails with a one-line reason. */
|
|
503
|
+
async sh(script, input) {
|
|
504
|
+
const args = [
|
|
505
|
+
"-o",
|
|
506
|
+
"BatchMode=yes",
|
|
507
|
+
"-o",
|
|
508
|
+
"ConnectTimeout=15",
|
|
509
|
+
...this.target.port ? ["-p", String(this.target.port)] : [],
|
|
510
|
+
"--",
|
|
511
|
+
this.destination,
|
|
512
|
+
`sh -c ${shellQuote(script)}`
|
|
513
|
+
];
|
|
514
|
+
const result = await this.run(args, input);
|
|
515
|
+
if (result.code === 0) return result.stdout;
|
|
516
|
+
throw new Error(this.errorMessage(result));
|
|
517
|
+
}
|
|
518
|
+
errorMessage(result) {
|
|
519
|
+
const stderr = result.stderr.trim();
|
|
520
|
+
const where = formatTarget(this.target);
|
|
521
|
+
if (/REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(stderr)) return `The host key of ${where} changed. If you reinstalled the server, remove the old key with ssh-keygen -R ${this.target.host}; otherwise do not connect.`;
|
|
522
|
+
if (/Host key verification failed|No .* host key is known/i.test(stderr)) return `${where} is not in your known_hosts yet. Connect once with ssh ${this.destination}${this.target.port ? ` -p ${this.target.port}` : ""}, check the fingerprint and answer yes, then try again.`;
|
|
523
|
+
if (/Permission denied/i.test(stderr)) return `${where} refused your SSH key. Add it with ssh-copy-id ${this.destination}, or load it into the agent with ssh-add.`;
|
|
524
|
+
if (result.code === 255) return `Could not connect to ${where}: ${stderr.split("\n").at(-1) || "ssh failed"}`;
|
|
525
|
+
const last = (stderr || result.stdout.trim()).split("\n").at(-1) ?? "";
|
|
526
|
+
return `The command on ${where} failed (exit ${result.code})${last ? `: ${last}` : "."}`;
|
|
527
|
+
}
|
|
528
|
+
/** Checks the connection and Docker; returns their versions. */
|
|
529
|
+
async verify() {
|
|
530
|
+
const output = await this.sh(`
|
|
531
|
+
command -v docker >/dev/null 2>&1 || { echo missing:docker; exit 0; }
|
|
532
|
+
docker compose version --short 2>/dev/null || echo missing:compose
|
|
533
|
+
docker info --format '{{.ServerVersion}}' 2>/dev/null || echo missing:permission
|
|
534
|
+
`);
|
|
535
|
+
if (output.includes("missing:docker")) throw new Error(`${formatTarget(this.target)} has no Docker. Install Docker Engine with the Compose plugin: https://docs.docker.com/engine/install/`);
|
|
536
|
+
if (output.includes("missing:compose")) throw new Error(`${formatTarget(this.target)} has Docker without the Compose plugin. Install docker-compose-plugin: https://docs.docker.com/compose/install/linux/`);
|
|
537
|
+
if (output.includes("missing:permission")) throw new Error(`${this.destination} may not use Docker on the server. Run sudo usermod -aG docker ${this.target.user ?? "$USER"} there and connect again.`);
|
|
538
|
+
const [compose = "", engine = ""] = output.trim().split("\n");
|
|
539
|
+
return `Docker ${engine.trim()}, Compose ${compose.trim().replace(/^v/, "")}`;
|
|
540
|
+
}
|
|
541
|
+
async setup(context) {
|
|
542
|
+
const { ask, options } = context;
|
|
543
|
+
let path = options.path ?? `/srv/${composeName(context.name)}`;
|
|
544
|
+
if (path.startsWith("~/")) path = `${(await this.sh("printf %s \"$HOME\"")).trim()}/${path.slice(2)}`;
|
|
545
|
+
if (!path.startsWith("/")) throw new Error(`Give --path as an absolute path, such as /srv/${composeName(context.name)}.`);
|
|
546
|
+
path = path.replace(/\/+$/, "");
|
|
547
|
+
const q = shellQuote;
|
|
548
|
+
if (!(await this.sh(`
|
|
549
|
+
if mkdir -p ${q(`${path}/releases`)} ${q(`${path}/shared`)} ${q(`${path}/deploys`)} 2>/dev/null && [ -w ${q(path)} ]; then echo ok; fi
|
|
550
|
+
`)).includes("ok")) {
|
|
551
|
+
const home = (await this.sh("printf %s \"$HOME\"")).trim();
|
|
552
|
+
throw new Error(`${this.destination} cannot write to ${path} on the server. Create it there with sudo mkdir -p ${path} && sudo chown ${this.target.user ?? "$USER"} ${path}, or pass a directory you own, such as --path ${home}/${composeName(context.name)}.`);
|
|
553
|
+
}
|
|
554
|
+
ask.step(`Created ${path} on the server`);
|
|
555
|
+
const project = composeName(context.name);
|
|
556
|
+
const link = {
|
|
557
|
+
project: path,
|
|
558
|
+
environment: "production",
|
|
559
|
+
application: project
|
|
560
|
+
};
|
|
561
|
+
await this.pushEnv({
|
|
562
|
+
provider: "ssh",
|
|
563
|
+
url: formatTarget(this.target),
|
|
564
|
+
...link
|
|
565
|
+
}, context.env);
|
|
566
|
+
ask.step("Uploaded the variables as shared/.env");
|
|
567
|
+
return link;
|
|
568
|
+
}
|
|
569
|
+
async pushEnv(link, env) {
|
|
570
|
+
const text = Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
|
|
571
|
+
const file = shellQuote(`${link.project}/shared/.env`);
|
|
572
|
+
await this.sh(`umask 077 && cat > ${file}.new && chmod 600 ${file}.new && mv ${file}.new ${file}`, `${text}\n`);
|
|
573
|
+
}
|
|
574
|
+
async deploy(link, options) {
|
|
575
|
+
const { directory } = options;
|
|
576
|
+
const [commit = "", message = ""] = this.git([
|
|
577
|
+
"-C",
|
|
578
|
+
directory,
|
|
579
|
+
"log",
|
|
580
|
+
"-1",
|
|
581
|
+
"--format=%h%n%s"
|
|
582
|
+
]).toString("utf8").split("\n");
|
|
583
|
+
const id = deployId(/* @__PURE__ */ new Date(), commit || "local");
|
|
584
|
+
await this.lock(link, id);
|
|
585
|
+
const archive = this.git([
|
|
586
|
+
"-C",
|
|
587
|
+
directory,
|
|
588
|
+
"archive",
|
|
589
|
+
"--format=tar",
|
|
590
|
+
"HEAD"
|
|
591
|
+
]);
|
|
592
|
+
const release = shellQuote(`${link.project}/releases/${id}`);
|
|
593
|
+
await this.sh(`mkdir -p ${release} && tar -x -C ${release} && ln -sfn ../../shared/.env ${release}/.env`, archive);
|
|
594
|
+
await this.start(link, {
|
|
595
|
+
id,
|
|
596
|
+
release: id,
|
|
597
|
+
commit,
|
|
598
|
+
message,
|
|
599
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
600
|
+
}, options.force);
|
|
601
|
+
return id;
|
|
602
|
+
}
|
|
603
|
+
async rollback(link, to) {
|
|
604
|
+
const deployments = await this.records(link, 50);
|
|
605
|
+
const current = (await this.sh(`readlink ${shellQuote(`${link.project}/current`)} || true`)).trim().replace(/^releases\//, "");
|
|
606
|
+
const releases = new Set((await this.sh(`ls -1 ${shellQuote(`${link.project}/releases`)}`)).split("\n").filter(Boolean));
|
|
607
|
+
let target;
|
|
608
|
+
if (to) {
|
|
609
|
+
target = deployments.find((item) => item.record.id === to)?.record;
|
|
610
|
+
if (!target) throw new Error(`There is no deploy ${to} on the server.`);
|
|
611
|
+
} else {
|
|
612
|
+
target = deployments.find((item) => item.status === "finished" && item.record.release !== current && releases.has(item.record.release))?.record;
|
|
613
|
+
if (!target) throw new Error("There is no earlier release on the server to roll back to.");
|
|
614
|
+
}
|
|
615
|
+
if (!releases.has(target.release)) throw new Error(`The release of ${target.id} is no longer on the server; only the last 5 are kept.`);
|
|
616
|
+
const id = deployId(/* @__PURE__ */ new Date(), "rollback");
|
|
617
|
+
await this.lock(link, id);
|
|
618
|
+
await this.start(link, {
|
|
619
|
+
id,
|
|
620
|
+
release: target.release,
|
|
621
|
+
commit: target.commit,
|
|
622
|
+
message: `Rollback to ${target.id}`,
|
|
623
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
624
|
+
rollbackOf: target.id
|
|
625
|
+
}, false);
|
|
626
|
+
return id;
|
|
627
|
+
}
|
|
628
|
+
/** Takes the server's deploy lock, or fails when a deploy is running. */
|
|
629
|
+
async lock(link, id) {
|
|
630
|
+
const lock = shellQuote(`${link.project}/deploy.lock`);
|
|
631
|
+
const output = await this.sh(`
|
|
632
|
+
if mkdir ${lock} 2>/dev/null; then echo ${id} > ${lock}/id; echo ok; exit 0; fi
|
|
633
|
+
pid=$(cat ${lock}/pid 2>/dev/null)
|
|
634
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "busy:$(cat ${lock}/id 2>/dev/null)"; exit 0; fi
|
|
635
|
+
rm -rf ${lock} && mkdir ${lock} && echo ${id} > ${lock}/id && echo ok
|
|
636
|
+
`);
|
|
637
|
+
const busy = output.match(/busy:(\S*)/);
|
|
638
|
+
if (busy) throw new Error(`Deploy ${busy[1]} is still running on the server. Follow it with dstack deploy status --watch.`);
|
|
639
|
+
if (!output.includes("ok")) throw new Error("Could not take the deploy lock on the server.");
|
|
640
|
+
}
|
|
641
|
+
async start(link, record, force) {
|
|
642
|
+
if (!ID.test(record.id) || !ID.test(record.release)) throw new Error(`Invalid deploy id ${record.id}.`);
|
|
643
|
+
const deploys = `${link.project}/deploys`;
|
|
644
|
+
const q = shellQuote;
|
|
645
|
+
const script = deployScript(link.project, link.application, record.id, record.release, force);
|
|
646
|
+
await this.sh(`
|
|
647
|
+
cd ${q(deploys)} || exit 1
|
|
648
|
+
cat > ${q(`${record.id}.sh`)}
|
|
649
|
+
printf '%s\\n' ${q(JSON.stringify(record))} > ${q(`${record.id}.json`)}
|
|
650
|
+
echo queued > ${q(`${record.id}.status`)}
|
|
651
|
+
nohup sh ${q(`${record.id}.sh`)} > ${q(`${record.id}.log`)} 2>&1 < /dev/null &
|
|
652
|
+
echo $! > ${q(`${record.id}.pid`)}
|
|
653
|
+
echo $! > ${q(`${link.project}/deploy.lock/pid`)}
|
|
654
|
+
`, script);
|
|
655
|
+
}
|
|
656
|
+
/** The newest deploy records first, with their status. */
|
|
657
|
+
async records(link, limit) {
|
|
658
|
+
const output = await this.sh(`
|
|
659
|
+
cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
|
|
660
|
+
for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
|
|
661
|
+
id="\${file%.json}"
|
|
662
|
+
cat "$file"
|
|
663
|
+
pid=$(cat "$id.pid" 2>/dev/null)
|
|
664
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
665
|
+
printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
666
|
+
echo ${MARK}
|
|
667
|
+
done
|
|
668
|
+
`);
|
|
669
|
+
const items = [];
|
|
670
|
+
for (const part of parts(`\n${output}`)) {
|
|
671
|
+
const lines = part.trim().split("\n");
|
|
672
|
+
if (lines.length < 2) continue;
|
|
673
|
+
try {
|
|
674
|
+
const record = JSON.parse(lines[0] ?? "");
|
|
675
|
+
const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
|
|
676
|
+
items.push({
|
|
677
|
+
record,
|
|
678
|
+
status: recordStatus(status, alive === "1"),
|
|
679
|
+
updatedAt: updatedAt || void 0
|
|
680
|
+
});
|
|
681
|
+
} catch {}
|
|
682
|
+
}
|
|
683
|
+
return items;
|
|
684
|
+
}
|
|
685
|
+
async deployment(link, id) {
|
|
686
|
+
if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
|
|
687
|
+
const deploys = shellQuote(`${link.project}/deploys`);
|
|
688
|
+
const output = await this.sh(`
|
|
689
|
+
cd ${deploys} || exit 1
|
|
690
|
+
[ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
|
|
691
|
+
cat ${shellQuote(`${id}.json`)}
|
|
692
|
+
pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
|
|
693
|
+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
|
|
694
|
+
printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
695
|
+
echo ${MARK}
|
|
696
|
+
cat ${shellQuote(`${id}.log`)} 2>/dev/null
|
|
697
|
+
`);
|
|
698
|
+
if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
|
|
699
|
+
const [head = "", log = ""] = output.split(`\n${MARK}\n`);
|
|
700
|
+
const [json = "{}", state = ""] = head.split("\n");
|
|
701
|
+
const record = JSON.parse(json);
|
|
702
|
+
const [status = "", alive = "0", updatedAt] = state.split(" ");
|
|
703
|
+
return {
|
|
704
|
+
...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
|
|
705
|
+
log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
|
|
706
|
+
text,
|
|
707
|
+
stderr: false
|
|
708
|
+
})) : []
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
async deployments(link, limit) {
|
|
712
|
+
return (await this.records(link, limit)).map((item) => ({
|
|
713
|
+
...toDeployment(item.record, item.status, item.updatedAt),
|
|
714
|
+
log: []
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
async status(link) {
|
|
718
|
+
const [ps = "", site = ""] = parts(await this.sh(`
|
|
719
|
+
docker compose -p ${shellQuote(link.application)} ps --all --format json
|
|
720
|
+
echo ${MARK}
|
|
721
|
+
sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
|
|
722
|
+
`));
|
|
723
|
+
const { state, detail } = composeState(parseComposePs(ps));
|
|
724
|
+
const domain = site.trim().replace(/^["']|["']$/g, "");
|
|
725
|
+
return {
|
|
726
|
+
state,
|
|
727
|
+
detail,
|
|
728
|
+
domains: domain ? [domain] : []
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
function toDeployment(record, status, updatedAt) {
|
|
733
|
+
return {
|
|
734
|
+
id: record.id,
|
|
735
|
+
status,
|
|
736
|
+
commit: record.commit,
|
|
737
|
+
message: record.message,
|
|
738
|
+
createdAt: record.createdAt,
|
|
739
|
+
updatedAt
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* A value for a Compose .env line: bare when safe, else single-quoted (taken
|
|
744
|
+
* literally), or double-quoted with escapes when it holds a single quote.
|
|
745
|
+
*/
|
|
746
|
+
function envValue(value) {
|
|
747
|
+
if (/^[\w@%+=:,./-]*$/.test(value)) return value;
|
|
748
|
+
if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
|
|
749
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
|
|
750
|
+
}
|
|
751
|
+
function defaultGit(args) {
|
|
752
|
+
return execFileSync("git", args, {
|
|
753
|
+
maxBuffer: 1073741824,
|
|
754
|
+
stdio: [
|
|
755
|
+
"ignore",
|
|
756
|
+
"pipe",
|
|
757
|
+
"pipe"
|
|
758
|
+
]
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const ssh = {
|
|
762
|
+
id: "ssh",
|
|
763
|
+
name: "SSH server",
|
|
764
|
+
auth: { kind: "ssh" },
|
|
765
|
+
source: "local",
|
|
766
|
+
target: {
|
|
767
|
+
label: "Server",
|
|
768
|
+
placeholder: "deploy@example.com",
|
|
769
|
+
normalize: (value) => formatTarget(parseTarget(value))
|
|
770
|
+
},
|
|
771
|
+
platformEnv: {},
|
|
772
|
+
setupFlags: [{
|
|
773
|
+
name: "path",
|
|
774
|
+
description: "The project directory on the server (default: /srv/<project>)"
|
|
775
|
+
}],
|
|
776
|
+
connect: (target) => new SshClient(target)
|
|
777
|
+
};
|
|
778
|
+
// src/deploy/dokploy.ts
|
|
779
|
+
var DokployError = class extends Error {
|
|
780
|
+
status;
|
|
781
|
+
constructor(message, status) {
|
|
782
|
+
super(message);
|
|
783
|
+
this.status = status;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
var DokployApi = class {
|
|
787
|
+
url;
|
|
788
|
+
token;
|
|
789
|
+
fetchImpl;
|
|
790
|
+
constructor(url, token, fetchImpl = fetch) {
|
|
791
|
+
this.url = url;
|
|
792
|
+
this.token = token;
|
|
793
|
+
this.fetchImpl = fetchImpl;
|
|
794
|
+
}
|
|
795
|
+
async request(method, procedure, input) {
|
|
796
|
+
let path = `${this.url}/api/${procedure}`;
|
|
797
|
+
if (method === "GET" && input) {
|
|
798
|
+
const query = new URLSearchParams();
|
|
799
|
+
for (const [key, value] of Object.entries(input)) if (value !== void 0) query.set(key, String(value));
|
|
800
|
+
path += `?${query}`;
|
|
801
|
+
}
|
|
802
|
+
let response;
|
|
803
|
+
try {
|
|
804
|
+
response = await this.fetchImpl(path, {
|
|
805
|
+
method,
|
|
806
|
+
headers: {
|
|
807
|
+
"x-api-key": this.token,
|
|
808
|
+
Accept: "application/json",
|
|
809
|
+
...method === "POST" ? { "Content-Type": "application/json" } : {}
|
|
810
|
+
},
|
|
811
|
+
body: method === "POST" ? JSON.stringify(input ?? {}) : void 0,
|
|
812
|
+
signal: AbortSignal.timeout(3e4)
|
|
813
|
+
});
|
|
814
|
+
} catch (error) {
|
|
815
|
+
throw new DokployError(`Could not reach Dokploy at ${this.url}: ${error.message}`, 0);
|
|
816
|
+
}
|
|
817
|
+
const text = await response.text();
|
|
818
|
+
if (!response.ok) throw new DokployError(errorMessage(response.status, text, this.url), response.status);
|
|
819
|
+
if (!text) return void 0;
|
|
820
|
+
try {
|
|
821
|
+
return JSON.parse(text);
|
|
822
|
+
} catch {
|
|
823
|
+
return text;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
get = (procedure, input) => this.request("GET", procedure, input);
|
|
827
|
+
post = (procedure, input) => this.request("POST", procedure, input);
|
|
828
|
+
};
|
|
829
|
+
function errorMessage(status, text, url) {
|
|
830
|
+
if (status === 401) return `Dokploy at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
831
|
+
if (status === 403) return "The API key may not do this. Create it as a user who may manage projects in Dokploy.";
|
|
832
|
+
if (status === 404 && text.includes("<html")) return `${url} does not answer like Dokploy; check the URL.`;
|
|
833
|
+
let message = "";
|
|
834
|
+
try {
|
|
835
|
+
const body = JSON.parse(text);
|
|
836
|
+
message = body.message ?? body.error?.message ?? "";
|
|
837
|
+
} catch {
|
|
838
|
+
message = text.slice(0, 200);
|
|
839
|
+
}
|
|
840
|
+
return `Dokploy answered ${status}${message ? `: ${message.trim()}` : "."}`;
|
|
841
|
+
}
|
|
842
|
+
/** A deployment's status: running, done, error or cancelled in Dokploy. */
|
|
843
|
+
function dokployDeploymentStatus(value) {
|
|
844
|
+
if (value === "done") return "finished";
|
|
845
|
+
if (value === "running") return "building";
|
|
846
|
+
if (value === "queued" || value === "idle") return "queued";
|
|
847
|
+
if (value === "cancelled") return "cancelled";
|
|
848
|
+
return "failed";
|
|
849
|
+
}
|
|
850
|
+
/** The service state from the compose status, which tracks the last deploy. */
|
|
851
|
+
function dokployAppState(value) {
|
|
852
|
+
if (value === "done") return "running";
|
|
853
|
+
if (value === "error") return "degraded";
|
|
854
|
+
if (value === "idle") return "stopped";
|
|
855
|
+
return "unknown";
|
|
856
|
+
}
|
|
857
|
+
/** A .env text for Dokploy, which writes it next to compose.yaml. */
|
|
858
|
+
function envText(env) {
|
|
859
|
+
return Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
|
|
860
|
+
}
|
|
861
|
+
function composeUrl(api, link) {
|
|
862
|
+
return `${api.url}/dashboard/project/${link.project}/environment/${link.environment}/services/compose/${link.application}`;
|
|
863
|
+
}
|
|
864
|
+
/** Finds a name or id in a list. */
|
|
865
|
+
function find(items, wanted, id, name) {
|
|
866
|
+
return items.find((item) => id(item) === wanted) ?? items.find((item) => name(item) === wanted);
|
|
867
|
+
}
|
|
868
|
+
const LOCAL_SERVER = "dokploy";
|
|
869
|
+
var DokployClient = class {
|
|
870
|
+
api;
|
|
871
|
+
constructor(url, token, fetchImpl) {
|
|
872
|
+
this.api = new DokployApi(url, token, fetchImpl);
|
|
873
|
+
}
|
|
874
|
+
async verify() {
|
|
875
|
+
await this.api.get("project.all");
|
|
876
|
+
const version = await this.api.get("settings.getDokployVersion").catch(() => void 0);
|
|
877
|
+
return typeof version === "string" && version.trim() ? version.trim() : "(version unknown)";
|
|
878
|
+
}
|
|
879
|
+
async setup(context) {
|
|
880
|
+
const { ask, options } = context;
|
|
881
|
+
if (!context.repository) throw new Error("Dokploy builds from a git repository; pass --repository.");
|
|
882
|
+
const api = this.api;
|
|
883
|
+
const servers = await api.get("server.all").catch(() => []) ?? [];
|
|
884
|
+
let serverId;
|
|
885
|
+
if (options.server && options.server !== LOCAL_SERVER) {
|
|
886
|
+
const server = find(servers, options.server, (item) => item.serverId, (item) => item.name);
|
|
887
|
+
if (!server) throw new Error(`Dokploy has no server ${options.server}.`);
|
|
888
|
+
serverId = server.serverId;
|
|
889
|
+
} else if (!options.server && servers.length && ask.interactive) {
|
|
890
|
+
const choice = await ask.select("Server to deploy to", [{
|
|
891
|
+
value: LOCAL_SERVER,
|
|
892
|
+
label: "The Dokploy server"
|
|
893
|
+
}, ...servers.map((item) => ({
|
|
894
|
+
value: item.serverId,
|
|
895
|
+
label: item.name,
|
|
896
|
+
hint: item.ipAddress
|
|
897
|
+
}))], LOCAL_SERVER);
|
|
898
|
+
serverId = choice === LOCAL_SERVER ? void 0 : choice;
|
|
899
|
+
}
|
|
900
|
+
const projects = await api.get("project.all") ?? [];
|
|
901
|
+
const projectName = options.project ?? context.name;
|
|
902
|
+
let project = find(projects, projectName, (item) => item.projectId, (item) => item.name);
|
|
903
|
+
if (!options.project && ask.interactive && projects.length) {
|
|
904
|
+
const NEW = "";
|
|
905
|
+
const id = await ask.select("Dokploy project", [...project ? [] : [{
|
|
906
|
+
value: NEW,
|
|
907
|
+
label: `Create ${projectName}`
|
|
908
|
+
}], ...projects.map((item) => ({
|
|
909
|
+
value: item.projectId,
|
|
910
|
+
label: item.name,
|
|
911
|
+
hint: item.description ?? void 0
|
|
912
|
+
}))], project?.projectId ?? NEW);
|
|
913
|
+
project = id === NEW ? void 0 : find(projects, id, (item) => item.projectId, (item) => item.name);
|
|
914
|
+
}
|
|
915
|
+
if (!project) {
|
|
916
|
+
const created = await api.post("project.create", {
|
|
917
|
+
name: projectName,
|
|
918
|
+
description: "DjangoStack"
|
|
919
|
+
});
|
|
920
|
+
const projectId = created?.project?.projectId ?? created?.projectId;
|
|
921
|
+
if (!projectId) throw new Error("Dokploy created the project but did not return its id.");
|
|
922
|
+
project = {
|
|
923
|
+
projectId,
|
|
924
|
+
name: projectName
|
|
925
|
+
};
|
|
926
|
+
ask.step(`Created the Dokploy project ${projectName}`);
|
|
927
|
+
}
|
|
928
|
+
const environmentName = options.environment ?? "production";
|
|
929
|
+
let environment = find(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
|
|
930
|
+
if (error.status === 404) throw new Error("This Dokploy has no environments; update Dokploy to a version with environments.");
|
|
931
|
+
throw error;
|
|
932
|
+
}) ?? [], environmentName, (item) => item.environmentId, (item) => item.name);
|
|
933
|
+
if (!environment) {
|
|
934
|
+
const environmentId = (await api.post("environment.create", {
|
|
935
|
+
name: environmentName,
|
|
936
|
+
projectId: project.projectId
|
|
937
|
+
}))?.environmentId ?? (await api.get("environment.byProjectId", { projectId: project.projectId })).find((item) => item.name === environmentName)?.environmentId;
|
|
938
|
+
if (!environmentId) throw new Error("Dokploy created the environment but did not return its id.");
|
|
939
|
+
environment = {
|
|
940
|
+
environmentId,
|
|
941
|
+
name: environmentName
|
|
942
|
+
};
|
|
943
|
+
ask.step(`Created the environment ${environmentName}`);
|
|
944
|
+
}
|
|
945
|
+
let composeId = (await api.post("compose.create", {
|
|
946
|
+
name: context.name,
|
|
947
|
+
environmentId: environment.environmentId,
|
|
948
|
+
projectId: project.projectId,
|
|
949
|
+
composeType: "docker-compose",
|
|
950
|
+
description: "DjangoStack",
|
|
951
|
+
...serverId ? { serverId } : {}
|
|
952
|
+
}))?.composeId;
|
|
953
|
+
if (!composeId) composeId = ((await api.get("environment.one", { environmentId: environment.environmentId }))?.compose ?? []).filter((item) => item.name === context.name).at(-1)?.composeId;
|
|
954
|
+
if (!composeId) throw new Error("Dokploy created the compose service but did not return its id.");
|
|
955
|
+
await api.post("compose.update", {
|
|
956
|
+
composeId,
|
|
957
|
+
sourceType: "git",
|
|
958
|
+
customGitUrl: context.repository,
|
|
959
|
+
customGitBranch: context.branch,
|
|
960
|
+
composePath: "./compose.yaml",
|
|
961
|
+
env: envText(context.env)
|
|
962
|
+
});
|
|
963
|
+
ask.step(`Created the compose service ${context.name} from ${context.repository} (${context.branch})`);
|
|
964
|
+
if (!(await api.get("domain.byComposeId", { composeId }).catch(() => []) ?? []).some((item) => item.host === context.domain)) await api.post("domain.create", {
|
|
965
|
+
host: context.domain,
|
|
966
|
+
path: "/",
|
|
967
|
+
port: 80,
|
|
968
|
+
https: true,
|
|
969
|
+
certificateType: "letsencrypt",
|
|
970
|
+
composeId,
|
|
971
|
+
serviceName: "proxy",
|
|
972
|
+
domainType: "compose"
|
|
973
|
+
});
|
|
974
|
+
ask.step(`Gave the proxy service https://${context.domain}`);
|
|
975
|
+
return {
|
|
976
|
+
project: project.projectId,
|
|
977
|
+
environment: environment.environmentId,
|
|
978
|
+
application: composeId
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
async pushEnv(link, env) {
|
|
982
|
+
await this.api.post("compose.update", {
|
|
983
|
+
composeId: link.application,
|
|
984
|
+
env: envText(env)
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
async deploy(link) {
|
|
988
|
+
const before = new Set((await this.raw(link)).map((item) => item.deploymentId));
|
|
989
|
+
await this.api.post("compose.deploy", {
|
|
990
|
+
composeId: link.application,
|
|
991
|
+
title: "dstack deploy"
|
|
992
|
+
});
|
|
993
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
994
|
+
const fresh = (await this.raw(link)).find((item) => !before.has(item.deploymentId));
|
|
995
|
+
if (fresh) return fresh.deploymentId;
|
|
996
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollMs));
|
|
997
|
+
}
|
|
998
|
+
throw new Error(`Dokploy took the deploy but shows no deployment for it yet. See ${composeUrl(this.api, link)}`);
|
|
999
|
+
}
|
|
1000
|
+
/** How long `deploy` waits between looks for the new deployment; tests set it to 0. */
|
|
1001
|
+
pollMs = 1e3;
|
|
1002
|
+
/** The deployments, newest first. */
|
|
1003
|
+
async raw(link) {
|
|
1004
|
+
return [...await this.api.get("deployment.allByCompose", { composeId: link.application }) ?? []].sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
|
|
1005
|
+
}
|
|
1006
|
+
toDeployment(link, item) {
|
|
1007
|
+
const status = dokployDeploymentStatus(item.status);
|
|
1008
|
+
const url = composeUrl(this.api, link);
|
|
1009
|
+
return {
|
|
1010
|
+
id: item.deploymentId,
|
|
1011
|
+
status,
|
|
1012
|
+
message: item.title && item.title !== "dstack deploy" ? item.title : (item.description ?? void 0) || void 0,
|
|
1013
|
+
createdAt: item.createdAt,
|
|
1014
|
+
updatedAt: item.finishedAt ?? item.startedAt ?? void 0,
|
|
1015
|
+
url,
|
|
1016
|
+
log: [...item.errorMessage ? [{
|
|
1017
|
+
text: item.errorMessage,
|
|
1018
|
+
stderr: true
|
|
1019
|
+
}] : [], {
|
|
1020
|
+
text: `Dokploy shows the build log in its dashboard: ${url} (Deployments)`,
|
|
1021
|
+
stderr: false
|
|
1022
|
+
}]
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async deployment(link, id) {
|
|
1026
|
+
const item = (await this.raw(link)).find((deployment) => deployment.deploymentId === id);
|
|
1027
|
+
if (!item) throw new Error(`Dokploy has no deployment ${id} for this service.`);
|
|
1028
|
+
return this.toDeployment(link, item);
|
|
1029
|
+
}
|
|
1030
|
+
async deployments(link, limit) {
|
|
1031
|
+
return (await this.raw(link)).slice(0, limit).map((item) => ({
|
|
1032
|
+
...this.toDeployment(link, item),
|
|
1033
|
+
log: []
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
async status(link) {
|
|
1037
|
+
const compose = await this.api.get("compose.one", { composeId: link.application });
|
|
1038
|
+
const domains = (compose.domains ?? []).map((item) => `${item.https === false ? "http" : "https"}://${item.host}`);
|
|
1039
|
+
return {
|
|
1040
|
+
state: dokployAppState(compose.composeStatus),
|
|
1041
|
+
detail: compose.composeStatus ?? "unknown",
|
|
1042
|
+
domains,
|
|
1043
|
+
branch: compose.customGitBranch ?? void 0,
|
|
1044
|
+
url: composeUrl(this.api, link)
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const dokploy = {
|
|
1049
|
+
id: "dokploy",
|
|
1050
|
+
name: "Dokploy",
|
|
1051
|
+
auth: {
|
|
1052
|
+
kind: "token",
|
|
1053
|
+
env: "DSTACK_DOKPLOY_TOKEN",
|
|
1054
|
+
help: (url) => `Create one in Dokploy under Settings › Profile › API/CLI (${url}/dashboard/settings/profile), as a user who may create projects.`
|
|
1055
|
+
},
|
|
1056
|
+
source: "remote",
|
|
1057
|
+
target: urlTarget("Dokploy", "https://dokploy.example.com"),
|
|
1058
|
+
platformEnv: PLATFORM_ENV,
|
|
1059
|
+
setupFlags: SHARED_SETUP_FLAGS,
|
|
1060
|
+
connect: (url, token) => {
|
|
1061
|
+
if (!token) throw new Error("Dokploy needs an API key.");
|
|
1062
|
+
return new DokployClient(url, token);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
// src/deploy/credentials.ts
|
|
1066
|
+
/**
|
|
1067
|
+
* API keys of deploy providers, never in the project: the OS keychain
|
|
1068
|
+
* (macOS Keychain, Windows Credential Manager, the Secret Service on Linux),
|
|
1069
|
+
* or a file only the user can read where there is no keychain. An
|
|
1070
|
+
* environment variable such as DSTACK_COOLIFY_TOKEN overrides both, for CI.
|
|
1071
|
+
*/
|
|
1072
|
+
const SERVICE = "dstack";
|
|
1073
|
+
function systemKeyring() {
|
|
1074
|
+
try {
|
|
1075
|
+
const { Entry } = createRequire(import.meta.url)("@napi-rs/keyring");
|
|
1076
|
+
return {
|
|
1077
|
+
get: (account) => new Entry(SERVICE, account).getPassword() ?? void 0,
|
|
1078
|
+
set: (account, token) => new Entry(SERVICE, account).setPassword(token),
|
|
1079
|
+
delete: (account) => void new Entry(SERVICE, account).deletePassword()
|
|
1080
|
+
};
|
|
1081
|
+
} catch {
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/** The account a key is saved under: one per provider and instance. */
|
|
1086
|
+
function accountFor(provider, url) {
|
|
1087
|
+
return `${provider}:${url}`;
|
|
1088
|
+
}
|
|
1089
|
+
function credentialsFile() {
|
|
1090
|
+
const base = process.env.XDG_CONFIG_HOME || process.platform === "win32" && process.env.APPDATA || join(homedir(), ".config");
|
|
1091
|
+
return join(base, "dstack", "credentials.json");
|
|
1092
|
+
}
|
|
1093
|
+
function readFile() {
|
|
1094
|
+
const path = credentialsFile();
|
|
1095
|
+
if (!existsSync(path)) return {};
|
|
1096
|
+
try {
|
|
1097
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1098
|
+
} catch {
|
|
1099
|
+
return {};
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function writeFile(values) {
|
|
1103
|
+
const path = credentialsFile();
|
|
1104
|
+
mkdirSync(dirname(path), {
|
|
1105
|
+
recursive: true,
|
|
1106
|
+
mode: 448
|
|
1107
|
+
});
|
|
1108
|
+
writeFileSync(path, `${JSON.stringify(values, null, 2)}\n`, { mode: 384 });
|
|
1109
|
+
chmodSync(path, 384);
|
|
1110
|
+
}
|
|
1111
|
+
function getToken(provider, url, envName) {
|
|
1112
|
+
const fromEnv = process.env[envName];
|
|
1113
|
+
if (fromEnv) return {
|
|
1114
|
+
token: fromEnv,
|
|
1115
|
+
source: "env"
|
|
1116
|
+
};
|
|
1117
|
+
const account = accountFor(provider, url);
|
|
1118
|
+
const keyring = systemKeyring();
|
|
1119
|
+
if (keyring) try {
|
|
1120
|
+
const token = keyring.get(account);
|
|
1121
|
+
if (token) return {
|
|
1122
|
+
token,
|
|
1123
|
+
source: "keychain"
|
|
1124
|
+
};
|
|
1125
|
+
} catch {}
|
|
1126
|
+
const token = readFile()[account];
|
|
1127
|
+
return token ? {
|
|
1128
|
+
token,
|
|
1129
|
+
source: "file"
|
|
1130
|
+
} : void 0;
|
|
1131
|
+
}
|
|
1132
|
+
/** Saves a key in the keychain, or in the credentials file when there is none. */
|
|
1133
|
+
function saveToken(provider, url, token) {
|
|
1134
|
+
const account = accountFor(provider, url);
|
|
1135
|
+
const keyring = systemKeyring();
|
|
1136
|
+
if (keyring) try {
|
|
1137
|
+
keyring.set(account, token);
|
|
1138
|
+
removeFromFile(account);
|
|
1139
|
+
return "keychain";
|
|
1140
|
+
} catch {}
|
|
1141
|
+
writeFile({
|
|
1142
|
+
...readFile(),
|
|
1143
|
+
[account]: token
|
|
1144
|
+
});
|
|
1145
|
+
return "file";
|
|
1146
|
+
}
|
|
1147
|
+
function removeFromFile(account) {
|
|
1148
|
+
const values = readFile();
|
|
1149
|
+
if (!(account in values)) return false;
|
|
1150
|
+
delete values[account];
|
|
1151
|
+
writeFile(values);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
/** Removes a key from the keychain and the file; returns whether there was one. */
|
|
1155
|
+
function deleteToken(provider, url) {
|
|
1156
|
+
const account = accountFor(provider, url);
|
|
1157
|
+
let removed = false;
|
|
1158
|
+
const keyring = systemKeyring();
|
|
1159
|
+
if (keyring) try {
|
|
1160
|
+
if (keyring.get(account)) {
|
|
1161
|
+
keyring.delete(account);
|
|
1162
|
+
removed = true;
|
|
1163
|
+
}
|
|
1164
|
+
} catch {}
|
|
1165
|
+
return removeFromFile(account) || removed;
|
|
1166
|
+
}
|
|
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
|
+
// src/deploy/index.ts
|
|
1278
|
+
const DEFAULT_PROVIDER = coolify;
|
|
1279
|
+
/** Every deploy provider. */
|
|
1280
|
+
const PROVIDERS = [
|
|
1281
|
+
coolify,
|
|
1282
|
+
dokploy,
|
|
1283
|
+
ssh
|
|
1284
|
+
];
|
|
1285
|
+
function getProvider(id) {
|
|
1286
|
+
const provider = PROVIDERS.find((item) => item.id === id);
|
|
1287
|
+
if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
|
|
1288
|
+
return provider;
|
|
1289
|
+
}
|
|
1290
|
+
// src/project.ts
|
|
1291
|
+
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
1292
|
+
const RUN_ENV = ".env.run";
|
|
1293
|
+
const RUN_DOMAIN = "site.localhost";
|
|
1294
|
+
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
1295
|
+
function findProjectDirectory(start) {
|
|
1296
|
+
let directory = resolve(start);
|
|
1297
|
+
for (;;) {
|
|
1298
|
+
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
1299
|
+
const parent = dirname(directory);
|
|
1300
|
+
if (parent === directory) return void 0;
|
|
1301
|
+
directory = parent;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function parseProfiles(value) {
|
|
1305
|
+
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
1306
|
+
}
|
|
1307
|
+
function projectName(directory) {
|
|
1308
|
+
try {
|
|
1309
|
+
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
1310
|
+
if (match?.[1]) return match[1];
|
|
1311
|
+
} catch {}
|
|
1312
|
+
return basename(directory).toLowerCase();
|
|
1313
|
+
}
|
|
1314
|
+
function fromEnv(directory, name, envFile, localRun) {
|
|
1315
|
+
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
1316
|
+
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
1317
|
+
const prod = profiles.includes("prod");
|
|
1318
|
+
const suffix = prod ? "-prod" : "";
|
|
1319
|
+
return {
|
|
1320
|
+
directory,
|
|
1321
|
+
name: localRun ? `${name}-run` : name,
|
|
1322
|
+
env,
|
|
1323
|
+
profiles,
|
|
1324
|
+
prod,
|
|
1325
|
+
localRun,
|
|
1326
|
+
services: {
|
|
1327
|
+
backend: `backend${suffix}`,
|
|
1328
|
+
frontend: `frontend${suffix}`,
|
|
1329
|
+
worker: `worker${suffix}`
|
|
1330
|
+
},
|
|
1331
|
+
composeArgs: localRun ? [
|
|
1332
|
+
"--env-file",
|
|
1333
|
+
envFile,
|
|
1334
|
+
"--project-name",
|
|
1335
|
+
`${name}-run`
|
|
1336
|
+
] : [],
|
|
1337
|
+
composeEnv: localRun ? {
|
|
1338
|
+
ENV_FILE: envFile,
|
|
1339
|
+
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
1340
|
+
} : {}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
1344
|
+
function readProject(start = ".") {
|
|
1345
|
+
const directory = findProjectDirectory(start);
|
|
1346
|
+
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
1347
|
+
return fromEnv(directory, projectName(directory), ".env", false);
|
|
1348
|
+
}
|
|
1349
|
+
/** The local production run of a development project, when `.env.run` exists. */
|
|
1350
|
+
function readLocalRun(project) {
|
|
1351
|
+
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
1352
|
+
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
1353
|
+
}
|
|
1354
|
+
/** The `django-stack` section of the project's `package.json`. */
|
|
1355
|
+
function projectManifest(project) {
|
|
1356
|
+
try {
|
|
1357
|
+
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
1358
|
+
} catch {
|
|
1359
|
+
return {};
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
1364
|
+
* for projects from before that, the defaults with the storage from `.env`.
|
|
1365
|
+
*/
|
|
1366
|
+
function projectStack(project) {
|
|
1367
|
+
const { preset } = projectManifest(project);
|
|
1368
|
+
if (preset) try {
|
|
1369
|
+
return decodePreset(preset);
|
|
1370
|
+
} catch {}
|
|
1371
|
+
return {
|
|
1372
|
+
...DEFAULT_STACK,
|
|
1373
|
+
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
/** The URLs of a running project. */
|
|
1377
|
+
function projectUrls(project) {
|
|
1378
|
+
if (project.prod) {
|
|
1379
|
+
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
1380
|
+
return [{
|
|
1381
|
+
label: "Site",
|
|
1382
|
+
url: `${site}/`
|
|
1383
|
+
}, {
|
|
1384
|
+
label: "Admin",
|
|
1385
|
+
url: `${site}/admin/`
|
|
1386
|
+
}];
|
|
1387
|
+
}
|
|
1388
|
+
const urls = [
|
|
1389
|
+
{
|
|
1390
|
+
label: "Site",
|
|
1391
|
+
url: "http://localhost:3000"
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
label: "Admin",
|
|
1395
|
+
url: "http://localhost:3000/admin/"
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
label: "Mail",
|
|
1399
|
+
url: "http://localhost:8025"
|
|
1400
|
+
}
|
|
1401
|
+
];
|
|
1402
|
+
if (project.profiles.includes("s3")) urls.push({
|
|
1403
|
+
label: "S3",
|
|
1404
|
+
url: "http://localhost:8333"
|
|
1405
|
+
});
|
|
1406
|
+
return urls;
|
|
1407
|
+
}
|
|
1408
|
+
/** Runs a command with the terminal attached and returns its exit status. */
|
|
1409
|
+
const runInherit = (cmd, args, options) => {
|
|
1410
|
+
const result = spawnSync(cmd, args, {
|
|
1411
|
+
stdio: "inherit",
|
|
1412
|
+
...options
|
|
1413
|
+
});
|
|
1414
|
+
if (result.error) return 127;
|
|
1415
|
+
return result.status ?? 1;
|
|
1416
|
+
};
|
|
1417
|
+
/** The full `docker compose` argument list for a project. */
|
|
1418
|
+
function composeCommand(project, args) {
|
|
1419
|
+
return [
|
|
1420
|
+
"compose",
|
|
1421
|
+
...project.composeArgs,
|
|
1422
|
+
...args
|
|
1423
|
+
];
|
|
1424
|
+
}
|
|
1425
|
+
function composeOptions(project) {
|
|
1426
|
+
return {
|
|
1427
|
+
cwd: project.directory,
|
|
1428
|
+
env: {
|
|
1429
|
+
...process.env,
|
|
1430
|
+
...project.composeEnv
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
1435
|
+
function compose(project, args, run = runInherit) {
|
|
1436
|
+
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
1437
|
+
}
|
|
1438
|
+
/** Runs `docker compose <args>` and captures the output. */
|
|
1439
|
+
function composeOutput(project, args) {
|
|
1440
|
+
const result = spawnSync("docker", composeCommand(project, args), {
|
|
1441
|
+
...composeOptions(project),
|
|
1442
|
+
encoding: "utf8",
|
|
1443
|
+
stdio: [
|
|
1444
|
+
"ignore",
|
|
1445
|
+
"pipe",
|
|
1446
|
+
"pipe"
|
|
1447
|
+
]
|
|
1448
|
+
});
|
|
1449
|
+
return {
|
|
1450
|
+
status: result.status ?? 1,
|
|
1451
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
/** The running services of the project. */
|
|
1455
|
+
function runningServices(project) {
|
|
1456
|
+
const result = spawnSync("docker", composeCommand(project, [
|
|
1457
|
+
"ps",
|
|
1458
|
+
"--services",
|
|
1459
|
+
"--filter",
|
|
1460
|
+
"status=running"
|
|
1461
|
+
]), {
|
|
1462
|
+
...composeOptions(project),
|
|
1463
|
+
encoding: "utf8",
|
|
1464
|
+
stdio: [
|
|
1465
|
+
"ignore",
|
|
1466
|
+
"pipe",
|
|
1467
|
+
"ignore"
|
|
1468
|
+
]
|
|
1469
|
+
});
|
|
1470
|
+
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* The project a command acts on: the local production run when it is the
|
|
1474
|
+
* one running, otherwise the project `.env` describes.
|
|
1475
|
+
*/
|
|
1476
|
+
function activeProject(project) {
|
|
1477
|
+
const localRun = readLocalRun(project);
|
|
1478
|
+
if (!localRun) return project;
|
|
1479
|
+
if (runningServices(project).length) return project;
|
|
1480
|
+
return runningServices(localRun).length ? localRun : project;
|
|
1481
|
+
}
|
|
11
1482
|
// src/ui.ts
|
|
12
1483
|
/** The DjangoStack badge with the version and an optional title. */
|
|
13
1484
|
function intro(title) {
|
|
@@ -18,24 +1489,454 @@ function fail(message) {
|
|
|
18
1489
|
p.cancel(pc.red(message));
|
|
19
1490
|
process.exit(1);
|
|
20
1491
|
}
|
|
21
|
-
function unwrap(value) {
|
|
22
|
-
if (p.isCancel(value)) fail("Cancelled.");
|
|
23
|
-
return value;
|
|
1492
|
+
function unwrap(value) {
|
|
1493
|
+
if (p.isCancel(value)) fail("Cancelled.");
|
|
1494
|
+
return value;
|
|
1495
|
+
}
|
|
1496
|
+
function has(command, args = ["--version"]) {
|
|
1497
|
+
try {
|
|
1498
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
1499
|
+
return true;
|
|
1500
|
+
} catch {
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const url = (value) => pc.cyan(pc.underline(value));
|
|
1505
|
+
const command = (value) => pc.cyan(value);
|
|
1506
|
+
const secret = (value) => pc.bold(pc.yellow(value));
|
|
1507
|
+
/** Prompts only when stdin is a terminal and `--yes` was not passed. */
|
|
1508
|
+
function isInteractive(yes) {
|
|
1509
|
+
return !yes && Boolean(process.stdin.isTTY);
|
|
1510
|
+
}
|
|
1511
|
+
/** Logs a check, with its help lines when it did not pass. */
|
|
1512
|
+
function logCheck(check) {
|
|
1513
|
+
const head = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
|
|
1514
|
+
const help = !check.ok && check.help ? check.help.map((line) => pc.dim(line)) : [];
|
|
1515
|
+
const text = [check.ok ? head : check.warning ? pc.yellow(head) : pc.red(head), ...help].join("\n");
|
|
1516
|
+
if (check.ok) p.log.success(text);
|
|
1517
|
+
else if (check.warning) p.log.warn(text);
|
|
1518
|
+
else p.log.error(text);
|
|
1519
|
+
}
|
|
1520
|
+
// src/commands/deploy.ts
|
|
1521
|
+
const PRODUCTION_ENV = ".env.production";
|
|
1522
|
+
function projectOrFail(options) {
|
|
1523
|
+
try {
|
|
1524
|
+
return readProject(options.dir ?? ".");
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
fail(error.message);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
function linkOrFail(directory) {
|
|
1530
|
+
let link;
|
|
1531
|
+
try {
|
|
1532
|
+
link = readLink(directory);
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
fail(error.message);
|
|
1535
|
+
}
|
|
1536
|
+
if (!link) fail(`This project is not set up for deploys yet. Run ${command("dstack deploy setup")}.`);
|
|
1537
|
+
return link;
|
|
1538
|
+
}
|
|
1539
|
+
function providerOrFail(id) {
|
|
1540
|
+
try {
|
|
1541
|
+
return getProvider(id);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
fail(error.message);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
/** Runs a provider call; a failure ends the command with its one-line reason. */
|
|
1547
|
+
async function attempt(work, spinner) {
|
|
1548
|
+
try {
|
|
1549
|
+
return await work;
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
spinner?.error("Failed");
|
|
1552
|
+
fail(error.message);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/** The variables for the platform: .env.production plus what the platform needs. */
|
|
1556
|
+
function deployEnv(directory, provider) {
|
|
1557
|
+
let file = join(directory, PRODUCTION_ENV);
|
|
1558
|
+
if (!existsSync(file)) {
|
|
1559
|
+
if (!(parseEnv(readFileSync(join(directory, ".env"), "utf8")).COMPOSE_PROFILES ?? "").split(",").includes("prod")) fail(`${PRODUCTION_ENV} does not exist. Run ${command("dstack production --domain example.com")} first.`);
|
|
1560
|
+
file = join(directory, ".env");
|
|
1561
|
+
}
|
|
1562
|
+
return {
|
|
1563
|
+
...parseEnv(readFileSync(file, "utf8")),
|
|
1564
|
+
...provider.platformEnv
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/** The API key settings of a provider, or a one-line failure for one that uses SSH. */
|
|
1568
|
+
function tokenAuth(provider) {
|
|
1569
|
+
if (provider.auth.kind !== "token") fail(`${provider.name} uses your SSH keys and ~/.ssh/config; there is no API key to save.`);
|
|
1570
|
+
return provider.auth;
|
|
1571
|
+
}
|
|
1572
|
+
async function askToken(provider, instance) {
|
|
1573
|
+
p.log.info(tokenAuth(provider).help(instance));
|
|
1574
|
+
return unwrap(await p.password({
|
|
1575
|
+
message: `${provider.name} API key`,
|
|
1576
|
+
validate: (value) => value?.trim() ? void 0 : "Paste the API key."
|
|
1577
|
+
})).trim();
|
|
1578
|
+
}
|
|
1579
|
+
/** Checks a key and saves it; returns it. */
|
|
1580
|
+
async function login(provider, instance, token) {
|
|
1581
|
+
const spinner = p.spinner();
|
|
1582
|
+
spinner.start(`Checking the key with ${instance}`);
|
|
1583
|
+
const version = await attempt(provider.connect(instance, token).verify(), spinner);
|
|
1584
|
+
spinner.stop(`${provider.name} ${version} accepts the key`);
|
|
1585
|
+
if (saveToken(provider.id, instance, token) === "keychain") p.log.success("Saved the key in the system keychain.");
|
|
1586
|
+
else p.log.warn(`There is no system keychain here, so the key is in ~/.config/dstack/credentials.json, readable only by you. Set ${tokenAuth(provider).env} instead to keep it off disk.`);
|
|
1587
|
+
return token;
|
|
1588
|
+
}
|
|
1589
|
+
/** A client for a provider target: the saved key, or a new one when the CLI may ask. */
|
|
1590
|
+
async function connect(provider, instance, interactive) {
|
|
1591
|
+
if (provider.auth.kind === "ssh") return attempt(Promise.resolve().then(() => provider.connect(instance)));
|
|
1592
|
+
const saved = getToken(provider.id, instance, provider.auth.env);
|
|
1593
|
+
if (saved) return provider.connect(instance, saved.token);
|
|
1594
|
+
if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.auth.env}.`);
|
|
1595
|
+
const token = await login(provider, instance, await askToken(provider, instance));
|
|
1596
|
+
return provider.connect(instance, token);
|
|
1597
|
+
}
|
|
1598
|
+
function promptAsk(interactive) {
|
|
1599
|
+
return {
|
|
1600
|
+
interactive,
|
|
1601
|
+
async select(message, choices, initial) {
|
|
1602
|
+
if (!interactive) {
|
|
1603
|
+
if (initial !== void 0) return initial;
|
|
1604
|
+
fail(`${message}: pass it as a flag; see ${command("dstack deploy setup --help")}.`);
|
|
1605
|
+
}
|
|
1606
|
+
return unwrap(await p.select({
|
|
1607
|
+
message,
|
|
1608
|
+
initialValue: initial,
|
|
1609
|
+
options: choices.map((choice) => ({
|
|
1610
|
+
value: choice.value,
|
|
1611
|
+
label: choice.label,
|
|
1612
|
+
hint: choice.hint
|
|
1613
|
+
}))
|
|
1614
|
+
}));
|
|
1615
|
+
},
|
|
1616
|
+
async text(message, options = {}) {
|
|
1617
|
+
if (!interactive) {
|
|
1618
|
+
if (options.initial) return options.initial;
|
|
1619
|
+
fail(`${message}: pass it as a flag.`);
|
|
1620
|
+
}
|
|
1621
|
+
return unwrap(await p.text({
|
|
1622
|
+
message,
|
|
1623
|
+
placeholder: options.placeholder,
|
|
1624
|
+
initialValue: options.initial
|
|
1625
|
+
}));
|
|
1626
|
+
},
|
|
1627
|
+
async confirm(message, initial = true) {
|
|
1628
|
+
if (!interactive) return initial;
|
|
1629
|
+
return unwrap(await p.confirm({
|
|
1630
|
+
message,
|
|
1631
|
+
initialValue: initial
|
|
1632
|
+
}));
|
|
1633
|
+
},
|
|
1634
|
+
step: (message) => p.log.success(message)
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/** `dstack deploy setup`: create the application on the platform and link the project to it. */
|
|
1638
|
+
async function deploySetup(options) {
|
|
1639
|
+
const interactive = isInteractive(options.yes);
|
|
1640
|
+
const project = projectOrFail(options);
|
|
1641
|
+
intro("Deploy setup");
|
|
1642
|
+
const existing = existsSync(join(project.directory, ".dstack/deploy.json")) ? readLink(project.directory) : void 0;
|
|
1643
|
+
if (existing && !options.force) fail(`This project already deploys to ${existing.url} (${LINK_FILE}). Pass --force to set it up again.`);
|
|
1644
|
+
let providerId = options.provider;
|
|
1645
|
+
if (!providerId) providerId = PROVIDERS.length === 1 || !interactive ? DEFAULT_PROVIDER.id : unwrap(await p.select({
|
|
1646
|
+
message: "Platform",
|
|
1647
|
+
options: PROVIDERS.map((item) => ({
|
|
1648
|
+
value: item.id,
|
|
1649
|
+
label: item.name
|
|
1650
|
+
}))
|
|
1651
|
+
}));
|
|
1652
|
+
const provider = providerOrFail(providerId);
|
|
1653
|
+
let instance = options.url;
|
|
1654
|
+
if (!instance) {
|
|
1655
|
+
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1656
|
+
instance = unwrap(await p.text({
|
|
1657
|
+
message: provider.target.label,
|
|
1658
|
+
placeholder: provider.target.placeholder,
|
|
1659
|
+
validate: (value) => {
|
|
1660
|
+
try {
|
|
1661
|
+
provider.target.normalize(value ?? "");
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
return error.message;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}));
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
instance = provider.target.normalize(instance);
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
fail(error.message);
|
|
1672
|
+
}
|
|
1673
|
+
const env = deployEnv(project.directory, provider);
|
|
1674
|
+
const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
1675
|
+
if (!domain || domain.endsWith("localhost")) fail(`SITE_URL in ${PRODUCTION_ENV} is not a public domain. Run ${command("dstack production --domain example.com --force")}.`);
|
|
1676
|
+
let repository = options.repository;
|
|
1677
|
+
if (provider.source === "remote") {
|
|
1678
|
+
if (!repository) {
|
|
1679
|
+
const remote = gitRemote(project.directory);
|
|
1680
|
+
if (!remote) fail("This project has no git remote origin. Push it to GitHub first, or pass --repository.");
|
|
1681
|
+
repository = remote;
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
repository = httpsRepository(repository);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
fail(error.message);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const branch = options.branch ?? gitBranch(project.directory) ?? "main";
|
|
1690
|
+
const client = await connect(provider, instance, interactive);
|
|
1691
|
+
await attempt(client.verify());
|
|
1692
|
+
const flags = {};
|
|
1693
|
+
for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
|
|
1694
|
+
const ids = await attempt(client.setup({
|
|
1695
|
+
ask: promptAsk(interactive),
|
|
1696
|
+
name: project.name,
|
|
1697
|
+
repository,
|
|
1698
|
+
branch,
|
|
1699
|
+
domain,
|
|
1700
|
+
env,
|
|
1701
|
+
options: flags
|
|
1702
|
+
}));
|
|
1703
|
+
const link = {
|
|
1704
|
+
provider: provider.id,
|
|
1705
|
+
url: instance,
|
|
1706
|
+
...ids
|
|
1707
|
+
};
|
|
1708
|
+
writeLink(project.directory, link);
|
|
1709
|
+
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.`, 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
|
+
if (options.deploy ?? (interactive ? unwrap(await p.confirm({
|
|
1712
|
+
message: "Deploy now?",
|
|
1713
|
+
initialValue: true
|
|
1714
|
+
})) : false)) {
|
|
1715
|
+
await startDeploy(project.directory, provider, link, client, {
|
|
1716
|
+
force: false,
|
|
1717
|
+
wait: true
|
|
1718
|
+
});
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
p.outro(`Deploy with ${command("dstack deploy")}`);
|
|
1722
|
+
}
|
|
1723
|
+
const STATUS_LABEL = {
|
|
1724
|
+
queued: pc.dim("queued"),
|
|
1725
|
+
building: pc.yellow("building"),
|
|
1726
|
+
finished: pc.green("finished"),
|
|
1727
|
+
failed: pc.red("failed"),
|
|
1728
|
+
cancelled: pc.dim("cancelled")
|
|
1729
|
+
};
|
|
1730
|
+
const DONE = /* @__PURE__ */ new Set([
|
|
1731
|
+
"finished",
|
|
1732
|
+
"failed",
|
|
1733
|
+
"cancelled"
|
|
1734
|
+
]);
|
|
1735
|
+
const POLL_MS = 3e3;
|
|
1736
|
+
const WAIT_LIMIT_MS = 27e5;
|
|
1737
|
+
function printLog(deployment, lines) {
|
|
1738
|
+
const log = lines ? deployment.log.slice(-lines) : deployment.log;
|
|
1739
|
+
for (const line of log) process.stdout.write(`${line.stderr ? pc.red(line.text) : line.text}\n`);
|
|
1740
|
+
}
|
|
1741
|
+
async function follow(link, client, id) {
|
|
1742
|
+
const spinner = p.spinner();
|
|
1743
|
+
spinner.start("Queued");
|
|
1744
|
+
const started = Date.now();
|
|
1745
|
+
let deployment;
|
|
1746
|
+
for (;;) {
|
|
1747
|
+
deployment = await attempt(client.deployment(link, id), spinner);
|
|
1748
|
+
if (DONE.has(deployment.status)) break;
|
|
1749
|
+
const last = deployment.log.at(-1)?.text.trim().slice(0, 70);
|
|
1750
|
+
const seconds = Math.round((Date.now() - started) / 1e3);
|
|
1751
|
+
spinner.message(`${deployment.status === "queued" ? "Queued" : "Building"} ${pc.dim(`${seconds}s`)}${last ? pc.dim(` ${last}`) : ""}`);
|
|
1752
|
+
if (Date.now() - started > WAIT_LIMIT_MS) {
|
|
1753
|
+
spinner.stop("Still running");
|
|
1754
|
+
fail(`The deploy takes longer than 45 minutes. Follow it with ${command("dstack deploy status")}.`);
|
|
1755
|
+
}
|
|
1756
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
1757
|
+
}
|
|
1758
|
+
const seconds = Math.round((Date.now() - started) / 1e3);
|
|
1759
|
+
const summary = `${STATUS_LABEL[deployment.status]} after ${seconds}s`;
|
|
1760
|
+
if (deployment.status === "finished") spinner.stop(summary);
|
|
1761
|
+
else spinner.error(summary);
|
|
1762
|
+
return deployment;
|
|
1763
|
+
}
|
|
1764
|
+
/** Why the deploy would not include the local work, or undefined. */
|
|
1765
|
+
async function notDeployedReason(directory, provider, link, client) {
|
|
1766
|
+
if (provider.source === "local") return uncommittedReason(directory);
|
|
1767
|
+
const status = await attempt(client.status(link));
|
|
1768
|
+
return status.branch ? unpushedReason(directory, status.branch) : void 0;
|
|
1769
|
+
}
|
|
1770
|
+
async function startDeploy(directory, provider, link, client, options) {
|
|
1771
|
+
if (options.pushCheck !== false) {
|
|
1772
|
+
const reason = await notDeployedReason(directory, provider, link, client);
|
|
1773
|
+
if (reason) fail(`${reason} Or pass --no-push-check.`);
|
|
1774
|
+
}
|
|
1775
|
+
await finishDeploy(link, client, await attempt(client.deploy(link, {
|
|
1776
|
+
force: options.force,
|
|
1777
|
+
directory
|
|
1778
|
+
})), options.wait);
|
|
1779
|
+
}
|
|
1780
|
+
/** Follows a started deploy, or reports where it runs when not waiting. */
|
|
1781
|
+
async function finishDeploy(link, client, id, wait) {
|
|
1782
|
+
if (!wait) {
|
|
1783
|
+
const deployment = await attempt(client.deployment(link, id));
|
|
1784
|
+
p.outro(`Started deploy ${pc.bold(id)}${deployment.url ? `: ${url(deployment.url)}` : ""}. Follow it with ${command("dstack deploy status --watch")}`);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
p.log.message(pc.dim("Ctrl+C stops following; the deploy goes on."));
|
|
1788
|
+
const deployment = await follow(link, client, id);
|
|
1789
|
+
if (deployment.status !== "finished") {
|
|
1790
|
+
printLog(deployment, 40);
|
|
1791
|
+
fail(`The deploy ${deployment.status === "cancelled" ? "was cancelled" : "failed"}. Full log: ${command(`dstack deploy logs ${id}`)}`);
|
|
1792
|
+
}
|
|
1793
|
+
const status = await attempt(client.status(link));
|
|
1794
|
+
p.outro(`Deployed${status.domains.length ? `: ${status.domains.map(url).join(" ")}` : ""}`);
|
|
1795
|
+
}
|
|
1796
|
+
/** `dstack deploy`: start a deploy and follow it. */
|
|
1797
|
+
async function deploy(options) {
|
|
1798
|
+
const project = projectOrFail(options);
|
|
1799
|
+
const link = linkOrFail(project.directory);
|
|
1800
|
+
const provider = providerOrFail(link.provider);
|
|
1801
|
+
intro(`Deploy to ${provider.name}`);
|
|
1802
|
+
const client = await connect(provider, link.url, isInteractive(options.yes));
|
|
1803
|
+
await startDeploy(project.directory, provider, link, client, {
|
|
1804
|
+
force: Boolean(options.force),
|
|
1805
|
+
wait: options.wait,
|
|
1806
|
+
pushCheck: options.pushCheck
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
function ago(date) {
|
|
1810
|
+
if (!date) return "";
|
|
1811
|
+
const seconds = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 1e3));
|
|
1812
|
+
if (Number.isNaN(seconds)) return "";
|
|
1813
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
1814
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
|
|
1815
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)} h ago`;
|
|
1816
|
+
return `${Math.round(seconds / 86400)} d ago`;
|
|
1817
|
+
}
|
|
1818
|
+
const STATE_LABEL = {
|
|
1819
|
+
running: pc.green("running"),
|
|
1820
|
+
degraded: pc.yellow("degraded"),
|
|
1821
|
+
stopped: pc.red("stopped"),
|
|
1822
|
+
unknown: pc.dim("unknown")
|
|
1823
|
+
};
|
|
1824
|
+
/** `dstack deploy status`: the application's state and its last deploys. */
|
|
1825
|
+
async function deployStatus(options) {
|
|
1826
|
+
const project = projectOrFail(options);
|
|
1827
|
+
const link = linkOrFail(project.directory);
|
|
1828
|
+
const provider = providerOrFail(link.provider);
|
|
1829
|
+
intro(`${provider.name} status`);
|
|
1830
|
+
const client = await connect(provider, link.url, isInteractive(false));
|
|
1831
|
+
const limit = Math.max(1, Number.parseInt(options.limit, 10) || 5);
|
|
1832
|
+
const [status, deployments] = await Promise.all([attempt(client.status(link)), attempt(client.deployments(link, limit))]);
|
|
1833
|
+
p.note([
|
|
1834
|
+
`${pc.bold("State")} ${STATE_LABEL[status.state]} ${pc.dim(`(${status.detail})`)}`,
|
|
1835
|
+
...status.domains.length ? [`${pc.bold("Site")} ${status.domains.map(url).join(" ")}`] : [],
|
|
1836
|
+
...status.branch ? [`${pc.bold("Branch")} ${status.branch}`] : [],
|
|
1837
|
+
...status.url ? [`${pc.bold(provider.name.padEnd(7))} ${url(status.url)}`] : []
|
|
1838
|
+
].join("\n"), project.name);
|
|
1839
|
+
if (deployments.length) p.log.message(deployments.map((item) => [
|
|
1840
|
+
STATUS_LABEL[item.status].padEnd(18),
|
|
1841
|
+
pc.dim(ago(item.createdAt).padEnd(11)),
|
|
1842
|
+
item.commit ? pc.cyan(item.commit) : pc.dim("-------"),
|
|
1843
|
+
item.message ?? ""
|
|
1844
|
+
].join(" ")).join("\n"), { symbol: pc.dim("│") });
|
|
1845
|
+
else p.log.message(pc.dim("No deploys yet."));
|
|
1846
|
+
const running = deployments.find((item) => !DONE.has(item.status));
|
|
1847
|
+
if (running && options.watch) {
|
|
1848
|
+
const deployment = await follow(link, client, running.id);
|
|
1849
|
+
if (deployment.status !== "finished") {
|
|
1850
|
+
printLog(deployment, 40);
|
|
1851
|
+
fail(`The deploy failed. Full log: ${command(`dstack deploy logs ${running.id}`)}`);
|
|
1852
|
+
}
|
|
1853
|
+
} else if (running) {
|
|
1854
|
+
p.outro(`A deploy is ${running.status}. Follow it with ${command("dstack deploy status --watch")}`);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
p.outro(pc.green("Done"));
|
|
1858
|
+
}
|
|
1859
|
+
/** `dstack deploy logs [deployment]`: the build log of a deploy, the latest by default. */
|
|
1860
|
+
async function deployLogs(id, options) {
|
|
1861
|
+
const link = linkOrFail(projectOrFail(options).directory);
|
|
1862
|
+
const client = await connect(providerOrFail(link.provider), link.url, isInteractive(false));
|
|
1863
|
+
let deploymentId = id;
|
|
1864
|
+
if (!deploymentId) {
|
|
1865
|
+
const [latest] = await attempt(client.deployments(link, 1));
|
|
1866
|
+
if (!latest) fail("There are no deploys yet.");
|
|
1867
|
+
deploymentId = latest.id;
|
|
1868
|
+
}
|
|
1869
|
+
const deployment = await attempt(client.deployment(link, deploymentId));
|
|
1870
|
+
printLog(deployment);
|
|
1871
|
+
process.stderr.write(pc.dim(`Deploy ${deployment.id}: ${deployment.status}\n`));
|
|
1872
|
+
if (deployment.status === "failed") process.exitCode = 1;
|
|
1873
|
+
}
|
|
1874
|
+
/** `dstack deploy rollback [deployment]`: run an earlier release again. */
|
|
1875
|
+
async function deployRollback(id, options) {
|
|
1876
|
+
const link = linkOrFail(projectOrFail(options).directory);
|
|
1877
|
+
const provider = providerOrFail(link.provider);
|
|
1878
|
+
intro(`Roll back on ${provider.name}`);
|
|
1879
|
+
const client = await connect(provider, link.url, isInteractive(options.yes));
|
|
1880
|
+
if (!client.rollback) fail(`${provider.name} keeps no releases to roll back to. Deploy an earlier commit, or roll back in ${provider.name}.`);
|
|
1881
|
+
const rollbackId = await attempt(client.rollback(link, id));
|
|
1882
|
+
p.log.info(pc.dim("The database is not rolled back; migrations of the newer release stay applied."));
|
|
1883
|
+
await finishDeploy(link, client, rollbackId, options.wait);
|
|
1884
|
+
}
|
|
1885
|
+
/** `dstack deploy env push`: upload .env.production to the platform again. */
|
|
1886
|
+
async function deployEnvPush(options) {
|
|
1887
|
+
const project = projectOrFail(options);
|
|
1888
|
+
const link = linkOrFail(project.directory);
|
|
1889
|
+
const provider = providerOrFail(link.provider);
|
|
1890
|
+
intro("Push variables");
|
|
1891
|
+
const interactive = isInteractive(options.yes);
|
|
1892
|
+
const env = deployEnv(project.directory, provider);
|
|
1893
|
+
if (interactive && !unwrap(await p.confirm({ message: `Replace ${Object.keys(env).length} variables in ${provider.name} with the values from ${PRODUCTION_ENV}?` }))) fail("Cancelled.");
|
|
1894
|
+
await attempt((await connect(provider, link.url, interactive)).pushEnv(link, env));
|
|
1895
|
+
p.outro(`Set ${Object.keys(env).length} variables. They apply on the next ${command("dstack deploy")}`);
|
|
24
1896
|
}
|
|
25
|
-
function
|
|
1897
|
+
async function loginTarget(options, interactive) {
|
|
1898
|
+
let link;
|
|
26
1899
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1900
|
+
link = readLink(readProject(options.dir ?? ".").directory);
|
|
1901
|
+
} catch {}
|
|
1902
|
+
const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
|
|
1903
|
+
tokenAuth(provider);
|
|
1904
|
+
let instance = options.url ?? link?.url;
|
|
1905
|
+
if (!instance) {
|
|
1906
|
+
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1907
|
+
instance = unwrap(await p.text({
|
|
1908
|
+
message: provider.target.label,
|
|
1909
|
+
placeholder: provider.target.placeholder
|
|
1910
|
+
}));
|
|
1911
|
+
}
|
|
1912
|
+
try {
|
|
1913
|
+
return {
|
|
1914
|
+
provider,
|
|
1915
|
+
instance: provider.target.normalize(instance)
|
|
1916
|
+
};
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
fail(error.message);
|
|
31
1919
|
}
|
|
32
1920
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
1921
|
+
/** `dstack deploy login`: check and save an API key. */
|
|
1922
|
+
async function deployLogin(options) {
|
|
1923
|
+
const interactive = isInteractive(options.yes);
|
|
1924
|
+
intro("Deploy login");
|
|
1925
|
+
const { provider, instance } = await loginTarget(options, interactive);
|
|
1926
|
+
const auth = tokenAuth(provider);
|
|
1927
|
+
let token = options.token ?? (options.yes ? process.env[auth.env] : void 0);
|
|
1928
|
+
if (!token) {
|
|
1929
|
+
if (!interactive) fail(`Pass --token or set ${auth.env}.`);
|
|
1930
|
+
token = await askToken(provider, instance);
|
|
1931
|
+
}
|
|
1932
|
+
await login(provider, instance, token);
|
|
1933
|
+
p.outro(pc.green("Done"));
|
|
1934
|
+
}
|
|
1935
|
+
/** `dstack deploy logout`: remove a saved API key. */
|
|
1936
|
+
async function deployLogout(options) {
|
|
1937
|
+
const { provider, instance } = await loginTarget(options, isInteractive(false));
|
|
1938
|
+
if (deleteToken(provider.id, instance)) p.log.success(`Removed the ${provider.name} key for ${instance}.`);
|
|
1939
|
+
else p.log.info(`There was no saved ${provider.name} key for ${instance}.`);
|
|
39
1940
|
}
|
|
40
1941
|
// src/commands/doctor.ts
|
|
41
1942
|
async function doctor(directoryArgument) {
|
|
@@ -45,12 +1946,7 @@ async function doctor(directoryArgument) {
|
|
|
45
1946
|
spinner.start("Checking the project and its services");
|
|
46
1947
|
const checks = await runDoctor(directory);
|
|
47
1948
|
spinner.clear();
|
|
48
|
-
for (const check of checks)
|
|
49
|
-
const line = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
|
|
50
|
-
if (check.ok) p.log.success(line);
|
|
51
|
-
else if (check.warning) p.log.warn(pc.yellow(line));
|
|
52
|
-
else p.log.error(pc.red(line));
|
|
53
|
-
}
|
|
1949
|
+
for (const check of checks) logCheck(check);
|
|
54
1950
|
const failed = checks.filter((check) => !check.ok && !check.warning).length;
|
|
55
1951
|
if (failed) {
|
|
56
1952
|
p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
|
|
@@ -141,7 +2037,7 @@ function updateCommand(location = import.meta.url, env = process.env) {
|
|
|
141
2037
|
if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
142
2038
|
if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
|
|
143
2039
|
if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
144
|
-
return `npm
|
|
2040
|
+
return `npm i -g ${PACKAGE_NAME}@latest`;
|
|
145
2041
|
}
|
|
146
2042
|
// src/global-install.ts
|
|
147
2043
|
/** The name of the installed command. */
|
|
@@ -187,8 +2083,8 @@ function installCommand(runner = "npm") {
|
|
|
187
2083
|
];
|
|
188
2084
|
return [
|
|
189
2085
|
"npm",
|
|
190
|
-
"
|
|
191
|
-
"
|
|
2086
|
+
"i",
|
|
2087
|
+
"-g",
|
|
192
2088
|
`${PACKAGE_NAME}@latest`
|
|
193
2089
|
];
|
|
194
2090
|
}
|
|
@@ -363,9 +2259,24 @@ function systemTimeZone() {
|
|
|
363
2259
|
return "UTC";
|
|
364
2260
|
}
|
|
365
2261
|
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Checks Docker, Node.js and Python before the prompts. Problems only warn:
|
|
2264
|
+
* the project is created anyway, and Node.js and Python are for development
|
|
2265
|
+
* outside the containers. Returns whether Docker can run the project.
|
|
2266
|
+
*/
|
|
2267
|
+
function checkComputer(checks = checkTools()) {
|
|
2268
|
+
const passed = checks.filter((check) => check.ok);
|
|
2269
|
+
if (passed.length) p.log.success(passed.map((check) => check.detail ?? check.name).join(", "));
|
|
2270
|
+
for (const check of checks.filter((check) => !check.ok)) logCheck({
|
|
2271
|
+
...check,
|
|
2272
|
+
warning: true
|
|
2273
|
+
});
|
|
2274
|
+
return !checks.some((check) => !check.ok && !check.warning);
|
|
2275
|
+
}
|
|
366
2276
|
async function init(directoryArgument, options) {
|
|
367
2277
|
const interactive = isInteractive(options.yes);
|
|
368
2278
|
intro("Create a project");
|
|
2279
|
+
const dockerReady = checkComputer();
|
|
369
2280
|
const zoneError = options.timeZone ? validateTimeZone(options.timeZone) : void 0;
|
|
370
2281
|
if (zoneError) fail(zoneError);
|
|
371
2282
|
let directory = directoryArgument;
|
|
@@ -475,7 +2386,6 @@ async function init(directoryArgument, options) {
|
|
|
475
2386
|
} catch {
|
|
476
2387
|
p.log.warn("Could not commit the project with git. Commit it yourself.");
|
|
477
2388
|
}
|
|
478
|
-
if (!has("docker", ["compose", "version"])) p.log.warn(`Docker Compose was not found. Install Docker to run the project: ${url("https://docs.docker.com/get-docker/")}`);
|
|
479
2389
|
p.note([
|
|
480
2390
|
command(`cd ${relativeDirectory(target)}`),
|
|
481
2391
|
command("docker compose up"),
|
|
@@ -489,7 +2399,8 @@ async function init(directoryArgument, options) {
|
|
|
489
2399
|
].join("\n"), "Next steps");
|
|
490
2400
|
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(", ")}.`);
|
|
491
2401
|
p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
|
|
492
|
-
if (options.start) {
|
|
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("docker compose up")}.`));
|
|
2403
|
+
else if (options.start) {
|
|
493
2404
|
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
494
2405
|
p.outro(`Starting the project with ${command("docker compose up")}`);
|
|
495
2406
|
execFileSync("docker", ["compose", "up"], {
|
|
@@ -605,198 +2516,6 @@ function setManifestPreset(manifestText, preset) {
|
|
|
605
2516
|
};
|
|
606
2517
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
607
2518
|
}
|
|
608
|
-
// src/project.ts
|
|
609
|
-
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
610
|
-
const RUN_ENV = ".env.run";
|
|
611
|
-
const RUN_DOMAIN = "site.localhost";
|
|
612
|
-
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
613
|
-
function findProjectDirectory(start) {
|
|
614
|
-
let directory = resolve(start);
|
|
615
|
-
for (;;) {
|
|
616
|
-
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
617
|
-
const parent = dirname(directory);
|
|
618
|
-
if (parent === directory) return void 0;
|
|
619
|
-
directory = parent;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
function parseProfiles(value) {
|
|
623
|
-
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
624
|
-
}
|
|
625
|
-
function projectName(directory) {
|
|
626
|
-
try {
|
|
627
|
-
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
628
|
-
if (match?.[1]) return match[1];
|
|
629
|
-
} catch {}
|
|
630
|
-
return basename(directory).toLowerCase();
|
|
631
|
-
}
|
|
632
|
-
function fromEnv(directory, name, envFile, localRun) {
|
|
633
|
-
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
634
|
-
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
635
|
-
const prod = profiles.includes("prod");
|
|
636
|
-
const suffix = prod ? "-prod" : "";
|
|
637
|
-
return {
|
|
638
|
-
directory,
|
|
639
|
-
name: localRun ? `${name}-run` : name,
|
|
640
|
-
env,
|
|
641
|
-
profiles,
|
|
642
|
-
prod,
|
|
643
|
-
localRun,
|
|
644
|
-
services: {
|
|
645
|
-
backend: `backend${suffix}`,
|
|
646
|
-
frontend: `frontend${suffix}`,
|
|
647
|
-
worker: `worker${suffix}`
|
|
648
|
-
},
|
|
649
|
-
composeArgs: localRun ? [
|
|
650
|
-
"--env-file",
|
|
651
|
-
envFile,
|
|
652
|
-
"--project-name",
|
|
653
|
-
`${name}-run`
|
|
654
|
-
] : [],
|
|
655
|
-
composeEnv: localRun ? {
|
|
656
|
-
ENV_FILE: envFile,
|
|
657
|
-
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
658
|
-
} : {}
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
662
|
-
function readProject(start = ".") {
|
|
663
|
-
const directory = findProjectDirectory(start);
|
|
664
|
-
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
665
|
-
return fromEnv(directory, projectName(directory), ".env", false);
|
|
666
|
-
}
|
|
667
|
-
/** The local production run of a development project, when `.env.run` exists. */
|
|
668
|
-
function readLocalRun(project) {
|
|
669
|
-
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
670
|
-
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
671
|
-
}
|
|
672
|
-
/** The `django-stack` section of the project's `package.json`. */
|
|
673
|
-
function projectManifest(project) {
|
|
674
|
-
try {
|
|
675
|
-
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
676
|
-
} catch {
|
|
677
|
-
return {};
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
/**
|
|
681
|
-
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
682
|
-
* for projects from before that, the defaults with the storage from `.env`.
|
|
683
|
-
*/
|
|
684
|
-
function projectStack(project) {
|
|
685
|
-
const { preset } = projectManifest(project);
|
|
686
|
-
if (preset) try {
|
|
687
|
-
return decodePreset(preset);
|
|
688
|
-
} catch {}
|
|
689
|
-
return {
|
|
690
|
-
...DEFAULT_STACK,
|
|
691
|
-
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
/** The URLs of a running project. */
|
|
695
|
-
function projectUrls(project) {
|
|
696
|
-
if (project.prod) {
|
|
697
|
-
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
698
|
-
return [{
|
|
699
|
-
label: "Site",
|
|
700
|
-
url: `${site}/`
|
|
701
|
-
}, {
|
|
702
|
-
label: "Admin",
|
|
703
|
-
url: `${site}/admin/`
|
|
704
|
-
}];
|
|
705
|
-
}
|
|
706
|
-
const urls = [
|
|
707
|
-
{
|
|
708
|
-
label: "Site",
|
|
709
|
-
url: "http://localhost:3000"
|
|
710
|
-
},
|
|
711
|
-
{
|
|
712
|
-
label: "Admin",
|
|
713
|
-
url: "http://localhost:3000/admin/"
|
|
714
|
-
},
|
|
715
|
-
{
|
|
716
|
-
label: "Mail",
|
|
717
|
-
url: "http://localhost:8025"
|
|
718
|
-
}
|
|
719
|
-
];
|
|
720
|
-
if (project.profiles.includes("s3")) urls.push({
|
|
721
|
-
label: "S3",
|
|
722
|
-
url: "http://localhost:8333"
|
|
723
|
-
});
|
|
724
|
-
return urls;
|
|
725
|
-
}
|
|
726
|
-
/** Runs a command with the terminal attached and returns its exit status. */
|
|
727
|
-
const runInherit = (cmd, args, options) => {
|
|
728
|
-
const result = spawnSync(cmd, args, {
|
|
729
|
-
stdio: "inherit",
|
|
730
|
-
...options
|
|
731
|
-
});
|
|
732
|
-
if (result.error) return 127;
|
|
733
|
-
return result.status ?? 1;
|
|
734
|
-
};
|
|
735
|
-
/** The full `docker compose` argument list for a project. */
|
|
736
|
-
function composeCommand(project, args) {
|
|
737
|
-
return [
|
|
738
|
-
"compose",
|
|
739
|
-
...project.composeArgs,
|
|
740
|
-
...args
|
|
741
|
-
];
|
|
742
|
-
}
|
|
743
|
-
function composeOptions(project) {
|
|
744
|
-
return {
|
|
745
|
-
cwd: project.directory,
|
|
746
|
-
env: {
|
|
747
|
-
...process.env,
|
|
748
|
-
...project.composeEnv
|
|
749
|
-
}
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
753
|
-
function compose(project, args, run = runInherit) {
|
|
754
|
-
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
755
|
-
}
|
|
756
|
-
/** Runs `docker compose <args>` and captures the output. */
|
|
757
|
-
function composeOutput(project, args) {
|
|
758
|
-
const result = spawnSync("docker", composeCommand(project, args), {
|
|
759
|
-
...composeOptions(project),
|
|
760
|
-
encoding: "utf8",
|
|
761
|
-
stdio: [
|
|
762
|
-
"ignore",
|
|
763
|
-
"pipe",
|
|
764
|
-
"pipe"
|
|
765
|
-
]
|
|
766
|
-
});
|
|
767
|
-
return {
|
|
768
|
-
status: result.status ?? 1,
|
|
769
|
-
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
770
|
-
};
|
|
771
|
-
}
|
|
772
|
-
/** The running services of the project. */
|
|
773
|
-
function runningServices(project) {
|
|
774
|
-
const result = spawnSync("docker", composeCommand(project, [
|
|
775
|
-
"ps",
|
|
776
|
-
"--services",
|
|
777
|
-
"--filter",
|
|
778
|
-
"status=running"
|
|
779
|
-
]), {
|
|
780
|
-
...composeOptions(project),
|
|
781
|
-
encoding: "utf8",
|
|
782
|
-
stdio: [
|
|
783
|
-
"ignore",
|
|
784
|
-
"pipe",
|
|
785
|
-
"ignore"
|
|
786
|
-
]
|
|
787
|
-
});
|
|
788
|
-
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
789
|
-
}
|
|
790
|
-
/**
|
|
791
|
-
* The project a command acts on: the local production run when it is the
|
|
792
|
-
* one running, otherwise the project `.env` describes.
|
|
793
|
-
*/
|
|
794
|
-
function activeProject(project) {
|
|
795
|
-
const localRun = readLocalRun(project);
|
|
796
|
-
if (!localRun) return project;
|
|
797
|
-
if (runningServices(project).length) return project;
|
|
798
|
-
return runningServices(localRun).length ? localRun : project;
|
|
799
|
-
}
|
|
800
2519
|
// src/commands/project.ts
|
|
801
2520
|
function open(options) {
|
|
802
2521
|
if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
|
|
@@ -901,42 +2620,66 @@ async function manage(args, options) {
|
|
|
901
2620
|
async function shell(options) {
|
|
902
2621
|
return manage(["shell"], options);
|
|
903
2622
|
}
|
|
904
|
-
/**
|
|
2623
|
+
/**
|
|
2624
|
+
* The project and the service that run the backups: the prod worker, on a
|
|
2625
|
+
* server or the local run, or the `backup` service of projects from 0.1.
|
|
2626
|
+
*/
|
|
905
2627
|
function backupProject(options) {
|
|
906
2628
|
const project = activeProject(open(options));
|
|
907
2629
|
if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
|
|
908
|
-
|
|
909
|
-
|
|
2630
|
+
const running = runningServices(project);
|
|
2631
|
+
const service = ["backup", project.services.worker].find((name) => running.includes(name));
|
|
2632
|
+
if (!service) fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
|
|
2633
|
+
return {
|
|
2634
|
+
project,
|
|
2635
|
+
service
|
|
2636
|
+
};
|
|
910
2637
|
}
|
|
911
|
-
/** `dstack backup`: back up the database now, or list the backups. */
|
|
2638
|
+
/** `dstack backup`: back up the database and the media now, or list the backups. */
|
|
912
2639
|
async function backup(options) {
|
|
913
|
-
|
|
2640
|
+
const { project, service } = backupProject(options);
|
|
2641
|
+
const args = [
|
|
2642
|
+
"python",
|
|
2643
|
+
"manage.py",
|
|
2644
|
+
"backup_database"
|
|
2645
|
+
];
|
|
2646
|
+
if (options.list) args.push("--list");
|
|
2647
|
+
else if (!options.media) args.push("--no-media");
|
|
2648
|
+
exitWith(compose(project, [
|
|
914
2649
|
"exec",
|
|
915
2650
|
"-T",
|
|
916
|
-
|
|
917
|
-
...
|
|
918
|
-
"python",
|
|
919
|
-
"manage.py",
|
|
920
|
-
"backup_database",
|
|
921
|
-
...options.list ? ["--list"] : []
|
|
922
|
-
]
|
|
2651
|
+
service,
|
|
2652
|
+
...args
|
|
923
2653
|
]));
|
|
924
2654
|
}
|
|
925
|
-
/**
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
2655
|
+
/** The management command and its options for `dstack restore`. */
|
|
2656
|
+
function restoreArgs(name, options) {
|
|
2657
|
+
const yes = options.yes ? ["--yes"] : [];
|
|
2658
|
+
if (name.endsWith(".media.tar.gz")) return [
|
|
2659
|
+
"python",
|
|
2660
|
+
"manage.py",
|
|
2661
|
+
"restore_media",
|
|
2662
|
+
name,
|
|
2663
|
+
...yes
|
|
2664
|
+
];
|
|
2665
|
+
return [
|
|
929
2666
|
"python",
|
|
930
2667
|
"manage.py",
|
|
931
2668
|
"restore_database",
|
|
932
2669
|
name,
|
|
933
|
-
...options.
|
|
2670
|
+
...options.media ? ["--media"] : [],
|
|
2671
|
+
...yes
|
|
934
2672
|
];
|
|
2673
|
+
}
|
|
2674
|
+
/** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
|
|
2675
|
+
async function restore(name, options) {
|
|
2676
|
+
const { project, service } = backupProject(options);
|
|
2677
|
+
const args = restoreArgs(name, options);
|
|
935
2678
|
if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
|
|
936
2679
|
exitWith(compose(project, [
|
|
937
2680
|
"exec",
|
|
938
2681
|
...options.yes ? ["-T"] : [],
|
|
939
|
-
|
|
2682
|
+
service,
|
|
940
2683
|
...args
|
|
941
2684
|
]));
|
|
942
2685
|
}
|
|
@@ -1056,13 +2799,8 @@ async function check(options) {
|
|
|
1056
2799
|
const doctorChecks = await runDoctor(project.directory);
|
|
1057
2800
|
spinner.clear();
|
|
1058
2801
|
for (const item of doctorChecks) {
|
|
1059
|
-
|
|
1060
|
-
if (item.ok
|
|
1061
|
-
else if (item.warning) p.log.warn(pc.yellow(line));
|
|
1062
|
-
else {
|
|
1063
|
-
p.log.error(pc.red(line));
|
|
1064
|
-
failed++;
|
|
1065
|
-
}
|
|
2802
|
+
logCheck(item);
|
|
2803
|
+
if (!item.ok && !item.warning) failed++;
|
|
1066
2804
|
}
|
|
1067
2805
|
}
|
|
1068
2806
|
const running = runningServices(project);
|
|
@@ -1259,9 +2997,26 @@ function createProgram() {
|
|
|
1259
2997
|
for (const flag of stackFlags()) configCommand.addOption(flag);
|
|
1260
2998
|
program.command("manage").description("Run a Django management command in the backend").argument("<args...>", "The command and its options, such as createsuperuser").addOption(dirOption()).allowUnknownOption().helpOption(false).action((args, options) => manage(args, options));
|
|
1261
2999
|
program.command("shell").description("Open Django's shell in the backend").addOption(dirOption()).action(shell);
|
|
1262
|
-
program.command("backup").description("Back up the database now (prod profile)").addOption(dirOption()).option("--list", "List the backups instead").action(backup);
|
|
1263
|
-
program.command("restore").description("Restore a backup over the database (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(restore);
|
|
3000
|
+
program.command("backup").description("Back up the database and the media now (prod profile)").addOption(dirOption()).option("--list", "List the backups instead").option("--no-media", "Back up only the database").action(backup);
|
|
3001
|
+
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);
|
|
1264
3002
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
3003
|
+
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");
|
|
3005
|
+
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
|
+
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
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3008
|
+
for (const flag of PROVIDERS.flatMap((provider) => provider.setupFlags)) {
|
|
3009
|
+
if (seen.has(flag.name)) continue;
|
|
3010
|
+
seen.add(flag.name);
|
|
3011
|
+
const kebab = flag.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
3012
|
+
setupCommand.option(`--${kebab} <value>`, flag.description);
|
|
3013
|
+
}
|
|
3014
|
+
deployCommand.command("status").description("Show the state of the site and the last deploys").addOption(dirOption()).option("-n, --limit <count>", "How many deploys to list", "5").option("--watch", "Follow a running deploy until it ends").action(deployStatus);
|
|
3015
|
+
deployCommand.command("logs").description("Print the build log of a deploy").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the latest)").addOption(dirOption()).action(deployLogs);
|
|
3016
|
+
deployCommand.command("rollback").description("Run an earlier release again (servers over SSH)").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the one before the running one)").addOption(dirOption()).option("--no-wait", "Don't follow the rollback; return once it started").option("-y, --yes", "Don't ask").action(deployRollback);
|
|
3017
|
+
deployCommand.command("env").description("Manage the platform's environment variables").command("push").description("Replace the platform's variables with .env.production").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(deployEnvPush);
|
|
3018
|
+
deployCommand.command("login").description("Check and save an API key in the system keychain").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").option("--token <token>", "The API key (default: ask, or the provider's environment variable with --yes)").option("-y, --yes", "Don't ask").action(deployLogin);
|
|
3019
|
+
deployCommand.command("logout").description("Remove a saved API key").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").action(deployLogout);
|
|
1265
3020
|
const presetCommand = program.command("preset").description("Print the preset code of a stack, or the stack of a code").argument("[preset]", "A preset code or name to show").option("-y, --yes", "Don't ask; use the flags and the defaults").action((code, options, cmd) => preset(code, {
|
|
1266
3021
|
...options,
|
|
1267
3022
|
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|