@stacksjs/ts-cloud 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aws/index.js +259 -0
- package/dist/bin/cli.js +316 -316
- package/dist/chunk-0wxyppza.js +146 -0
- package/dist/chunk-3knnr7wh.js +601 -0
- package/dist/chunk-3rsfns7x.js +10427 -0
- package/dist/chunk-93hjhs78.js +1752 -0
- package/dist/chunk-arsh1g5h.js +1749 -0
- package/dist/chunk-b82pbxyp.js +1572 -0
- package/dist/chunk-c6rgvg1j.js +46124 -0
- package/dist/chunk-d2p5n2aq.js +23 -0
- package/dist/chunk-d7p84vz5.js +1699 -0
- package/dist/chunk-dpkk640m.js +4392 -0
- package/dist/chunk-eq08r166.js +8 -0
- package/dist/chunk-hsk6fe6x.js +371 -0
- package/dist/chunk-mj1tmhte.js +13 -0
- package/dist/chunk-p6309384.js +12828 -0
- package/dist/chunk-pegd7rmf.js +10 -0
- package/dist/chunk-pqfzdg68.js +12 -0
- package/dist/chunk-qpj3edwz.js +601 -0
- package/dist/chunk-tjjgajbh.js +1032 -0
- package/dist/chunk-tnztxpcb.js +630 -0
- package/dist/chunk-tt4kxske.js +1788 -0
- package/dist/chunk-v0bahtg2.js +4 -0
- package/dist/chunk-vd87cpvn.js +1754 -0
- package/dist/chunk-zn0nxxa8.js +1779 -0
- package/dist/deploy/index.js +87 -0
- package/dist/dns/index.js +31 -0
- package/dist/drivers/hetzner/provision.d.ts +53 -0
- package/dist/drivers/index.d.ts +5 -1
- package/dist/drivers/index.js +98 -0
- package/dist/drivers/shared/remote-exec.d.ts +47 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4383 -91358
- package/dist/push/index.js +509 -0
- package/dist/ui/index.html +3 -3
- package/dist/ui/server/actions.html +3 -3
- package/dist/ui/server/backups.html +3 -3
- package/dist/ui/server/database.html +3 -3
- package/dist/ui/server/deployments.html +3 -3
- package/dist/ui/server/firewall.html +3 -3
- package/dist/ui/server/logs.html +3 -3
- package/dist/ui/server/services.html +3 -3
- package/dist/ui/server/sites.html +3 -3
- package/dist/ui/server/ssh-keys.html +3 -3
- package/dist/ui/server/terminal.html +3 -3
- package/dist/ui/server/workers.html +3 -3
- package/dist/ui/serverless/alarms.html +3 -3
- package/dist/ui/serverless/assets.html +3 -3
- package/dist/ui/serverless/data.html +3 -3
- package/dist/ui/serverless/deployments.html +3 -3
- package/dist/ui/serverless/functions.html +3 -3
- package/dist/ui/serverless/logs.html +3 -3
- package/dist/ui/serverless/queues.html +3 -3
- package/dist/ui/serverless/scheduler.html +3 -3
- package/dist/ui/serverless/secrets.html +3 -3
- package/dist/ui/serverless/traces.html +3 -3
- package/dist/ui/serverless.html +3 -3
- package/package.json +3 -3
|
@@ -0,0 +1,4392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deploymentCoexistenceError,
|
|
3
|
+
hasManagementDashboardSite,
|
|
4
|
+
resolveCloudProvider,
|
|
5
|
+
resolveManagementDashboardSite,
|
|
6
|
+
resolveProjectStackName
|
|
7
|
+
} from "./chunk-c6rgvg1j.js";
|
|
8
|
+
import {
|
|
9
|
+
EC2Client,
|
|
10
|
+
SSMClient
|
|
11
|
+
} from "./chunk-93hjhs78.js";
|
|
12
|
+
import {
|
|
13
|
+
CloudFormationClient
|
|
14
|
+
} from "./chunk-qpj3edwz.js";
|
|
15
|
+
import {
|
|
16
|
+
S3Client
|
|
17
|
+
} from "./chunk-vd87cpvn.js";
|
|
18
|
+
import {
|
|
19
|
+
__require
|
|
20
|
+
} from "./chunk-v0bahtg2.js";
|
|
21
|
+
|
|
22
|
+
// src/drivers/aws/driver.ts
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
// src/drivers/shared/package-manager.ts
|
|
27
|
+
var PANTRY_PACKAGES = {
|
|
28
|
+
php: "php.net",
|
|
29
|
+
nginx: "nginx.org",
|
|
30
|
+
composer: "getcomposer.org",
|
|
31
|
+
mysql: "mysql.com",
|
|
32
|
+
mariadb: "mariadb.com/server",
|
|
33
|
+
postgres: "postgresql.org",
|
|
34
|
+
redis: "redis.io",
|
|
35
|
+
memcached: "memcached.org",
|
|
36
|
+
meilisearch: "meilisearch.com",
|
|
37
|
+
git: "git-scm.com",
|
|
38
|
+
certbot: "certbot.eff.org",
|
|
39
|
+
bun: "bun.sh",
|
|
40
|
+
node: "nodejs.org",
|
|
41
|
+
deno: "deno.land"
|
|
42
|
+
};
|
|
43
|
+
var PANTRY_INSTALL_DIR = "/usr/local/bin";
|
|
44
|
+
var PANTRY_PROJECT_DIR = "/opt/pantry";
|
|
45
|
+
function sh(value) {
|
|
46
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
47
|
+
}
|
|
48
|
+
function buildPantryBootstrapScript(options = {}) {
|
|
49
|
+
const versionLine = options.version ? `export PANTRY_VERSION=${sh(options.version)}` : 'export PANTRY_VERSION="${PANTRY_VERSION:-latest}"';
|
|
50
|
+
return [
|
|
51
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
52
|
+
"export PANTRY_SERVICE_SCOPE=system",
|
|
53
|
+
`export PANTRY_INSTALL_DIR=${PANTRY_INSTALL_DIR}`,
|
|
54
|
+
versionLine,
|
|
55
|
+
"command -v curl >/dev/null 2>&1 || (apt-get update -y && apt-get install -y curl ca-certificates)",
|
|
56
|
+
"command -v unzip >/dev/null 2>&1 || (apt-get update -y && apt-get install -y unzip)",
|
|
57
|
+
"command -v pantry >/dev/null 2>&1 || curl -fsSL https://pantry.dev | bash",
|
|
58
|
+
`export PATH="${PANTRY_INSTALL_DIR}:$PATH"`,
|
|
59
|
+
`mkdir -p ${PANTRY_PROJECT_DIR}`
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function pantryEnvActivation() {
|
|
63
|
+
return `eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/null)" || true`;
|
|
64
|
+
}
|
|
65
|
+
function buildPantryInstallScript(specs) {
|
|
66
|
+
if (specs.length === 0)
|
|
67
|
+
return [];
|
|
68
|
+
const unique = [...new Set(specs)];
|
|
69
|
+
return [`(cd ${PANTRY_PROJECT_DIR} && pantry install ${unique.map(sh).join(" ")})`];
|
|
70
|
+
}
|
|
71
|
+
function buildPantryServiceScript(services) {
|
|
72
|
+
return [...new Set(services)].flatMap((name) => [
|
|
73
|
+
`(cd ${PANTRY_PROJECT_DIR} && pantry start ${sh(name)})`,
|
|
74
|
+
`(cd ${PANTRY_PROJECT_DIR} && pantry enable ${sh(name)})`
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/drivers/shared/db-provision.ts
|
|
79
|
+
function enabled(spec) {
|
|
80
|
+
return spec === true || typeof spec === "object" && spec != null;
|
|
81
|
+
}
|
|
82
|
+
function planServices(services) {
|
|
83
|
+
const packages = [];
|
|
84
|
+
const names = [];
|
|
85
|
+
if (enabled(services.mysql)) {
|
|
86
|
+
packages.push(typeof services.mysql === "object" && services.mysql.version ? `mysql.com@${services.mysql.version}` : "mysql.com@8.0.43");
|
|
87
|
+
names.push("mysql");
|
|
88
|
+
} else if (enabled(services.mariadb)) {
|
|
89
|
+
packages.push(PANTRY_PACKAGES.mariadb);
|
|
90
|
+
names.push("mariadb");
|
|
91
|
+
}
|
|
92
|
+
if (enabled(services.postgres)) {
|
|
93
|
+
packages.push("postgresql.org");
|
|
94
|
+
names.push("postgres");
|
|
95
|
+
}
|
|
96
|
+
if (enabled(services.redis)) {
|
|
97
|
+
packages.push("redis.io");
|
|
98
|
+
names.push("redis");
|
|
99
|
+
}
|
|
100
|
+
if (enabled(services.memcached)) {
|
|
101
|
+
packages.push("memcached.org");
|
|
102
|
+
names.push("memcached");
|
|
103
|
+
}
|
|
104
|
+
if (enabled(services.meilisearch)) {
|
|
105
|
+
packages.push("meilisearch.com");
|
|
106
|
+
names.push("meilisearch");
|
|
107
|
+
}
|
|
108
|
+
return { packages, services: names };
|
|
109
|
+
}
|
|
110
|
+
function buildServicesProvisionScript(services = {}, _options = {}) {
|
|
111
|
+
const plan = planServices(services);
|
|
112
|
+
if (plan.packages.length === 0)
|
|
113
|
+
return [];
|
|
114
|
+
return [
|
|
115
|
+
...buildPantryInstallScript(plan.packages),
|
|
116
|
+
...buildPantryServiceScript(plan.services)
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
function buildDatabaseSetupScript(database, services = {}) {
|
|
120
|
+
if (!database?.name)
|
|
121
|
+
return [];
|
|
122
|
+
if (database.host && database.host !== "127.0.0.1" && database.host !== "localhost")
|
|
123
|
+
return [];
|
|
124
|
+
const name = database.name;
|
|
125
|
+
const user = database.username || name;
|
|
126
|
+
const pass = database.password || "";
|
|
127
|
+
const usePostgres = enabled(services.postgres) || database.engine === "postgres";
|
|
128
|
+
const useMariadb = enabled(services.mariadb) || database.engine === "mariadb";
|
|
129
|
+
const useMysql = enabled(services.mysql) || database.engine === "mysql";
|
|
130
|
+
if (usePostgres && !useMysql && !useMariadb) {
|
|
131
|
+
const pgIdent = (v) => `"${v.replace(/"/g, '""')}"`;
|
|
132
|
+
const pgLit = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
133
|
+
const pgEnsureRole = (u, p) => [
|
|
134
|
+
"DO $$ BEGIN",
|
|
135
|
+
` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(u)}) THEN`,
|
|
136
|
+
` CREATE ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
|
|
137
|
+
" ELSE",
|
|
138
|
+
` ALTER ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
|
|
139
|
+
" END IF;",
|
|
140
|
+
"END $$;"
|
|
141
|
+
];
|
|
142
|
+
const pgGrant = (u) => {
|
|
143
|
+
const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const db of dbs) {
|
|
146
|
+
if (u.access === "readonly") {
|
|
147
|
+
lines.push(`GRANT CONNECT ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`, `\\connect ${pgIdent(db)}`, `GRANT USAGE ON SCHEMA public TO ${pgIdent(u.username)};`, `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${pgIdent(u.username)};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
|
|
148
|
+
} else {
|
|
149
|
+
lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return lines;
|
|
153
|
+
};
|
|
154
|
+
const extraUsers2 = database.users || [];
|
|
155
|
+
return [
|
|
156
|
+
pantryEnvActivation(),
|
|
157
|
+
"for i in $(seq 1 30); do pg_isready -h 127.0.0.1 -p 5432 -q && break; sleep 2; done",
|
|
158
|
+
"psql -h 127.0.0.1 -p 5432 -U postgres <<'TS_CLOUD_PG_EOF'",
|
|
159
|
+
...pgEnsureRole(user, pass),
|
|
160
|
+
`SELECT 'CREATE DATABASE ${pgIdent(name)} OWNER ${pgIdent(user)}' ` + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`,
|
|
161
|
+
...extraUsers2.flatMap((u) => [...pgEnsureRole(u.username, u.password), ...pgGrant(u)]),
|
|
162
|
+
"TS_CLOUD_PG_EOF"
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
const sock = useMariadb ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
|
|
166
|
+
const ident = (v) => v.replace(/`/g, "``");
|
|
167
|
+
const lit = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
168
|
+
const mysqlUser = (u) => {
|
|
169
|
+
const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
|
|
170
|
+
const priv = u.access === "readonly" ? "SELECT" : "ALL PRIVILEGES";
|
|
171
|
+
const lines = [
|
|
172
|
+
`CREATE USER IF NOT EXISTS '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
|
|
173
|
+
`CREATE USER IF NOT EXISTS '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`,
|
|
174
|
+
`ALTER USER '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
|
|
175
|
+
`ALTER USER '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`
|
|
176
|
+
];
|
|
177
|
+
for (const db of dbs) {
|
|
178
|
+
lines.push(`GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'%';`, `GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'localhost';`);
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
};
|
|
182
|
+
const extraUsers = database.users || [];
|
|
183
|
+
return [
|
|
184
|
+
pantryEnvActivation(),
|
|
185
|
+
`for i in $(seq 1 30); do mysqladmin --socket=${sock} -u root ping 2>/dev/null | grep -q alive && break; sleep 2; done`,
|
|
186
|
+
`mysql --socket=${sock} -u root <<'TS_CLOUD_SQL_EOF'`,
|
|
187
|
+
`CREATE DATABASE IF NOT EXISTS \`${ident(name)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
|
|
188
|
+
...mysqlUser({ username: user, password: pass }),
|
|
189
|
+
...extraUsers.flatMap(mysqlUser),
|
|
190
|
+
"FLUSH PRIVILEGES;",
|
|
191
|
+
"TS_CLOUD_SQL_EOF"
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
function buildManagedDbEnv(database) {
|
|
195
|
+
if (!database?.name)
|
|
196
|
+
return {};
|
|
197
|
+
const connection = database.engine === "postgres" ? "pgsql" : "mysql";
|
|
198
|
+
const port = database.port ?? (database.engine === "postgres" ? 5432 : 3306);
|
|
199
|
+
const env = {
|
|
200
|
+
DB_CONNECTION: connection,
|
|
201
|
+
DB_HOST: database.host || "127.0.0.1",
|
|
202
|
+
DB_PORT: String(port),
|
|
203
|
+
DB_DATABASE: database.name
|
|
204
|
+
};
|
|
205
|
+
if (database.username)
|
|
206
|
+
env.DB_USERNAME = database.username;
|
|
207
|
+
if (database.password)
|
|
208
|
+
env.DB_PASSWORD = database.password;
|
|
209
|
+
return env;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/drivers/shared/php-provision.ts
|
|
213
|
+
var PRODUCTION_PHP_INI = {
|
|
214
|
+
"opcache.enable": "1",
|
|
215
|
+
"opcache.enable_cli": "1",
|
|
216
|
+
"opcache.memory_consumption": "256",
|
|
217
|
+
"opcache.interned_strings_buffer": "16",
|
|
218
|
+
"opcache.max_accelerated_files": "20000",
|
|
219
|
+
"opcache.validate_timestamps": "0",
|
|
220
|
+
"opcache.revalidate_freq": "0",
|
|
221
|
+
"opcache.save_comments": "1",
|
|
222
|
+
"opcache.fast_shutdown": "1",
|
|
223
|
+
realpath_cache_size: "4096K",
|
|
224
|
+
realpath_cache_ttl: "600"
|
|
225
|
+
};
|
|
226
|
+
var PHP_FPM_LISTEN = "127.0.0.1:9074";
|
|
227
|
+
function phpFpmSocketPath(_version) {
|
|
228
|
+
return PHP_FPM_LISTEN;
|
|
229
|
+
}
|
|
230
|
+
function resolveDefaultPhpVersion(options = {}) {
|
|
231
|
+
const versions = options.versions?.length ? options.versions : ["8.3"];
|
|
232
|
+
return options.default && versions.includes(options.default) ? options.default : versions[0];
|
|
233
|
+
}
|
|
234
|
+
function resolvePhpIni(options = {}) {
|
|
235
|
+
const base = options.optimizeForProduction === false ? {} : { ...PRODUCTION_PHP_INI };
|
|
236
|
+
return { ...base, ...options.ini || {} };
|
|
237
|
+
}
|
|
238
|
+
function buildPhpTuningScript(options = {}) {
|
|
239
|
+
const ini = resolvePhpIni(options);
|
|
240
|
+
const keys = Object.keys(ini);
|
|
241
|
+
if (keys.length === 0)
|
|
242
|
+
return [];
|
|
243
|
+
const body = keys.map((k) => `${k}=${ini[k]}`);
|
|
244
|
+
const marker = "ts-cloud-managed";
|
|
245
|
+
return [
|
|
246
|
+
pantryEnvActivation(),
|
|
247
|
+
"TS_CLOUD_SCAN_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Scan this dir for additional .ini files/{print $2}' | head -1)",
|
|
248
|
+
`TS_CLOUD_LOADED_INI=$(php -r 'echo php_ini_loaded_file() ?: "";' 2>/dev/null)`,
|
|
249
|
+
'if [ -n "$TS_CLOUD_SCAN_DIR" ] && [ "$TS_CLOUD_SCAN_DIR" != "(none)" ]; then',
|
|
250
|
+
' mkdir -p "$TS_CLOUD_SCAN_DIR"',
|
|
251
|
+
' TS_CLOUD_PHP_INI="$TS_CLOUD_SCAN_DIR/zz-ts-cloud.ini"',
|
|
252
|
+
` cat > "$TS_CLOUD_PHP_INI" <<'TS_CLOUD_PHPINI_EOF'`,
|
|
253
|
+
`; ${marker} — production PHP tuning`,
|
|
254
|
+
...body,
|
|
255
|
+
"TS_CLOUD_PHPINI_EOF",
|
|
256
|
+
"else",
|
|
257
|
+
' if [ -z "$TS_CLOUD_LOADED_INI" ]; then',
|
|
258
|
+
" TS_CLOUD_INI_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Configuration File \\(php.ini\\) Path/{print $2}' | head -1)",
|
|
259
|
+
' TS_CLOUD_LOADED_INI="$TS_CLOUD_INI_DIR/php.ini"',
|
|
260
|
+
' mkdir -p "$TS_CLOUD_INI_DIR"',
|
|
261
|
+
' touch "$TS_CLOUD_LOADED_INI"',
|
|
262
|
+
" fi",
|
|
263
|
+
` sed -i '/; ${marker} BEGIN/,/; ${marker} END/d' "$TS_CLOUD_LOADED_INI"`,
|
|
264
|
+
` cat >> "$TS_CLOUD_LOADED_INI" <<'TS_CLOUD_PHPINI_EOF'`,
|
|
265
|
+
`; ${marker} BEGIN`,
|
|
266
|
+
...body,
|
|
267
|
+
`; ${marker} END`,
|
|
268
|
+
"TS_CLOUD_PHPINI_EOF",
|
|
269
|
+
"fi",
|
|
270
|
+
`(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
function buildPhpProvisionScript(options = {}) {
|
|
274
|
+
const defaultVersion = resolveDefaultPhpVersion(options);
|
|
275
|
+
const installNginx = options.installNginx !== false;
|
|
276
|
+
const installComposer = options.installComposer !== false;
|
|
277
|
+
const specs = [`php.net@${defaultVersion}`];
|
|
278
|
+
if (installComposer)
|
|
279
|
+
specs.push("getcomposer.org");
|
|
280
|
+
if (installNginx)
|
|
281
|
+
specs.push("nginx.org");
|
|
282
|
+
return [
|
|
283
|
+
...buildPantryInstallScript(specs),
|
|
284
|
+
...buildPantryServiceScript(["php-fpm"]),
|
|
285
|
+
...buildPhpTuningScript(options)
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/drivers/shared/nginx-vhost.ts
|
|
290
|
+
function resolveNginxSnippet(nginx, templates) {
|
|
291
|
+
if (!nginx)
|
|
292
|
+
return [];
|
|
293
|
+
const out = [];
|
|
294
|
+
if (nginx.template && templates?.[nginx.template])
|
|
295
|
+
out.push(...templates[nginx.template]);
|
|
296
|
+
if (nginx.serverSnippet)
|
|
297
|
+
out.push(...nginx.serverSnippet);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function hstsHeader(hsts) {
|
|
301
|
+
if (!hsts)
|
|
302
|
+
return "";
|
|
303
|
+
const o = typeof hsts === "object" ? hsts : {};
|
|
304
|
+
const maxAge = o.maxAge ?? 31536000;
|
|
305
|
+
const parts = [`max-age=${maxAge}`];
|
|
306
|
+
if (o.includeSubDomains ?? true)
|
|
307
|
+
parts.push("includeSubDomains");
|
|
308
|
+
if (o.preload)
|
|
309
|
+
parts.push("preload");
|
|
310
|
+
return ` add_header Strict-Transport-Security "${parts.join("; ")}" always;`;
|
|
311
|
+
}
|
|
312
|
+
function securityRules(security) {
|
|
313
|
+
if (!security || !security.allow?.length && !security.deny?.length)
|
|
314
|
+
return [];
|
|
315
|
+
const lines = [];
|
|
316
|
+
for (const ip of security.deny || [])
|
|
317
|
+
lines.push(` deny ${ip};`);
|
|
318
|
+
if (security.allow?.length) {
|
|
319
|
+
for (const ip of security.allow)
|
|
320
|
+
lines.push(` allow ${ip};`);
|
|
321
|
+
lines.push(" deny all;");
|
|
322
|
+
}
|
|
323
|
+
return lines;
|
|
324
|
+
}
|
|
325
|
+
function htpasswdPath(siteName) {
|
|
326
|
+
return `/etc/nginx/.htpasswd-${siteName}`;
|
|
327
|
+
}
|
|
328
|
+
var PHP_TYPES = new Set(["laravel", "php", "statamic", "wordpress"]);
|
|
329
|
+
function isPhpSiteType(type) {
|
|
330
|
+
return PHP_TYPES.has(type);
|
|
331
|
+
}
|
|
332
|
+
function defaultWebDirectory(type) {
|
|
333
|
+
return type === "laravel" || type === "statamic" ? "public" : "";
|
|
334
|
+
}
|
|
335
|
+
function resolveRoot(appDir, webDirectory) {
|
|
336
|
+
const base = appDir.replace(/\/+$/, "");
|
|
337
|
+
const sub = webDirectory.replace(/^\/+|\/+$/g, "");
|
|
338
|
+
return sub ? `${base}/${sub}` : base;
|
|
339
|
+
}
|
|
340
|
+
function vhostBody(options) {
|
|
341
|
+
const type = options.type ?? "laravel";
|
|
342
|
+
const webDirectory = options.webDirectory ?? defaultWebDirectory(type);
|
|
343
|
+
const root = resolveRoot(options.appDir, webDirectory);
|
|
344
|
+
const isPhp = isPhpSiteType(type);
|
|
345
|
+
const phpVersion = options.phpVersion ?? "8.3";
|
|
346
|
+
const lines = [
|
|
347
|
+
` root ${root};`,
|
|
348
|
+
"",
|
|
349
|
+
' add_header X-Frame-Options "SAMEORIGIN";',
|
|
350
|
+
' add_header X-Content-Type-Options "nosniff";'
|
|
351
|
+
];
|
|
352
|
+
const hsts = hstsHeader(options.hsts);
|
|
353
|
+
if (hsts)
|
|
354
|
+
lines.push(hsts);
|
|
355
|
+
for (const rule of securityRules(options.security))
|
|
356
|
+
lines.push(rule);
|
|
357
|
+
lines.push("", ` index ${isPhp ? "index.php index.html" : "index.html index.htm"};`, "", " charset utf-8;", "");
|
|
358
|
+
if (options.clientMaxBodySize) {
|
|
359
|
+
lines.push(` client_max_body_size ${options.clientMaxBodySize};`, "");
|
|
360
|
+
}
|
|
361
|
+
if (options.auth) {
|
|
362
|
+
lines.push(` auth_basic "${options.auth.realm || "Restricted"}";`, ` auth_basic_user_file ${htpasswdPath(options.siteName)};`, "");
|
|
363
|
+
}
|
|
364
|
+
for (const [from, to] of Object.entries(options.redirects || {})) {
|
|
365
|
+
lines.push(` location = ${from} { return 301 ${to}; }`);
|
|
366
|
+
}
|
|
367
|
+
if (Object.keys(options.redirects || {}).length > 0)
|
|
368
|
+
lines.push("");
|
|
369
|
+
if (isPhp) {
|
|
370
|
+
lines.push(" location / {", " try_files $uri $uri/ /index.php?$query_string;", " }", "", " location = /favicon.ico { access_log off; log_not_found off; }", " location = /robots.txt { access_log off; log_not_found off; }", "", " error_page 404 /index.php;", "", " location ~ \\.php$ {", ` fastcgi_pass ${options.fastcgiPass ?? phpFpmSocketPath(phpVersion)};`, " fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;", " include fastcgi_params;", " }", "", " location ~ /\\.(?!well-known).* {", " deny all;", " }");
|
|
371
|
+
if (type === "wordpress") {
|
|
372
|
+
lines.push("", " location = /xmlrpc.php { deny all; }", " location ~* /(?:wp-config\\.php|readme\\.html|license\\.txt)$ { deny all; }", " location ~* /wp-content/uploads/.*\\.php$ { deny all; }");
|
|
373
|
+
}
|
|
374
|
+
} else if (type === "spa") {
|
|
375
|
+
lines.push(" location / {", " try_files $uri $uri/ /index.html;", " }");
|
|
376
|
+
} else {
|
|
377
|
+
lines.push(" location / {", " try_files $uri $uri/ =404;", " }");
|
|
378
|
+
}
|
|
379
|
+
if (options.serverSnippet && options.serverSnippet.length > 0) {
|
|
380
|
+
lines.push("");
|
|
381
|
+
for (const line of options.serverSnippet)
|
|
382
|
+
lines.push(line ? ` ${line}` : "");
|
|
383
|
+
}
|
|
384
|
+
return lines;
|
|
385
|
+
}
|
|
386
|
+
function buildNginxVhost(options) {
|
|
387
|
+
const serverNames = [options.domain, ...options.aliases || []].filter(Boolean).join(" ");
|
|
388
|
+
const body = vhostBody(options);
|
|
389
|
+
if (options.ssl) {
|
|
390
|
+
const redirect = [
|
|
391
|
+
"server {",
|
|
392
|
+
" listen 80;",
|
|
393
|
+
" listen [::]:80;",
|
|
394
|
+
` server_name ${serverNames};`,
|
|
395
|
+
" return 301 https://$host$request_uri;",
|
|
396
|
+
"}"
|
|
397
|
+
];
|
|
398
|
+
const tls = [
|
|
399
|
+
"server {",
|
|
400
|
+
" listen 443 ssl;",
|
|
401
|
+
" listen [::]:443 ssl;",
|
|
402
|
+
` server_name ${serverNames};`,
|
|
403
|
+
` ssl_certificate ${options.ssl.certPath};`,
|
|
404
|
+
` ssl_certificate_key ${options.ssl.keyPath};`,
|
|
405
|
+
...options.tlsProtocols?.length ? [` ssl_protocols ${options.tlsProtocols.join(" ")};`] : [],
|
|
406
|
+
"",
|
|
407
|
+
...body,
|
|
408
|
+
"}"
|
|
409
|
+
];
|
|
410
|
+
return `${[...redirect, "", ...tls].join(`
|
|
411
|
+
`)}
|
|
412
|
+
`;
|
|
413
|
+
}
|
|
414
|
+
const lines = [
|
|
415
|
+
"server {",
|
|
416
|
+
" listen 80;",
|
|
417
|
+
" listen [::]:80;",
|
|
418
|
+
` server_name ${serverNames};`,
|
|
419
|
+
...body,
|
|
420
|
+
"}"
|
|
421
|
+
];
|
|
422
|
+
return `${lines.join(`
|
|
423
|
+
`)}
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
function buildNginxVhostScript(options) {
|
|
427
|
+
const available = `/etc/nginx/sites-available/${options.siteName}`;
|
|
428
|
+
const enabled2 = `/etc/nginx/sites-enabled/${options.siteName}`;
|
|
429
|
+
const vhost = buildNginxVhost(options);
|
|
430
|
+
const out = [...buildNginxServiceScript()];
|
|
431
|
+
if (options.auth) {
|
|
432
|
+
const file = htpasswdPath(options.siteName);
|
|
433
|
+
const sq = (v) => v.split("'").join("'\\''");
|
|
434
|
+
const pw = sq(options.auth.password);
|
|
435
|
+
const user = sq(options.auth.username);
|
|
436
|
+
out.push(`TS_CLOUD_HTPASS=$(openssl passwd -apr1 '${pw}')`, `printf '%s:%s\\n' '${user}' "$TS_CLOUD_HTPASS" > ${file}`, `chmod 640 ${file}`, `chown root:www-data ${file} 2>/dev/null || true`);
|
|
437
|
+
}
|
|
438
|
+
out.push(`cat > ${available} <<'TS_CLOUD_NGINX_EOF'`, vhost.replace(/\n$/, ""), "TS_CLOUD_NGINX_EOF", `ln -sf ${available} ${enabled2}`, "rm -f /etc/nginx/sites-enabled/default", `${NGINX_WRAPPER} -t`, "systemctl reload ts-cloud-nginx 2>/dev/null || systemctl restart ts-cloud-nginx 2>/dev/null || true");
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
var NGINX_WRAPPER = "/usr/local/bin/ts-cloud-nginx";
|
|
442
|
+
function buildNginxServiceScript(projectDir = "/opt/pantry") {
|
|
443
|
+
return [
|
|
444
|
+
...buildPantryBootstrapScript(),
|
|
445
|
+
...buildPantryInstallScript(["nginx.org"]),
|
|
446
|
+
"mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled /var/log/nginx /var/lib/nginx/body /var/lib/nginx/proxy /var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi",
|
|
447
|
+
"getent passwd www-data >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin www-data",
|
|
448
|
+
"chown -R www-data:www-data /var/lib/nginx /var/log/nginx",
|
|
449
|
+
`cat > ${NGINX_WRAPPER} <<'TS_CLOUD_NGINXBIN_EOF'`,
|
|
450
|
+
"#!/bin/sh",
|
|
451
|
+
`eval "$(cd ${projectDir} && pantry env 2>/dev/null)"`,
|
|
452
|
+
"NGINX_BIN=/opt/pantry/pantry/.bin/nginx",
|
|
453
|
+
'if [ ! -x "$NGINX_BIN" ]; then NGINX_BIN=$(command -v nginx || true); fi',
|
|
454
|
+
'if [ -z "$NGINX_BIN" ]; then echo "nginx binary not found; run pantry install nginx.org or install nginx explicitly" >&2; exit 1; fi',
|
|
455
|
+
'exec "$NGINX_BIN" -c /etc/nginx/nginx.conf "$@"',
|
|
456
|
+
"TS_CLOUD_NGINXBIN_EOF",
|
|
457
|
+
`chmod +x ${NGINX_WRAPPER}`,
|
|
458
|
+
"cat > /etc/nginx/fastcgi_params <<'TS_CLOUD_FCGI_EOF'",
|
|
459
|
+
"fastcgi_param QUERY_STRING $query_string;",
|
|
460
|
+
"fastcgi_param REQUEST_METHOD $request_method;",
|
|
461
|
+
"fastcgi_param CONTENT_TYPE $content_type;",
|
|
462
|
+
"fastcgi_param CONTENT_LENGTH $content_length;",
|
|
463
|
+
"fastcgi_param SCRIPT_NAME $fastcgi_script_name;",
|
|
464
|
+
"fastcgi_param REQUEST_URI $request_uri;",
|
|
465
|
+
"fastcgi_param DOCUMENT_URI $document_uri;",
|
|
466
|
+
"fastcgi_param DOCUMENT_ROOT $document_root;",
|
|
467
|
+
"fastcgi_param SERVER_PROTOCOL $server_protocol;",
|
|
468
|
+
"fastcgi_param REQUEST_SCHEME $scheme;",
|
|
469
|
+
"fastcgi_param HTTPS $https if_not_empty;",
|
|
470
|
+
"fastcgi_param GATEWAY_INTERFACE CGI/1.1;",
|
|
471
|
+
"fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;",
|
|
472
|
+
"fastcgi_param REMOTE_ADDR $remote_addr;",
|
|
473
|
+
"fastcgi_param REMOTE_PORT $remote_port;",
|
|
474
|
+
"fastcgi_param SERVER_ADDR $server_addr;",
|
|
475
|
+
"fastcgi_param SERVER_PORT $server_port;",
|
|
476
|
+
"fastcgi_param SERVER_NAME $server_name;",
|
|
477
|
+
"fastcgi_param REDIRECT_STATUS 200;",
|
|
478
|
+
"TS_CLOUD_FCGI_EOF",
|
|
479
|
+
"cat > /etc/nginx/nginx.conf <<'TS_CLOUD_NGINXCONF_EOF'",
|
|
480
|
+
"user www-data;",
|
|
481
|
+
"worker_processes auto;",
|
|
482
|
+
"pid /run/nginx.pid;",
|
|
483
|
+
"error_log /var/log/nginx/error.log;",
|
|
484
|
+
"events { worker_connections 1024; }",
|
|
485
|
+
"http {",
|
|
486
|
+
" default_type application/octet-stream;",
|
|
487
|
+
" types {",
|
|
488
|
+
" text/html html htm;",
|
|
489
|
+
" text/css css;",
|
|
490
|
+
" application/javascript js;",
|
|
491
|
+
" application/json json;",
|
|
492
|
+
" image/svg+xml svg;",
|
|
493
|
+
" image/png png;",
|
|
494
|
+
" image/jpeg jpg jpeg;",
|
|
495
|
+
" image/gif gif;",
|
|
496
|
+
" image/x-icon ico;",
|
|
497
|
+
" image/webp webp;",
|
|
498
|
+
" font/woff2 woff2;",
|
|
499
|
+
" font/woff woff;",
|
|
500
|
+
" text/plain txt;",
|
|
501
|
+
" }",
|
|
502
|
+
" access_log /var/log/nginx/access.log;",
|
|
503
|
+
" sendfile on;",
|
|
504
|
+
" tcp_nopush on;",
|
|
505
|
+
" keepalive_timeout 65;",
|
|
506
|
+
" server_tokens off;",
|
|
507
|
+
" client_max_body_size 100m;",
|
|
508
|
+
" client_body_temp_path /var/lib/nginx/body;",
|
|
509
|
+
" proxy_temp_path /var/lib/nginx/proxy;",
|
|
510
|
+
" fastcgi_temp_path /var/lib/nginx/fastcgi;",
|
|
511
|
+
" uwsgi_temp_path /var/lib/nginx/uwsgi;",
|
|
512
|
+
" scgi_temp_path /var/lib/nginx/scgi;",
|
|
513
|
+
" server {",
|
|
514
|
+
" listen 80 default_server;",
|
|
515
|
+
" listen [::]:80 default_server;",
|
|
516
|
+
" server_name _;",
|
|
517
|
+
" return 444;",
|
|
518
|
+
" }",
|
|
519
|
+
" include /etc/nginx/sites-enabled/*;",
|
|
520
|
+
"}",
|
|
521
|
+
"TS_CLOUD_NGINXCONF_EOF",
|
|
522
|
+
"cat > /etc/systemd/system/ts-cloud-nginx.service <<'TS_CLOUD_NGINXUNIT_EOF'",
|
|
523
|
+
"[Unit]",
|
|
524
|
+
"Description=ts-cloud nginx (pantry)",
|
|
525
|
+
"After=network.target",
|
|
526
|
+
"",
|
|
527
|
+
"[Service]",
|
|
528
|
+
"Type=simple",
|
|
529
|
+
`ExecStartPre=${NGINX_WRAPPER} -t`,
|
|
530
|
+
`ExecStart=${NGINX_WRAPPER} -g 'daemon off;'`,
|
|
531
|
+
`ExecReload=${NGINX_WRAPPER} -s reload`,
|
|
532
|
+
"Restart=always",
|
|
533
|
+
"RestartSec=3",
|
|
534
|
+
"",
|
|
535
|
+
"[Install]",
|
|
536
|
+
"WantedBy=multi-user.target",
|
|
537
|
+
"TS_CLOUD_NGINXUNIT_EOF",
|
|
538
|
+
"systemctl daemon-reload",
|
|
539
|
+
"systemctl enable ts-cloud-nginx",
|
|
540
|
+
"ls /etc/nginx/sites-enabled/* >/dev/null 2>&1 && systemctl restart ts-cloud-nginx || true"
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/drivers/shared/ufw.ts
|
|
545
|
+
var UFW_BASE_PORTS = [80, 443];
|
|
546
|
+
function buildUfwScript(firewall = {}) {
|
|
547
|
+
if (firewall.enabled === false)
|
|
548
|
+
return [];
|
|
549
|
+
const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
|
|
550
|
+
const lines = [
|
|
551
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
552
|
+
"apt-get install -y ufw",
|
|
553
|
+
"ufw default deny incoming",
|
|
554
|
+
"ufw default allow outgoing",
|
|
555
|
+
"ufw allow OpenSSH"
|
|
556
|
+
];
|
|
557
|
+
for (const port of ports)
|
|
558
|
+
lines.push(`ufw allow ${port}/tcp`);
|
|
559
|
+
lines.push("ufw --force enable");
|
|
560
|
+
return lines;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/drivers/shared/maintenance.ts
|
|
564
|
+
function buildAutoUpdatesScript(enabled2 = true) {
|
|
565
|
+
if (!enabled2)
|
|
566
|
+
return [];
|
|
567
|
+
return [
|
|
568
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
569
|
+
"apt-get install -y unattended-upgrades",
|
|
570
|
+
"cat > /etc/apt/apt.conf.d/20auto-upgrades <<'TS_CLOUD_AUTOUPD_EOF'",
|
|
571
|
+
'APT::Periodic::Update-Package-Lists "1";',
|
|
572
|
+
'APT::Periodic::Unattended-Upgrade "1";',
|
|
573
|
+
'APT::Periodic::Download-Upgradeable-Packages "1";',
|
|
574
|
+
'APT::Periodic::AutocleanInterval "7";',
|
|
575
|
+
"TS_CLOUD_AUTOUPD_EOF",
|
|
576
|
+
"systemctl enable unattended-upgrades 2>/dev/null || true",
|
|
577
|
+
"systemctl restart unattended-upgrades 2>/dev/null || true"
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/drivers/shared/monitoring.ts
|
|
582
|
+
var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
|
|
583
|
+
var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
|
|
584
|
+
var DEFAULT_CPU_LOAD_PER_CORE = 2;
|
|
585
|
+
var DEFAULT_MEM_PERCENT = 90;
|
|
586
|
+
var DEFAULT_DISK_PERCENT = 90;
|
|
587
|
+
var SERVICE_PROBES = [
|
|
588
|
+
["nginx", 80],
|
|
589
|
+
["phpFpm", 9074],
|
|
590
|
+
["mysql", 3306],
|
|
591
|
+
["postgres", 5432],
|
|
592
|
+
["redis", 6379],
|
|
593
|
+
["meilisearch", 7700]
|
|
594
|
+
];
|
|
595
|
+
function resolveMonitoring(monitoring = true) {
|
|
596
|
+
const obj = typeof monitoring === "object" ? monitoring : {};
|
|
597
|
+
const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
|
|
598
|
+
return {
|
|
599
|
+
enabled: enabled2,
|
|
600
|
+
cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
|
|
601
|
+
memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
|
|
602
|
+
diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function buildMonitoringScript(monitoring = true) {
|
|
606
|
+
const { enabled: enabled2, cpuLoadPerCore, memPercent, diskPercent } = resolveMonitoring(monitoring);
|
|
607
|
+
if (!enabled2)
|
|
608
|
+
return [];
|
|
609
|
+
const probeLines = SERVICE_PROBES.map(([name, port]) => `SVC_${name.toUpperCase()}=$(probe ${port})`);
|
|
610
|
+
const servicesJson = SERVICE_PROBES.map(([name]) => `"${name}":"$SVC_${name.toUpperCase()}"`).join(",");
|
|
611
|
+
return [
|
|
612
|
+
"mkdir -p /var/lib/ts-cloud",
|
|
613
|
+
"cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
|
|
614
|
+
"#!/bin/bash",
|
|
615
|
+
"set -uo pipefail",
|
|
616
|
+
"LOAD=$(cut -d' ' -f1 /proc/loadavg)",
|
|
617
|
+
"CPUS=$(nproc)",
|
|
618
|
+
"MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
|
|
619
|
+
"MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
|
|
620
|
+
"SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
|
|
621
|
+
"SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
|
|
622
|
+
`DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
|
|
623
|
+
"UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
|
|
624
|
+
`RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
|
|
625
|
+
`TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
|
|
626
|
+
"probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
|
|
627
|
+
...probeLines,
|
|
628
|
+
"LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
|
|
629
|
+
"SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
|
|
630
|
+
"UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
|
|
631
|
+
"MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
|
|
632
|
+
`cat > ${METRICS_PATH}.tmp <<JSON`,
|
|
633
|
+
'{"load":$LOAD,"cpus":$CPUS,"memTotalMb":$MEM_TOTAL,"memUsedMb":$MEM_USED,"memUsedPct":$MEM_PCT,"swapTotalMb":$SWAP_TOTAL,"swapUsedMb":$SWAP_USED,"diskUsedPct":$DISK_PCT,"uptimeSec":$UPTIME_SEC,"network":{"rxBytes":$RX_BYTES,"txBytes":$TX_BYTES},"services":{' + servicesJson + "}}",
|
|
634
|
+
"JSON",
|
|
635
|
+
`mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
|
|
636
|
+
'ALERTS=""',
|
|
637
|
+
`if awk -v l="$LOAD" -v c="$CPUS" -v t=${cpuLoadPerCore} 'BEGIN{exit !(c>0 && l/c > t)}'; then ALERTS="$ALERTS load=$LOAD/${cpuLoadPerCore}xCPU"; fi`,
|
|
638
|
+
`if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
|
|
639
|
+
`if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
|
|
640
|
+
`PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
|
|
641
|
+
'if [ -n "$ALERTS" ]; then',
|
|
642
|
+
' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
|
|
643
|
+
` echo alert > ${ALERT_STATE_PATH}`,
|
|
644
|
+
"else",
|
|
645
|
+
' if [ "$PREV" = alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "✅ $(hostname): resource usage back to normal" || true; fi',
|
|
646
|
+
` echo ok > ${ALERT_STATE_PATH}`,
|
|
647
|
+
"fi",
|
|
648
|
+
"TS_CLOUD_METRICS_EOF",
|
|
649
|
+
"chmod +x /usr/local/bin/ts-cloud-metrics.sh",
|
|
650
|
+
"cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
|
|
651
|
+
"[Unit]",
|
|
652
|
+
"Description=ts-cloud metrics collector",
|
|
653
|
+
"",
|
|
654
|
+
"[Service]",
|
|
655
|
+
"Type=oneshot",
|
|
656
|
+
"ExecStart=/usr/local/bin/ts-cloud-metrics.sh",
|
|
657
|
+
"TS_CLOUD_METRICS_SVC_EOF",
|
|
658
|
+
"cat > /etc/systemd/system/ts-cloud-metrics.timer <<'TS_CLOUD_METRICS_TMR_EOF'",
|
|
659
|
+
"[Unit]",
|
|
660
|
+
"Description=Run ts-cloud metrics collector every minute",
|
|
661
|
+
"",
|
|
662
|
+
"[Timer]",
|
|
663
|
+
"OnBootSec=60",
|
|
664
|
+
"OnUnitActiveSec=60",
|
|
665
|
+
"",
|
|
666
|
+
"[Install]",
|
|
667
|
+
"WantedBy=timers.target",
|
|
668
|
+
"TS_CLOUD_METRICS_TMR_EOF",
|
|
669
|
+
"systemctl daemon-reload",
|
|
670
|
+
"systemctl enable ts-cloud-metrics.timer",
|
|
671
|
+
"systemctl start ts-cloud-metrics.timer"
|
|
672
|
+
];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/drivers/shared/ssh-keys.ts
|
|
676
|
+
var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
|
|
677
|
+
var BLOCK_END = "# <<< ts-cloud managed keys <<<";
|
|
678
|
+
var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
679
|
+
function buildAuthorizedKeysScript(keys = [], options = {}) {
|
|
680
|
+
if (keys.length === 0)
|
|
681
|
+
return [];
|
|
682
|
+
const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
|
|
683
|
+
const dir = path.replace(/\/[^/]*$/, "");
|
|
684
|
+
const block = [
|
|
685
|
+
BLOCK_BEGIN,
|
|
686
|
+
...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
|
|
687
|
+
BLOCK_END
|
|
688
|
+
].join(`
|
|
689
|
+
`);
|
|
690
|
+
return [
|
|
691
|
+
`mkdir -p ${dir}`,
|
|
692
|
+
`touch ${path}`,
|
|
693
|
+
`sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
|
|
694
|
+
`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
|
|
695
|
+
block,
|
|
696
|
+
"TS_CLOUD_KEYS_EOF",
|
|
697
|
+
`chmod 600 ${path}`
|
|
698
|
+
];
|
|
699
|
+
}
|
|
700
|
+
function escapeSed(value) {
|
|
701
|
+
return value.replace(/[.*[\]\\/^$]/g, "\\$&");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/drivers/shared/notifications.ts
|
|
705
|
+
function wantsEvent(config, event) {
|
|
706
|
+
return !config.events || config.events.includes(event);
|
|
707
|
+
}
|
|
708
|
+
async function sendNotifications(config, event, message, options = {}) {
|
|
709
|
+
if (!config)
|
|
710
|
+
return [];
|
|
711
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
712
|
+
const attempted = [];
|
|
713
|
+
const tasks = [];
|
|
714
|
+
const post = (url, body) => fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {
|
|
715
|
+
return;
|
|
716
|
+
});
|
|
717
|
+
if (config.slack?.webhookUrl && wantsEvent(config, event)) {
|
|
718
|
+
attempted.push("slack");
|
|
719
|
+
tasks.push(post(config.slack.webhookUrl, { text: message }));
|
|
720
|
+
}
|
|
721
|
+
if (config.discord?.webhookUrl && wantsEvent(config, event)) {
|
|
722
|
+
attempted.push("discord");
|
|
723
|
+
tasks.push(post(config.discord.webhookUrl, { content: message }));
|
|
724
|
+
}
|
|
725
|
+
if (config.telegram?.botToken && config.telegram.chatId && wantsEvent(config, event)) {
|
|
726
|
+
attempted.push("telegram");
|
|
727
|
+
const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
|
|
728
|
+
tasks.push(post(url, { chat_id: config.telegram.chatId, text: message }));
|
|
729
|
+
}
|
|
730
|
+
if (config.webhook?.url && wantsEvent(config, event)) {
|
|
731
|
+
attempted.push("webhook");
|
|
732
|
+
if ((config.webhook.method || "POST") === "GET") {
|
|
733
|
+
const sep = config.webhook.url.includes("?") ? "&" : "?";
|
|
734
|
+
const url = `${config.webhook.url}${sep}event=${encodeURIComponent(event)}&message=${encodeURIComponent(message)}`;
|
|
735
|
+
tasks.push(fetchImpl(url, { method: "GET" }).catch(() => {
|
|
736
|
+
return;
|
|
737
|
+
}));
|
|
738
|
+
} else {
|
|
739
|
+
tasks.push(post(config.webhook.url, { event, message }));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (config.email?.to && wantsEvent(config, event)) {
|
|
743
|
+
attempted.push("email");
|
|
744
|
+
tasks.push(sendEmailNotification(config.email, event, message));
|
|
745
|
+
}
|
|
746
|
+
await Promise.all(tasks);
|
|
747
|
+
return attempted;
|
|
748
|
+
}
|
|
749
|
+
async function sendEmailNotification(email, event, message) {
|
|
750
|
+
try {
|
|
751
|
+
const mod = await import("./chunk-pqfzdg68.js");
|
|
752
|
+
await mod.email.send({
|
|
753
|
+
to: email.to,
|
|
754
|
+
from: email.from,
|
|
755
|
+
subject: `[ts-cloud] ${event}`,
|
|
756
|
+
text: message
|
|
757
|
+
});
|
|
758
|
+
} catch {}
|
|
759
|
+
}
|
|
760
|
+
function resolveNotifications(project, site) {
|
|
761
|
+
return site ?? project;
|
|
762
|
+
}
|
|
763
|
+
function buildNotifierScript(config) {
|
|
764
|
+
if (!config)
|
|
765
|
+
return [];
|
|
766
|
+
const curls = [];
|
|
767
|
+
if (config.slack?.webhookUrl)
|
|
768
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"text\\":\\"$MSG\\"}" ${config.slack.webhookUrl} || true`);
|
|
769
|
+
if (config.discord?.webhookUrl)
|
|
770
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"content\\":\\"$MSG\\"}" ${config.discord.webhookUrl} || true`);
|
|
771
|
+
if (config.telegram?.botToken && config.telegram.chatId)
|
|
772
|
+
curls.push(`curl -fsS -X POST "https://api.telegram.org/bot${config.telegram.botToken}/sendMessage" --data-urlencode "chat_id=${config.telegram.chatId}" --data-urlencode "text=$MSG" || true`);
|
|
773
|
+
if (config.webhook?.url)
|
|
774
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"message\\":\\"$MSG\\"}" ${config.webhook.url} || true`);
|
|
775
|
+
if (curls.length === 0)
|
|
776
|
+
return [];
|
|
777
|
+
return [
|
|
778
|
+
"cat > /usr/local/bin/ts-cloud-notify <<'TS_CLOUD_NOTIFY_EOF'",
|
|
779
|
+
"#!/bin/bash",
|
|
780
|
+
'MSG="$1"',
|
|
781
|
+
...curls,
|
|
782
|
+
"TS_CLOUD_NOTIFY_EOF",
|
|
783
|
+
"chmod +x /usr/local/bin/ts-cloud-notify"
|
|
784
|
+
];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/drivers/shared/backups.ts
|
|
788
|
+
var BACKUP_OUTPUT_DIR = "/var/backups/ts-cloud";
|
|
789
|
+
var BACKUP_CONFIG_PATH = "/etc/ts-cloud/backups.config.ts";
|
|
790
|
+
var BACKUP_CRON_PATH = "/etc/cron.d/ts-cloud-backups";
|
|
791
|
+
var BACKUP_RUNNER_PATH = "/usr/local/bin/ts-cloud-backup.sh";
|
|
792
|
+
function backupEntryFor(database) {
|
|
793
|
+
if (!database.name)
|
|
794
|
+
return null;
|
|
795
|
+
const host = database.host || "127.0.0.1";
|
|
796
|
+
const isPostgres = database.engine === "postgres";
|
|
797
|
+
const type = isPostgres ? "postgresql" : "mysql";
|
|
798
|
+
const port = database.port ?? (isPostgres ? 5432 : 3306);
|
|
799
|
+
const ts = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
800
|
+
return [
|
|
801
|
+
" {",
|
|
802
|
+
` type: '${type}',`,
|
|
803
|
+
` name: '${ts(database.name)}',`,
|
|
804
|
+
" connection: {",
|
|
805
|
+
` hostname: '${ts(host)}',`,
|
|
806
|
+
` port: ${port},`,
|
|
807
|
+
` database: '${ts(database.name)}',`,
|
|
808
|
+
` username: '${ts(database.username || database.name)}',`,
|
|
809
|
+
` password: '${ts(database.password || "")}',`,
|
|
810
|
+
" ssl: false,",
|
|
811
|
+
" },",
|
|
812
|
+
" includeSchema: true,",
|
|
813
|
+
" includeData: true,",
|
|
814
|
+
" },"
|
|
815
|
+
].join(`
|
|
816
|
+
`);
|
|
817
|
+
}
|
|
818
|
+
function buildBackupsConfigTs(database, backups) {
|
|
819
|
+
const entry = database ? backupEntryFor(database) : null;
|
|
820
|
+
const destinations = backups.bucket ? [
|
|
821
|
+
" destinations: [",
|
|
822
|
+
" {",
|
|
823
|
+
" type: 's3',",
|
|
824
|
+
` bucket: '${backups.bucket}',`,
|
|
825
|
+
" prefix: 'db-backups',",
|
|
826
|
+
...backups.endpoint ? [` endpoint: '${backups.endpoint}',`] : [],
|
|
827
|
+
" optional: false,",
|
|
828
|
+
" },",
|
|
829
|
+
" ],"
|
|
830
|
+
] : [];
|
|
831
|
+
return [
|
|
832
|
+
"import type { BackupConfig } from 'ts-backups'",
|
|
833
|
+
"",
|
|
834
|
+
"const config: BackupConfig = {",
|
|
835
|
+
" verbose: true,",
|
|
836
|
+
` outputPath: '${BACKUP_OUTPUT_DIR}',`,
|
|
837
|
+
" retention: {",
|
|
838
|
+
` count: ${backups.retentionCount ?? 5},`,
|
|
839
|
+
` maxAge: ${backups.retentionDays ?? 30},`,
|
|
840
|
+
" },",
|
|
841
|
+
" databases: [",
|
|
842
|
+
...entry ? [entry] : [],
|
|
843
|
+
" ],",
|
|
844
|
+
...destinations,
|
|
845
|
+
"}",
|
|
846
|
+
"",
|
|
847
|
+
"export default config",
|
|
848
|
+
""
|
|
849
|
+
].join(`
|
|
850
|
+
`);
|
|
851
|
+
}
|
|
852
|
+
function buildBackupProvisionScript(options) {
|
|
853
|
+
const { database, backups } = options;
|
|
854
|
+
if (!backups.enabled)
|
|
855
|
+
return [];
|
|
856
|
+
const schedule = backups.schedule || "0 2 * * *";
|
|
857
|
+
const configTs = buildBackupsConfigTs(database, backups);
|
|
858
|
+
return [
|
|
859
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
860
|
+
`mkdir -p /etc/ts-cloud ${BACKUP_OUTPUT_DIR}`,
|
|
861
|
+
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf /root/.bun/bin/bun /usr/local/bin/bun)",
|
|
862
|
+
"bun add -g ts-backups || true",
|
|
863
|
+
`cat > ${BACKUP_CONFIG_PATH} <<'TS_CLOUD_BACKUP_CFG_EOF'`,
|
|
864
|
+
configTs.replace(/\n$/, ""),
|
|
865
|
+
"TS_CLOUD_BACKUP_CFG_EOF",
|
|
866
|
+
`cat > ${BACKUP_RUNNER_PATH} <<'TS_CLOUD_BACKUP_RUN_EOF'`,
|
|
867
|
+
"#!/bin/bash",
|
|
868
|
+
"set -uo pipefail",
|
|
869
|
+
'export PATH="/root/.bun/bin:/usr/local/bin:$PATH"',
|
|
870
|
+
'notify() { [ -x /usr/local/bin/ts-cloud-notify ] && /usr/local/bin/ts-cloud-notify "$1" || true; }',
|
|
871
|
+
"cd /etc/ts-cloud",
|
|
872
|
+
'if ! ts-backups backup --config /etc/ts-cloud/backups.config.ts; then notify "❌ ts-cloud backup failed"; exit 1; fi',
|
|
873
|
+
"TS_CLOUD_BACKUP_RUN_EOF",
|
|
874
|
+
`chmod +x ${BACKUP_RUNNER_PATH}`,
|
|
875
|
+
`cat > ${BACKUP_CRON_PATH} <<'TS_CLOUD_BACKUP_CRON_EOF'`,
|
|
876
|
+
`${schedule} root ${BACKUP_RUNNER_PATH} >> /var/log/ts-cloud-backup.log 2>&1`,
|
|
877
|
+
"TS_CLOUD_BACKUP_CRON_EOF",
|
|
878
|
+
`chmod 644 ${BACKUP_CRON_PATH}`
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
function engineSocket(engine) {
|
|
882
|
+
return engine === "mariadb" ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
|
|
883
|
+
}
|
|
884
|
+
function buildBackupRestoreScript(database, options = {}) {
|
|
885
|
+
if (!database?.name)
|
|
886
|
+
return [];
|
|
887
|
+
const name = database.name;
|
|
888
|
+
const isPg = database.engine === "postgres";
|
|
889
|
+
const locate = options.from ? `TS_CLOUD_DUMP="${options.from}"` : `TS_CLOUD_DUMP="$(find ${BACKUP_OUTPUT_DIR} -type f -name '*${name}*.sql' -o -type f -name '*${name}*.sql.gz' 2>/dev/null | xargs -r ls -1t 2>/dev/null | head -1)"`;
|
|
890
|
+
const client = isPg ? `psql -h 127.0.0.1 -p ${database.port ?? 5432} -U postgres -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
|
|
891
|
+
return [
|
|
892
|
+
"set -uo pipefail",
|
|
893
|
+
'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
|
|
894
|
+
locate,
|
|
895
|
+
'[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
|
|
896
|
+
'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
|
|
897
|
+
`case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client}`,
|
|
898
|
+
'echo "restore complete"'
|
|
899
|
+
];
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/drivers/shared/compute-provision.ts
|
|
903
|
+
function buildComputeProvisionScripts(config) {
|
|
904
|
+
const compute = config.infrastructure?.compute ?? {};
|
|
905
|
+
const phpBox = compute.runtime === "php" || !!compute.php;
|
|
906
|
+
const needsPantry = phpBox || !!compute.managedServices;
|
|
907
|
+
const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
|
|
908
|
+
const useNginx = compute.webServer !== "rpx";
|
|
909
|
+
const phpProvision = phpBox ? [
|
|
910
|
+
...pantryBootstrap,
|
|
911
|
+
...buildPhpProvisionScript({
|
|
912
|
+
versions: compute.php?.versions,
|
|
913
|
+
default: compute.php?.default,
|
|
914
|
+
extensions: compute.php?.extensions,
|
|
915
|
+
installNginx: useNginx,
|
|
916
|
+
optimizeForProduction: compute.php?.optimizeForProduction,
|
|
917
|
+
ini: compute.php?.ini
|
|
918
|
+
}),
|
|
919
|
+
...useNginx ? buildNginxServiceScript() : []
|
|
920
|
+
] : undefined;
|
|
921
|
+
const extras = [];
|
|
922
|
+
if (!phpBox && needsPantry)
|
|
923
|
+
extras.push(...pantryBootstrap);
|
|
924
|
+
extras.push(...buildNotifierScript(config.notifications));
|
|
925
|
+
if (compute.managedServices) {
|
|
926
|
+
extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
|
|
927
|
+
}
|
|
928
|
+
extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
|
|
929
|
+
extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
|
|
930
|
+
extras.push(...buildMonitoringScript(compute.monitoring ?? phpBox));
|
|
931
|
+
extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
|
|
932
|
+
if (compute.backups?.enabled) {
|
|
933
|
+
extras.push(...buildBackupProvisionScript({
|
|
934
|
+
database: config.infrastructure?.appDatabase,
|
|
935
|
+
backups: compute.backups
|
|
936
|
+
}));
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
runtime: compute.runtime || "bun",
|
|
940
|
+
runtimeVersion: compute.runtimeVersion || "latest",
|
|
941
|
+
phpBox,
|
|
942
|
+
phpProvision,
|
|
943
|
+
servicesProvision: extras.length > 0 ? extras : undefined
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// src/drivers/shared/ubuntu-bootstrap.ts
|
|
948
|
+
function buildUbuntuBootstrapScript(options = {}) {
|
|
949
|
+
const {
|
|
950
|
+
runtime = "bun",
|
|
951
|
+
runtimeVersion = "latest",
|
|
952
|
+
systemPackages = [],
|
|
953
|
+
database,
|
|
954
|
+
phpProvision,
|
|
955
|
+
servicesProvision,
|
|
956
|
+
caddyfile,
|
|
957
|
+
rpxProvision,
|
|
958
|
+
baked = false
|
|
959
|
+
} = options;
|
|
960
|
+
const packages = new Set(systemPackages);
|
|
961
|
+
if (database === "sqlite")
|
|
962
|
+
packages.add("sqlite3");
|
|
963
|
+
else if (database === "mysql")
|
|
964
|
+
packages.add("mysql-client");
|
|
965
|
+
else if (database === "postgres")
|
|
966
|
+
packages.add("postgresql-client");
|
|
967
|
+
let script = `#!/bin/bash
|
|
968
|
+
set -euo pipefail
|
|
969
|
+
`;
|
|
970
|
+
if (!baked) {
|
|
971
|
+
script += `
|
|
972
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
973
|
+
apt-get update -y
|
|
974
|
+
apt-get upgrade -y
|
|
975
|
+
apt-get install -y curl tar gzip unzip git ca-certificates
|
|
976
|
+
`;
|
|
977
|
+
if (packages.size > 0) {
|
|
978
|
+
script += `
|
|
979
|
+
apt-get install -y ${[...packages].join(" ")}
|
|
980
|
+
`;
|
|
981
|
+
}
|
|
982
|
+
if (phpProvision && phpProvision.length > 0) {
|
|
983
|
+
script += `
|
|
984
|
+
${phpProvision.join(`
|
|
985
|
+
`)}
|
|
986
|
+
`;
|
|
987
|
+
}
|
|
988
|
+
if (servicesProvision && servicesProvision.length > 0) {
|
|
989
|
+
script += `
|
|
990
|
+
${servicesProvision.join(`
|
|
991
|
+
`)}
|
|
992
|
+
`;
|
|
993
|
+
}
|
|
994
|
+
if (runtime === "bun") {
|
|
995
|
+
script += `
|
|
996
|
+
# cloud-init runs this with \`set -u\` and NO $HOME set. bun's install.sh
|
|
997
|
+
# dereferences $HOME even when BUN_INSTALL is set, so without this the install
|
|
998
|
+
# aborts with "HOME: unbound variable" and /usr/local/bin/bun never appears —
|
|
999
|
+
# breaking a from-scratch provision (adopting an existing box masked it).
|
|
1000
|
+
export HOME="\${HOME:-/root}"
|
|
1001
|
+
export BUN_INSTALL="/root/.bun"
|
|
1002
|
+
# bun.sh's installer references $HOME internally and runs under this
|
|
1003
|
+
# script's \`set -u\` (inherited by the piped bash) — cloud-init's runcmd
|
|
1004
|
+
# environment doesn't export HOME by default, so without this the
|
|
1005
|
+
# installer dies with "HOME: unbound variable" partway through (after
|
|
1006
|
+
# the binary is already downloaded, so it looks like a partial success)
|
|
1007
|
+
# and \`set -e\` aborts everything after it: the bun symlink, /var/www +
|
|
1008
|
+
# /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
|
|
1009
|
+
# never run. Confirmed against a real Hetzner box (stacksjs/status#1
|
|
1010
|
+
# Phase 9 e2e deploy) — cloud-init reported success with none of that
|
|
1011
|
+
# actually done.
|
|
1012
|
+
export HOME="\${HOME:-/root}"
|
|
1013
|
+
curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
|
|
1014
|
+
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
|
1015
|
+
echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
|
|
1016
|
+
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> /etc/profile.d/bun.sh
|
|
1017
|
+
`;
|
|
1018
|
+
} else if (runtime === "node") {
|
|
1019
|
+
const nodeMajor = runtimeVersion === "latest" || !runtimeVersion ? "20" : runtimeVersion.split(".")[0];
|
|
1020
|
+
script += `
|
|
1021
|
+
curl -fsSL https://deb.nodesource.com/setup_${nodeMajor}.x | bash -
|
|
1022
|
+
apt-get install -y nodejs
|
|
1023
|
+
ln -sf /usr/bin/node /usr/local/bin/node
|
|
1024
|
+
ln -sf /usr/bin/npm /usr/local/bin/npm
|
|
1025
|
+
`;
|
|
1026
|
+
} else if (runtime === "deno") {
|
|
1027
|
+
script += `
|
|
1028
|
+
curl -fsSL https://deno.land/install.sh | sh
|
|
1029
|
+
ln -sf /root/.deno/bin/deno /usr/local/bin/deno
|
|
1030
|
+
`;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
script += `
|
|
1034
|
+
mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
|
|
1035
|
+
`;
|
|
1036
|
+
if (caddyfile) {
|
|
1037
|
+
const escaped = caddyfile.replace(/\$/g, "\\$");
|
|
1038
|
+
script += `
|
|
1039
|
+
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
|
1040
|
+
curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
|
|
1041
|
+
chmod +x /usr/local/bin/caddy
|
|
1042
|
+
|
|
1043
|
+
getent group caddy >/dev/null || groupadd --system caddy
|
|
1044
|
+
getent passwd caddy >/dev/null || useradd --system --gid caddy \\
|
|
1045
|
+
--create-home --home-dir /var/lib/caddy \\
|
|
1046
|
+
--shell /usr/sbin/nologin --comment "Caddy web server" caddy
|
|
1047
|
+
|
|
1048
|
+
mkdir -p /etc/caddy /var/lib/caddy /var/log/caddy
|
|
1049
|
+
chown -R caddy:caddy /var/lib/caddy /var/log/caddy
|
|
1050
|
+
|
|
1051
|
+
cat > /etc/systemd/system/caddy.service <<'CADDY_UNIT_EOF'
|
|
1052
|
+
[Unit]
|
|
1053
|
+
Description=Caddy
|
|
1054
|
+
Documentation=https://caddyserver.com/docs/
|
|
1055
|
+
After=network.target network-online.target
|
|
1056
|
+
Requires=network-online.target
|
|
1057
|
+
|
|
1058
|
+
[Service]
|
|
1059
|
+
Type=notify
|
|
1060
|
+
User=caddy
|
|
1061
|
+
Group=caddy
|
|
1062
|
+
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
|
1063
|
+
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
|
|
1064
|
+
TimeoutStopSec=5s
|
|
1065
|
+
LimitNOFILE=1048576
|
|
1066
|
+
PrivateTmp=true
|
|
1067
|
+
ProtectSystem=full
|
|
1068
|
+
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
1069
|
+
|
|
1070
|
+
[Install]
|
|
1071
|
+
WantedBy=multi-user.target
|
|
1072
|
+
CADDY_UNIT_EOF
|
|
1073
|
+
|
|
1074
|
+
cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
|
|
1075
|
+
${escaped}
|
|
1076
|
+
CADDY_CONFIG_EOF
|
|
1077
|
+
|
|
1078
|
+
systemctl daemon-reload
|
|
1079
|
+
systemctl enable caddy
|
|
1080
|
+
systemctl start caddy
|
|
1081
|
+
`;
|
|
1082
|
+
}
|
|
1083
|
+
if (rpxProvision && rpxProvision.length > 0) {
|
|
1084
|
+
const body = rpxProvision[0] === "set -euo pipefail" ? rpxProvision.slice(1) : rpxProvision;
|
|
1085
|
+
script += `
|
|
1086
|
+
${body.join(`
|
|
1087
|
+
`)}
|
|
1088
|
+
`;
|
|
1089
|
+
}
|
|
1090
|
+
script += `
|
|
1091
|
+
echo "ts-cloud bootstrap complete — instance is ready for site deploys"
|
|
1092
|
+
`;
|
|
1093
|
+
return script;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/drivers/aws/provision.ts
|
|
1097
|
+
var UBUNTU_AMI_SSM_PARAM = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id";
|
|
1098
|
+
function awsComputeIngressRules(config) {
|
|
1099
|
+
const rules = [
|
|
1100
|
+
{ port: 22, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1101
|
+
{ port: 80, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1102
|
+
{ port: 443, protocol: "tcp", cidr: "0.0.0.0/0" }
|
|
1103
|
+
];
|
|
1104
|
+
const extra = new Set;
|
|
1105
|
+
for (const site of Object.values(config.sites || {})) {
|
|
1106
|
+
if (site && typeof site.port === "number" && ![22, 80, 443].includes(site.port))
|
|
1107
|
+
extra.add(site.port);
|
|
1108
|
+
}
|
|
1109
|
+
for (const port of extra)
|
|
1110
|
+
rules.push({ port, protocol: "tcp", cidr: "0.0.0.0/0" });
|
|
1111
|
+
return rules;
|
|
1112
|
+
}
|
|
1113
|
+
function buildAwsUserData(config) {
|
|
1114
|
+
const compute = config.infrastructure?.compute ?? {};
|
|
1115
|
+
const provision = buildComputeProvisionScripts(config);
|
|
1116
|
+
return buildUbuntuBootstrapScript({
|
|
1117
|
+
runtime: provision.runtime,
|
|
1118
|
+
runtimeVersion: provision.runtimeVersion,
|
|
1119
|
+
systemPackages: compute.systemPackages,
|
|
1120
|
+
database: config.infrastructure?.database,
|
|
1121
|
+
phpProvision: provision.phpProvision,
|
|
1122
|
+
servicesProvision: provision.servicesProvision,
|
|
1123
|
+
baked: compute.bakedImage === true
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
function encodeUserData(userData) {
|
|
1127
|
+
return Buffer.from(userData, "utf8").toString("base64");
|
|
1128
|
+
}
|
|
1129
|
+
function resolveAwsImageId(config) {
|
|
1130
|
+
return config.infrastructure?.compute?.image ?? null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/drivers/aws/driver.ts
|
|
1134
|
+
function readPinnedInstanceId(stackName) {
|
|
1135
|
+
try {
|
|
1136
|
+
const raw = readFileSync(join(process.cwd(), ".ts-cloud/state", `${stackName}.json`), "utf8");
|
|
1137
|
+
const state = JSON.parse(raw);
|
|
1138
|
+
return typeof state.instanceId === "string" && state.instanceId.length > 0 ? state.instanceId : null;
|
|
1139
|
+
} catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
class AwsDriver {
|
|
1145
|
+
name = "aws";
|
|
1146
|
+
usesCloudFormation = true;
|
|
1147
|
+
region;
|
|
1148
|
+
constructor(options = {}) {
|
|
1149
|
+
this.region = options.region || "us-east-1";
|
|
1150
|
+
}
|
|
1151
|
+
resolveRegion(config) {
|
|
1152
|
+
return config.project.region || this.region;
|
|
1153
|
+
}
|
|
1154
|
+
async provisionComputeInfrastructure(options) {
|
|
1155
|
+
const { config, environment } = options;
|
|
1156
|
+
const region = this.resolveRegion(config);
|
|
1157
|
+
const slug = config.project.slug;
|
|
1158
|
+
const compute = config.infrastructure?.compute;
|
|
1159
|
+
if (!compute)
|
|
1160
|
+
throw new Error("infrastructure.compute is required to provision AWS compute");
|
|
1161
|
+
const ec2 = new EC2Client(region);
|
|
1162
|
+
const existing = await this.findComputeTargets({ slug, environment, role: "app" });
|
|
1163
|
+
if (existing.length > 0) {
|
|
1164
|
+
const first = existing[0];
|
|
1165
|
+
return { appInstanceId: first.id, appPublicIp: first.publicIp, sshUser: "ubuntu", deployStoragePath: "/var/ts-cloud/staging" };
|
|
1166
|
+
}
|
|
1167
|
+
let imageId = resolveAwsImageId(config);
|
|
1168
|
+
if (!imageId) {
|
|
1169
|
+
const ssm = new SSMClient(region);
|
|
1170
|
+
const param = await ssm.getParameter({ Name: UBUNTU_AMI_SSM_PARAM });
|
|
1171
|
+
imageId = param.Parameter?.Value ?? null;
|
|
1172
|
+
if (!imageId)
|
|
1173
|
+
throw new Error("Could not resolve the Ubuntu 24.04 AMI from SSM");
|
|
1174
|
+
}
|
|
1175
|
+
const vpcs = await ec2.describeVpcs();
|
|
1176
|
+
const vpc = (vpcs.Vpcs || []).find((v) => v.IsDefault) || (vpcs.Vpcs || [])[0];
|
|
1177
|
+
if (!vpc?.VpcId)
|
|
1178
|
+
throw new Error("No VPC found to launch the instance into");
|
|
1179
|
+
const subnets = (await ec2.describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpc.VpcId] }] })).Subnets || [];
|
|
1180
|
+
const subnet = subnets.find((s) => s.MapPublicIpOnLaunch) || subnets[0];
|
|
1181
|
+
const subnetId = subnet?.SubnetId;
|
|
1182
|
+
if (!subnet?.MapPublicIpOnLaunch)
|
|
1183
|
+
console.warn("ts-cloud: no public subnet found; the instance may not get a public IP (deploys/SSL need one).");
|
|
1184
|
+
const sgName = `${slug}-${environment}-app-sg`;
|
|
1185
|
+
const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }, { Name: "vpc-id", Values: [vpc.VpcId] }] });
|
|
1186
|
+
let groupId = found.SecurityGroups?.[0]?.GroupId;
|
|
1187
|
+
if (!groupId) {
|
|
1188
|
+
const created = await ec2.createSecurityGroup({ GroupName: sgName, Description: `ts-cloud ${slug}/${environment} app`, VpcId: vpc.VpcId });
|
|
1189
|
+
groupId = created.GroupId;
|
|
1190
|
+
}
|
|
1191
|
+
const rules = awsComputeIngressRules(config);
|
|
1192
|
+
await ec2.authorizeSecurityGroupIngress({
|
|
1193
|
+
GroupId: groupId,
|
|
1194
|
+
IpPermissions: rules.map((r) => ({ IpProtocol: r.protocol, FromPort: r.port, ToPort: r.port, IpRanges: [{ CidrIp: r.cidr }] }))
|
|
1195
|
+
}).catch((e) => {
|
|
1196
|
+
if (!/InvalidPermission\.Duplicate/.test(e instanceof Error ? e.message : ""))
|
|
1197
|
+
throw e;
|
|
1198
|
+
});
|
|
1199
|
+
const userData = encodeUserData(buildAwsUserData(config));
|
|
1200
|
+
const instanceType = compute.server?.instanceType || "t3.small";
|
|
1201
|
+
const run = await ec2.runInstances({
|
|
1202
|
+
ImageId: imageId,
|
|
1203
|
+
InstanceType: instanceType,
|
|
1204
|
+
MinCount: 1,
|
|
1205
|
+
MaxCount: 1,
|
|
1206
|
+
SecurityGroupIds: groupId ? [groupId] : undefined,
|
|
1207
|
+
SubnetId: subnetId,
|
|
1208
|
+
UserData: userData,
|
|
1209
|
+
IamInstanceProfile: compute.server?.iamInstanceProfile ? { Name: compute.server.iamInstanceProfile } : undefined,
|
|
1210
|
+
TagSpecifications: [{
|
|
1211
|
+
ResourceType: "instance",
|
|
1212
|
+
Tags: [
|
|
1213
|
+
{ Key: "Name", Value: `${slug}-${environment}-app` },
|
|
1214
|
+
{ Key: "Project", Value: slug },
|
|
1215
|
+
{ Key: "Environment", Value: environment },
|
|
1216
|
+
{ Key: "Role", Value: "app" }
|
|
1217
|
+
]
|
|
1218
|
+
}]
|
|
1219
|
+
});
|
|
1220
|
+
const instanceId = run.Instances?.[0]?.InstanceId;
|
|
1221
|
+
if (!instanceId)
|
|
1222
|
+
throw new Error("RunInstances did not return an instance id");
|
|
1223
|
+
const running = await ec2.waitForInstanceState(instanceId, "running");
|
|
1224
|
+
if (!running)
|
|
1225
|
+
throw new Error(`Instance ${instanceId} did not reach 'running' before timeout`);
|
|
1226
|
+
return {
|
|
1227
|
+
appInstanceId: instanceId,
|
|
1228
|
+
appPublicIp: running.PublicIpAddress,
|
|
1229
|
+
sshUser: "ubuntu",
|
|
1230
|
+
deployStoragePath: "/var/ts-cloud/staging"
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async destroyCompute(options) {
|
|
1234
|
+
const { config, environment } = options;
|
|
1235
|
+
const region = this.resolveRegion(config);
|
|
1236
|
+
const ec2 = new EC2Client(region);
|
|
1237
|
+
const destroyed = [];
|
|
1238
|
+
const targets = await this.findComputeTargets({ slug: config.project.slug, environment, role: "app" });
|
|
1239
|
+
const ids = targets.map((t) => t.id);
|
|
1240
|
+
if (ids.length > 0) {
|
|
1241
|
+
await ec2.terminateInstances(ids);
|
|
1242
|
+
destroyed.push(...ids.map((id) => `instance ${id}`));
|
|
1243
|
+
await Promise.all(ids.map((id) => ec2.waitForInstanceState(id, "terminated").catch(() => {
|
|
1244
|
+
return;
|
|
1245
|
+
})));
|
|
1246
|
+
}
|
|
1247
|
+
const sgName = `${config.project.slug}-${environment}-app-sg`;
|
|
1248
|
+
const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
|
|
1249
|
+
const groupId = found.SecurityGroups?.[0]?.GroupId;
|
|
1250
|
+
if (groupId) {
|
|
1251
|
+
for (let i = 0;i < 10; i++) {
|
|
1252
|
+
try {
|
|
1253
|
+
await ec2.deleteSecurityGroup(groupId);
|
|
1254
|
+
destroyed.push(`security group ${sgName}`);
|
|
1255
|
+
break;
|
|
1256
|
+
} catch {
|
|
1257
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return { destroyed };
|
|
1262
|
+
}
|
|
1263
|
+
async getComputeOutputs(options) {
|
|
1264
|
+
const region = this.resolveRegion(options.config);
|
|
1265
|
+
const stackName = resolveProjectStackName(options.config, options.environment);
|
|
1266
|
+
const cfn = new CloudFormationClient(region);
|
|
1267
|
+
try {
|
|
1268
|
+
const outputs = await cfn.getStackOutputs(stackName);
|
|
1269
|
+
return {
|
|
1270
|
+
deployBucketName: outputs.deployBucketName,
|
|
1271
|
+
appInstanceId: outputs.appInstanceId,
|
|
1272
|
+
appPublicIp: outputs.appPublicIp,
|
|
1273
|
+
sshUser: "ec2-user"
|
|
1274
|
+
};
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
if (!/does not exist|ValidationError/i.test(err instanceof Error ? err.message : ""))
|
|
1277
|
+
throw err;
|
|
1278
|
+
const targets = await this.findComputeTargets({
|
|
1279
|
+
slug: options.config.project.slug,
|
|
1280
|
+
environment: options.environment,
|
|
1281
|
+
role: "app"
|
|
1282
|
+
});
|
|
1283
|
+
const first = targets[0];
|
|
1284
|
+
return {
|
|
1285
|
+
appInstanceId: first?.id,
|
|
1286
|
+
appPublicIp: first?.publicIp,
|
|
1287
|
+
sshUser: "ubuntu",
|
|
1288
|
+
deployStoragePath: "/var/ts-cloud/staging"
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
async uploadRelease(options) {
|
|
1293
|
+
const region = this.resolveRegion(options.config);
|
|
1294
|
+
const outputs = await this.getComputeOutputs({
|
|
1295
|
+
config: options.config,
|
|
1296
|
+
environment: options.environment
|
|
1297
|
+
});
|
|
1298
|
+
const bucket = outputs.deployBucketName;
|
|
1299
|
+
if (!bucket) {
|
|
1300
|
+
throw new Error("No deployBucketName in stack outputs. Re-deploy infrastructure to add the staging bucket.");
|
|
1301
|
+
}
|
|
1302
|
+
const s3 = new S3Client(region);
|
|
1303
|
+
await s3.putObject({
|
|
1304
|
+
bucket,
|
|
1305
|
+
key: options.remoteKey,
|
|
1306
|
+
body: readFileSync(options.localPath),
|
|
1307
|
+
contentType: "application/gzip"
|
|
1308
|
+
});
|
|
1309
|
+
return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
|
|
1310
|
+
}
|
|
1311
|
+
async findComputeTargets(options) {
|
|
1312
|
+
const region = this.region;
|
|
1313
|
+
const ec2 = new EC2Client(region);
|
|
1314
|
+
const filters = [
|
|
1315
|
+
{ Name: "tag:Project", Values: [options.slug] },
|
|
1316
|
+
{ Name: "tag:Environment", Values: [options.environment] },
|
|
1317
|
+
{ Name: "tag:Role", Values: [options.role || "app"] },
|
|
1318
|
+
{ Name: "instance-state-name", Values: ["running", "pending"] }
|
|
1319
|
+
];
|
|
1320
|
+
const result = await ec2.describeInstances({ Filters: filters });
|
|
1321
|
+
const targets = this.reservationsToTargets(result.Reservations);
|
|
1322
|
+
if (targets.length > 0 || (options.role || "app") !== "app")
|
|
1323
|
+
return targets;
|
|
1324
|
+
const stackName = options.stackName ?? `${options.slug}-${options.environment}`;
|
|
1325
|
+
let pinnedId = readPinnedInstanceId(stackName);
|
|
1326
|
+
if (!pinnedId) {
|
|
1327
|
+
try {
|
|
1328
|
+
pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
|
|
1329
|
+
} catch {
|
|
1330
|
+
pinnedId = null;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (!pinnedId)
|
|
1334
|
+
return [];
|
|
1335
|
+
try {
|
|
1336
|
+
const pinned = await ec2.describeInstances({
|
|
1337
|
+
InstanceIds: [pinnedId],
|
|
1338
|
+
Filters: [{ Name: "instance-state-name", Values: ["running", "pending"] }]
|
|
1339
|
+
});
|
|
1340
|
+
return this.reservationsToTargets(pinned.Reservations);
|
|
1341
|
+
} catch {
|
|
1342
|
+
return [];
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
reservationsToTargets(reservations) {
|
|
1346
|
+
const targets = [];
|
|
1347
|
+
for (const reservation of reservations || []) {
|
|
1348
|
+
for (const instance of reservation.Instances || []) {
|
|
1349
|
+
if (!instance.InstanceId)
|
|
1350
|
+
continue;
|
|
1351
|
+
const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
|
|
1352
|
+
targets.push({
|
|
1353
|
+
id: instance.InstanceId,
|
|
1354
|
+
name: nameTag,
|
|
1355
|
+
publicIp: instance.PublicIpAddress,
|
|
1356
|
+
privateIp: instance.PrivateIpAddress,
|
|
1357
|
+
status: instance.State?.Name
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return targets;
|
|
1362
|
+
}
|
|
1363
|
+
bashWrap(commands) {
|
|
1364
|
+
return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
|
|
1365
|
+
`), "TS_CLOUD_BASH_EOF"];
|
|
1366
|
+
}
|
|
1367
|
+
async runRemoteDeploy(options) {
|
|
1368
|
+
const region = this.region;
|
|
1369
|
+
const ssm = new SSMClient(region);
|
|
1370
|
+
if (options.targets.length > 0) {
|
|
1371
|
+
const sendResult = await ssm.sendCommand({
|
|
1372
|
+
InstanceIds: options.targets.map((target) => target.id),
|
|
1373
|
+
DocumentName: "AWS-RunShellScript",
|
|
1374
|
+
Parameters: { commands: this.bashWrap(options.commands) },
|
|
1375
|
+
TimeoutSeconds: options.timeoutSeconds || 600,
|
|
1376
|
+
Comment: options.comment
|
|
1377
|
+
});
|
|
1378
|
+
if (!sendResult.CommandId) {
|
|
1379
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1380
|
+
}
|
|
1381
|
+
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1382
|
+
}
|
|
1383
|
+
if (!options.tags || Object.keys(options.tags).length === 0) {
|
|
1384
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
|
|
1385
|
+
}
|
|
1386
|
+
const result = await ssm.sendCommandByTags({
|
|
1387
|
+
tags: options.tags,
|
|
1388
|
+
commands: this.bashWrap(options.commands),
|
|
1389
|
+
timeoutSeconds: options.timeoutSeconds || 600,
|
|
1390
|
+
comment: options.comment
|
|
1391
|
+
});
|
|
1392
|
+
return {
|
|
1393
|
+
success: result.success,
|
|
1394
|
+
instanceCount: result.instanceCount,
|
|
1395
|
+
perInstance: result.perInstance.map((item) => ({
|
|
1396
|
+
instanceId: item.instanceId,
|
|
1397
|
+
status: item.status,
|
|
1398
|
+
output: item.output,
|
|
1399
|
+
error: item.error
|
|
1400
|
+
})),
|
|
1401
|
+
error: result.error
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
async pollSsmCommand(ssm, commandId, expectedCount) {
|
|
1405
|
+
const pollInterval = 3000;
|
|
1406
|
+
const maxWait = 600000;
|
|
1407
|
+
const startTime = Date.now();
|
|
1408
|
+
const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
|
|
1409
|
+
let lastInvocations = [];
|
|
1410
|
+
while (Date.now() - startTime < maxWait) {
|
|
1411
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1412
|
+
try {
|
|
1413
|
+
const invocations = await ssm.listCommandInvocations({ CommandId: commandId, Details: true });
|
|
1414
|
+
lastInvocations = invocations;
|
|
1415
|
+
if (lastInvocations.length >= expectedCount && lastInvocations.every((i) => terminalStatuses.has(i.Status || ""))) {
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
} catch {}
|
|
1419
|
+
}
|
|
1420
|
+
const perInstance = lastInvocations.map((item) => ({
|
|
1421
|
+
instanceId: item.InstanceId,
|
|
1422
|
+
status: item.Status || "Unknown",
|
|
1423
|
+
output: item.StandardOutputContent,
|
|
1424
|
+
error: item.StandardErrorContent
|
|
1425
|
+
}));
|
|
1426
|
+
const success = perInstance.length > 0 && perInstance.every((item) => item.status === "Success");
|
|
1427
|
+
return {
|
|
1428
|
+
success,
|
|
1429
|
+
instanceCount: perInstance.length,
|
|
1430
|
+
perInstance,
|
|
1431
|
+
error: success ? undefined : "One or more SSM command invocations failed"
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/drivers/hetzner/driver.ts
|
|
1437
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1438
|
+
import { homedir } from "node:os";
|
|
1439
|
+
import { join as join3 } from "node:path";
|
|
1440
|
+
import { execSync } from "node:child_process";
|
|
1441
|
+
|
|
1442
|
+
// src/drivers/hetzner/client.ts
|
|
1443
|
+
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
1444
|
+
|
|
1445
|
+
class HetznerClient {
|
|
1446
|
+
name = "hetzner";
|
|
1447
|
+
apiToken;
|
|
1448
|
+
baseUrl;
|
|
1449
|
+
fetchImpl;
|
|
1450
|
+
constructor(options) {
|
|
1451
|
+
this.apiToken = options.apiToken;
|
|
1452
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
|
|
1453
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
1454
|
+
}
|
|
1455
|
+
async request(method, path, body) {
|
|
1456
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
1457
|
+
method,
|
|
1458
|
+
headers: {
|
|
1459
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
1460
|
+
"Content-Type": "application/json"
|
|
1461
|
+
},
|
|
1462
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
1463
|
+
});
|
|
1464
|
+
const text = await response.text();
|
|
1465
|
+
let data;
|
|
1466
|
+
try {
|
|
1467
|
+
data = text ? JSON.parse(text) : {};
|
|
1468
|
+
} catch {
|
|
1469
|
+
if (!response.ok) {
|
|
1470
|
+
const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
|
|
1471
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
|
|
1472
|
+
}
|
|
1473
|
+
throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
|
|
1474
|
+
}
|
|
1475
|
+
if (!response.ok) {
|
|
1476
|
+
const message = data.error?.message || response.statusText || "Hetzner API error";
|
|
1477
|
+
const code = data.error?.code ? ` [${data.error.code}]` : "";
|
|
1478
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
|
|
1479
|
+
}
|
|
1480
|
+
return data;
|
|
1481
|
+
}
|
|
1482
|
+
async listServers() {
|
|
1483
|
+
const data = await this.request("GET", "/servers");
|
|
1484
|
+
return data.servers;
|
|
1485
|
+
}
|
|
1486
|
+
async getServer(id) {
|
|
1487
|
+
const data = await this.request("GET", `/servers/${id}`);
|
|
1488
|
+
return data.server;
|
|
1489
|
+
}
|
|
1490
|
+
async createServer(options) {
|
|
1491
|
+
const data = await this.request("POST", "/servers", {
|
|
1492
|
+
name: options.name,
|
|
1493
|
+
server_type: options.serverType,
|
|
1494
|
+
image: options.image,
|
|
1495
|
+
location: options.location,
|
|
1496
|
+
datacenter: options.datacenter,
|
|
1497
|
+
ssh_keys: options.sshKeys,
|
|
1498
|
+
user_data: options.userData,
|
|
1499
|
+
labels: options.labels,
|
|
1500
|
+
firewalls: options.firewalls,
|
|
1501
|
+
networks: options.networks,
|
|
1502
|
+
start_after_create: true
|
|
1503
|
+
});
|
|
1504
|
+
return { server: data.server, action: data.action };
|
|
1505
|
+
}
|
|
1506
|
+
async listNetworks() {
|
|
1507
|
+
const data = await this.request("GET", "/networks");
|
|
1508
|
+
return data.networks;
|
|
1509
|
+
}
|
|
1510
|
+
async createNetwork(options) {
|
|
1511
|
+
const ipRange = options.ipRange ?? "10.0.0.0/16";
|
|
1512
|
+
const data = await this.request("POST", "/networks", {
|
|
1513
|
+
name: options.name,
|
|
1514
|
+
ip_range: ipRange,
|
|
1515
|
+
subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
|
|
1516
|
+
labels: options.labels
|
|
1517
|
+
});
|
|
1518
|
+
return data.network;
|
|
1519
|
+
}
|
|
1520
|
+
async deleteNetwork(id) {
|
|
1521
|
+
await this.request("DELETE", `/networks/${id}`);
|
|
1522
|
+
}
|
|
1523
|
+
async listLoadBalancers() {
|
|
1524
|
+
const data = await this.request("GET", "/load_balancers");
|
|
1525
|
+
return data.load_balancers;
|
|
1526
|
+
}
|
|
1527
|
+
async createLoadBalancer(options) {
|
|
1528
|
+
const data = await this.request("POST", "/load_balancers", {
|
|
1529
|
+
name: options.name,
|
|
1530
|
+
load_balancer_type: options.type ?? "lb11",
|
|
1531
|
+
location: options.location,
|
|
1532
|
+
network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
|
|
1533
|
+
network: options.network,
|
|
1534
|
+
labels: options.labels,
|
|
1535
|
+
targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
|
|
1536
|
+
services: options.services.map((s) => ({
|
|
1537
|
+
protocol: s.protocol ?? "tcp",
|
|
1538
|
+
listen_port: s.listenPort,
|
|
1539
|
+
destination_port: s.destinationPort,
|
|
1540
|
+
health_check: {
|
|
1541
|
+
protocol: "http",
|
|
1542
|
+
port: 80,
|
|
1543
|
+
interval: 15,
|
|
1544
|
+
timeout: 10,
|
|
1545
|
+
retries: 3,
|
|
1546
|
+
http: { path: "/", status_codes: ["2??", "3??"] }
|
|
1547
|
+
}
|
|
1548
|
+
}))
|
|
1549
|
+
});
|
|
1550
|
+
return data.load_balancer;
|
|
1551
|
+
}
|
|
1552
|
+
async deleteLoadBalancer(id) {
|
|
1553
|
+
await this.request("DELETE", `/load_balancers/${id}`);
|
|
1554
|
+
}
|
|
1555
|
+
async deleteServer(id) {
|
|
1556
|
+
const data = await this.request("DELETE", `/servers/${id}`);
|
|
1557
|
+
return data.action;
|
|
1558
|
+
}
|
|
1559
|
+
async listFirewalls() {
|
|
1560
|
+
const data = await this.request("GET", "/firewalls");
|
|
1561
|
+
return data.firewalls;
|
|
1562
|
+
}
|
|
1563
|
+
async createFirewall(options) {
|
|
1564
|
+
const data = await this.request("POST", "/firewalls", {
|
|
1565
|
+
name: options.name,
|
|
1566
|
+
rules: options.rules,
|
|
1567
|
+
labels: options.labels,
|
|
1568
|
+
apply_to: options.applyTo
|
|
1569
|
+
});
|
|
1570
|
+
return { firewall: data.firewall, actions: data.actions };
|
|
1571
|
+
}
|
|
1572
|
+
async setFirewallRules(firewallId, rules) {
|
|
1573
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
|
|
1574
|
+
rules
|
|
1575
|
+
});
|
|
1576
|
+
return data.actions ?? [];
|
|
1577
|
+
}
|
|
1578
|
+
async deleteFirewall(firewallId) {
|
|
1579
|
+
await this.request("DELETE", `/firewalls/${firewallId}`);
|
|
1580
|
+
}
|
|
1581
|
+
async applyFirewallToResources(firewallId, applyTo) {
|
|
1582
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
1583
|
+
apply_to: applyTo
|
|
1584
|
+
});
|
|
1585
|
+
return data.actions;
|
|
1586
|
+
}
|
|
1587
|
+
async listSshKeys() {
|
|
1588
|
+
const data = await this.request("GET", "/ssh_keys");
|
|
1589
|
+
return data.ssh_keys;
|
|
1590
|
+
}
|
|
1591
|
+
async createSshKey(options) {
|
|
1592
|
+
const data = await this.request("POST", "/ssh_keys", {
|
|
1593
|
+
name: options.name,
|
|
1594
|
+
public_key: options.publicKey,
|
|
1595
|
+
labels: options.labels
|
|
1596
|
+
});
|
|
1597
|
+
return data.ssh_key;
|
|
1598
|
+
}
|
|
1599
|
+
async waitForAction(actionId, options) {
|
|
1600
|
+
const pollInterval = options?.pollIntervalMs ?? 2000;
|
|
1601
|
+
const maxWait = options?.maxWaitMs ?? 300000;
|
|
1602
|
+
const start = Date.now();
|
|
1603
|
+
while (Date.now() - start < maxWait) {
|
|
1604
|
+
const data = await this.request("GET", `/actions/${actionId}`);
|
|
1605
|
+
if (data.action.status === "success")
|
|
1606
|
+
return data.action;
|
|
1607
|
+
if (data.action.status === "error") {
|
|
1608
|
+
throw new Error(data.action.error?.message || "Hetzner action failed");
|
|
1609
|
+
}
|
|
1610
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1611
|
+
}
|
|
1612
|
+
throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
|
|
1613
|
+
}
|
|
1614
|
+
async waitForServerRunning(serverId, options) {
|
|
1615
|
+
const pollInterval = options?.pollIntervalMs ?? 3000;
|
|
1616
|
+
const maxWait = options?.maxWaitMs ?? 600000;
|
|
1617
|
+
const start = Date.now();
|
|
1618
|
+
while (Date.now() - start < maxWait) {
|
|
1619
|
+
const server = await this.getServer(serverId);
|
|
1620
|
+
if (server.status === "running")
|
|
1621
|
+
return server;
|
|
1622
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1623
|
+
}
|
|
1624
|
+
throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
function resolveHetznerApiToken(configToken) {
|
|
1628
|
+
const token = configToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
|
|
1629
|
+
if (!token) {
|
|
1630
|
+
throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
|
|
1631
|
+
}
|
|
1632
|
+
return token;
|
|
1633
|
+
}
|
|
1634
|
+
function normalizeSshPublicKey(publicKey) {
|
|
1635
|
+
const [type, body] = publicKey.trim().split(/\s+/);
|
|
1636
|
+
return body ? `${type} ${body}` : type;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// src/drivers/hetzner/cloud-init.ts
|
|
1640
|
+
function wrapCloudInitUserData(bootstrapScript) {
|
|
1641
|
+
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
1642
|
+
const indented = bootstrapScript.split(`
|
|
1643
|
+
`).map((line) => ` ${line}`).join(`
|
|
1644
|
+
`);
|
|
1645
|
+
return `#cloud-config
|
|
1646
|
+
write_files:
|
|
1647
|
+
- path: ${scriptPath}
|
|
1648
|
+
permissions: '0755'
|
|
1649
|
+
owner: root:root
|
|
1650
|
+
content: |
|
|
1651
|
+
${indented}
|
|
1652
|
+
runcmd:
|
|
1653
|
+
- [ bash, ${scriptPath} ]
|
|
1654
|
+
`;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/deploy/site-target.ts
|
|
1658
|
+
var PHP_SITE_TYPES = new Set([
|
|
1659
|
+
"laravel",
|
|
1660
|
+
"php",
|
|
1661
|
+
"statamic",
|
|
1662
|
+
"wordpress"
|
|
1663
|
+
]);
|
|
1664
|
+
function isPhpSite(site) {
|
|
1665
|
+
return site.type != null && PHP_SITE_TYPES.has(site.type);
|
|
1666
|
+
}
|
|
1667
|
+
function resolveSiteDeployTarget(site) {
|
|
1668
|
+
if (site.deploy)
|
|
1669
|
+
return site.deploy;
|
|
1670
|
+
if (isPhpSite(site))
|
|
1671
|
+
return "server";
|
|
1672
|
+
if (site.start)
|
|
1673
|
+
return "server";
|
|
1674
|
+
return "bucket";
|
|
1675
|
+
}
|
|
1676
|
+
function resolveSiteKind(site) {
|
|
1677
|
+
if (site.redirect)
|
|
1678
|
+
return "redirect";
|
|
1679
|
+
if (isPhpSite(site))
|
|
1680
|
+
return "server-php";
|
|
1681
|
+
const target = resolveSiteDeployTarget(site);
|
|
1682
|
+
if (target === "bucket")
|
|
1683
|
+
return "bucket";
|
|
1684
|
+
return site.start ? "server-app" : "server-static";
|
|
1685
|
+
}
|
|
1686
|
+
function hasComputeConfigured(config) {
|
|
1687
|
+
return config.infrastructure?.compute != null;
|
|
1688
|
+
}
|
|
1689
|
+
function validateDeploymentConfig(config) {
|
|
1690
|
+
const errors = [];
|
|
1691
|
+
const warnings = [];
|
|
1692
|
+
const sites = config.sites || {};
|
|
1693
|
+
const computeConfigured = hasComputeConfigured(config);
|
|
1694
|
+
const coexistence = deploymentCoexistenceError(config);
|
|
1695
|
+
if (coexistence)
|
|
1696
|
+
errors.push(coexistence);
|
|
1697
|
+
const portOwners = new Map;
|
|
1698
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
1699
|
+
if (!site) {
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const target = resolveSiteDeployTarget(site);
|
|
1703
|
+
const kind = resolveSiteKind(site);
|
|
1704
|
+
if (kind === "redirect") {
|
|
1705
|
+
if (!site.domain)
|
|
1706
|
+
errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
|
|
1707
|
+
const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
|
|
1708
|
+
if (!to)
|
|
1709
|
+
errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
|
|
1710
|
+
if (!computeConfigured) {
|
|
1711
|
+
errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
|
|
1712
|
+
}
|
|
1713
|
+
const serverOnly = [];
|
|
1714
|
+
if (site.start)
|
|
1715
|
+
serverOnly.push("start");
|
|
1716
|
+
if (site.root)
|
|
1717
|
+
serverOnly.push("root");
|
|
1718
|
+
if (serverOnly.length > 0)
|
|
1719
|
+
warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
if (target === "server" && !site.start && !site.root) {
|
|
1723
|
+
errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (kind === "server-php") {
|
|
1727
|
+
if (!computeConfigured) {
|
|
1728
|
+
errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but no \`infrastructure.compute\` is configured. Add a server (infrastructure.compute) with PHP provisioning.`);
|
|
1729
|
+
}
|
|
1730
|
+
if (!site.repository?.url) {
|
|
1731
|
+
errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but has no \`repository.url\` to clone. PHP sites deploy via git.`);
|
|
1732
|
+
}
|
|
1733
|
+
} else if (kind === "server-app") {
|
|
1734
|
+
if (!computeConfigured) {
|
|
1735
|
+
errors.push(`Site '${name}' deploys to a server (deploy:'server'${site.deploy ? "" : " inferred from `start`"}) but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
|
|
1736
|
+
}
|
|
1737
|
+
if (typeof site.port === "number") {
|
|
1738
|
+
const existing = portOwners.get(site.port);
|
|
1739
|
+
if (existing) {
|
|
1740
|
+
errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
|
|
1741
|
+
} else {
|
|
1742
|
+
portOwners.set(site.port, name);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} else if (kind === "server-static") {
|
|
1746
|
+
if (!site.root) {
|
|
1747
|
+
errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
|
|
1748
|
+
}
|
|
1749
|
+
if (!computeConfigured) {
|
|
1750
|
+
errors.push(`Site '${name}' deploys to a server (deploy:'server') but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
|
|
1751
|
+
}
|
|
1752
|
+
} else {
|
|
1753
|
+
if (!site.root) {
|
|
1754
|
+
errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
|
|
1755
|
+
}
|
|
1756
|
+
const serverOnly = [];
|
|
1757
|
+
if (site.start)
|
|
1758
|
+
serverOnly.push("start");
|
|
1759
|
+
if (typeof site.port === "number")
|
|
1760
|
+
serverOnly.push("port");
|
|
1761
|
+
if (site.preStart && site.preStart.length > 0)
|
|
1762
|
+
serverOnly.push("preStart");
|
|
1763
|
+
if (serverOnly.length > 0) {
|
|
1764
|
+
warnings.push(`Site '${name}' deploys to a bucket but sets server-only field(s): ${serverOnly.join(", ")}. These are ignored. Set deploy:'server' to use them.`);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return { errors, warnings };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// src/drivers/shared/rpx-gateway.ts
|
|
1772
|
+
var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
|
|
1773
|
+
var DEFAULT_ACME_WEBROOT = "/var/www/acme-challenge";
|
|
1774
|
+
function normalizeSiteRedirect(input) {
|
|
1775
|
+
if (typeof input === "string")
|
|
1776
|
+
return { to: input };
|
|
1777
|
+
const out = { to: input.to };
|
|
1778
|
+
if (input.status != null)
|
|
1779
|
+
out.status = input.status;
|
|
1780
|
+
if (input.preservePath != null)
|
|
1781
|
+
out.preservePath = input.preservePath;
|
|
1782
|
+
return out;
|
|
1783
|
+
}
|
|
1784
|
+
function resolveRouteAuth(site) {
|
|
1785
|
+
const auth = site.auth;
|
|
1786
|
+
if (!auth || auth.enabled === false || !auth.password)
|
|
1787
|
+
return;
|
|
1788
|
+
return {
|
|
1789
|
+
username: auth.username || "admin",
|
|
1790
|
+
password: auth.password,
|
|
1791
|
+
...auth.realm ? { realm: auth.realm } : {}
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
function normalizeRoutePath(path) {
|
|
1795
|
+
if (!path || path === "/")
|
|
1796
|
+
return;
|
|
1797
|
+
let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
1798
|
+
if (!p.startsWith("/"))
|
|
1799
|
+
p = `/${p}`;
|
|
1800
|
+
return p === "" || p === "/" ? undefined : p;
|
|
1801
|
+
}
|
|
1802
|
+
function deriveRouteId(to, path) {
|
|
1803
|
+
const base = path ? `${to}${path}` : to;
|
|
1804
|
+
const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
|
|
1805
|
+
return cleaned.length > 0 ? cleaned : "rpx";
|
|
1806
|
+
}
|
|
1807
|
+
function resolveServerAppFrom(port, appBoxes) {
|
|
1808
|
+
if (!appBoxes || appBoxes.length === 0)
|
|
1809
|
+
return `localhost:${port}`;
|
|
1810
|
+
return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
|
|
1811
|
+
}
|
|
1812
|
+
function buildRpxConfigInternal(sites, options, appBoxes) {
|
|
1813
|
+
const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
|
|
1814
|
+
const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
|
|
1815
|
+
const loadBalancer = options.proxy.loadBalancer;
|
|
1816
|
+
const proxies = [];
|
|
1817
|
+
const domains = new Set;
|
|
1818
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
1819
|
+
if (!site || !site.domain)
|
|
1820
|
+
continue;
|
|
1821
|
+
const kind = resolveSiteKind(site);
|
|
1822
|
+
if (kind === "bucket")
|
|
1823
|
+
continue;
|
|
1824
|
+
const path = normalizeRoutePath(site.path);
|
|
1825
|
+
const id = deriveRouteId(site.domain, path);
|
|
1826
|
+
const auth = resolveRouteAuth(site);
|
|
1827
|
+
if (kind === "redirect") {
|
|
1828
|
+
proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
|
|
1829
|
+
domains.add(site.domain);
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
if (kind === "server-app") {
|
|
1833
|
+
if (typeof site.port !== "number")
|
|
1834
|
+
continue;
|
|
1835
|
+
const from = resolveServerAppFrom(site.port, appBoxes);
|
|
1836
|
+
proxies.push({
|
|
1837
|
+
to: site.domain,
|
|
1838
|
+
path,
|
|
1839
|
+
from,
|
|
1840
|
+
id,
|
|
1841
|
+
...auth ? { auth } : {},
|
|
1842
|
+
...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
|
|
1843
|
+
});
|
|
1844
|
+
} else {
|
|
1845
|
+
proxies.push({
|
|
1846
|
+
to: site.domain,
|
|
1847
|
+
path,
|
|
1848
|
+
static: `${wwwRoot}/${name}/current`,
|
|
1849
|
+
cleanUrls: site.pathRewriteStyle !== "flat",
|
|
1850
|
+
spa: site.spa ?? false,
|
|
1851
|
+
...auth ? { auth } : {},
|
|
1852
|
+
id
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
domains.add(site.domain);
|
|
1856
|
+
}
|
|
1857
|
+
if (options.proxy.autoWww !== false) {
|
|
1858
|
+
for (const domain of [...domains]) {
|
|
1859
|
+
if (domain.split(".").length !== 2)
|
|
1860
|
+
continue;
|
|
1861
|
+
const wwwDomain = `www.${domain}`;
|
|
1862
|
+
if (domains.has(wwwDomain))
|
|
1863
|
+
continue;
|
|
1864
|
+
proxies.push({ to: wwwDomain, redirect: { to: `https://${domain}` }, id: deriveRouteId(wwwDomain) });
|
|
1865
|
+
domains.add(wwwDomain);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
proxies.sort((a, b) => {
|
|
1869
|
+
if (a.to !== b.to)
|
|
1870
|
+
return a.to.localeCompare(b.to);
|
|
1871
|
+
return (b.path?.length ?? 0) - (a.path?.length ?? 0);
|
|
1872
|
+
});
|
|
1873
|
+
const config = {
|
|
1874
|
+
proxies,
|
|
1875
|
+
productionCerts: { certsDir },
|
|
1876
|
+
https: true,
|
|
1877
|
+
hostsManagement: false,
|
|
1878
|
+
cleanup: { hosts: false, certs: false }
|
|
1879
|
+
};
|
|
1880
|
+
if (options.proxy.onDemandTls && domains.size > 0) {
|
|
1881
|
+
config.onDemandTls = {
|
|
1882
|
+
enabled: true,
|
|
1883
|
+
allowedSuffixes: [...domains],
|
|
1884
|
+
email: options.proxy.onDemandTlsEmail,
|
|
1885
|
+
certsDir
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
if (options.proxy.onDemandTls) {
|
|
1889
|
+
config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
|
|
1890
|
+
}
|
|
1891
|
+
const cdn = options.proxy.cdn;
|
|
1892
|
+
if (cdn?.secret && cdn.frontedHosts.length > 0) {
|
|
1893
|
+
config.originGuard = {
|
|
1894
|
+
header: cdn.secretHeader ?? "X-Origin-Verify",
|
|
1895
|
+
value: cdn.secret,
|
|
1896
|
+
hosts: cdn.frontedHosts
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
return config;
|
|
1900
|
+
}
|
|
1901
|
+
function buildRpxConfig(sites, options) {
|
|
1902
|
+
return buildRpxConfigInternal(sites, options);
|
|
1903
|
+
}
|
|
1904
|
+
function buildRpxLbConfig(sites, appBoxes, options) {
|
|
1905
|
+
return buildRpxConfigInternal(sites, options, appBoxes);
|
|
1906
|
+
}
|
|
1907
|
+
function renderRpxLauncher(config) {
|
|
1908
|
+
const json = JSON.stringify(config, null, 2);
|
|
1909
|
+
return `// Generated by ts-cloud — rpx reverse-proxy gateway.
|
|
1910
|
+
// Routes are derived from the \`sites\` model on every \`buddy deploy\`.
|
|
1911
|
+
import { startProxies } from '@stacksjs/rpx'
|
|
1912
|
+
|
|
1913
|
+
const config = ${json} as const
|
|
1914
|
+
|
|
1915
|
+
await startProxies(config as any)
|
|
1916
|
+
`;
|
|
1917
|
+
}
|
|
1918
|
+
var RPX_DIR = "/etc/rpx";
|
|
1919
|
+
var RPX_INSTALL_DIR = "/opt/rpx-gateway";
|
|
1920
|
+
var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
|
|
1921
|
+
var RPX_SERVICE_NAME = "rpx-gateway.service";
|
|
1922
|
+
var RPX_SITES_DIR = "/etc/rpx/sites.d";
|
|
1923
|
+
function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
|
|
1924
|
+
return `// Generated by ts-cloud — rpx gateway assembler.
|
|
1925
|
+
// Merges every app's fragment in ${sitesDir} so independent deploys compose
|
|
1926
|
+
// without clobbering each other. Each deploy writes only its own <slug>.json.
|
|
1927
|
+
import { startProxies } from '@stacksjs/rpx'
|
|
1928
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
1929
|
+
|
|
1930
|
+
const dir = ${JSON.stringify(sitesDir)}
|
|
1931
|
+
const proxies = []
|
|
1932
|
+
const seen = new Set()
|
|
1933
|
+
const suffixes = new Set()
|
|
1934
|
+
const guardHosts = new Set()
|
|
1935
|
+
let email
|
|
1936
|
+
let certsDir = ${JSON.stringify(defaultCertsDir)}
|
|
1937
|
+
let acmeChallengeWebroot
|
|
1938
|
+
let guard
|
|
1939
|
+
let files = []
|
|
1940
|
+
try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
|
|
1941
|
+
for (const f of files) {
|
|
1942
|
+
let frag
|
|
1943
|
+
try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
|
|
1944
|
+
for (const p of frag.proxies ?? []) {
|
|
1945
|
+
const key = p.id || (p.to + (p.path ?? ''))
|
|
1946
|
+
if (seen.has(key)) continue
|
|
1947
|
+
seen.add(key)
|
|
1948
|
+
proxies.push(p)
|
|
1949
|
+
}
|
|
1950
|
+
for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
|
|
1951
|
+
email ??= frag.onDemandTls?.email
|
|
1952
|
+
if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
|
|
1953
|
+
acmeChallengeWebroot ??= frag.acmeChallengeWebroot
|
|
1954
|
+
if (frag.originGuard) {
|
|
1955
|
+
guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
|
|
1956
|
+
for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
const config = {
|
|
1960
|
+
proxies,
|
|
1961
|
+
productionCerts: { certsDir },
|
|
1962
|
+
https: true,
|
|
1963
|
+
hostsManagement: false,
|
|
1964
|
+
cleanup: { hosts: false, certs: false },
|
|
1965
|
+
...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
|
|
1966
|
+
...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
|
|
1967
|
+
...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
await startProxies(config)
|
|
1971
|
+
`;
|
|
1972
|
+
}
|
|
1973
|
+
function writeFileHeredoc(path, content, delimiter) {
|
|
1974
|
+
return [
|
|
1975
|
+
`cat > ${path} <<'${delimiter}'`,
|
|
1976
|
+
content,
|
|
1977
|
+
delimiter
|
|
1978
|
+
];
|
|
1979
|
+
}
|
|
1980
|
+
function certDomainsForConfig(config) {
|
|
1981
|
+
const seen = new Set;
|
|
1982
|
+
for (const r of config.proxies) {
|
|
1983
|
+
const host = r.to;
|
|
1984
|
+
if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
|
|
1985
|
+
continue;
|
|
1986
|
+
seen.add(host);
|
|
1987
|
+
}
|
|
1988
|
+
return [...seen];
|
|
1989
|
+
}
|
|
1990
|
+
function buildCertManagementCommands(options) {
|
|
1991
|
+
const { config, proxy } = options;
|
|
1992
|
+
const webroot = config.acmeChallengeWebroot;
|
|
1993
|
+
const domains = certDomainsForConfig(config);
|
|
1994
|
+
if (!proxy.onDemandTls || !webroot || domains.length === 0)
|
|
1995
|
+
return [];
|
|
1996
|
+
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
1997
|
+
const version = proxy.version ?? "latest";
|
|
1998
|
+
const certsDir = config.productionCerts.certsDir;
|
|
1999
|
+
const email = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
|
|
2000
|
+
const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
|
|
2001
|
+
const csv = domains.join(",");
|
|
2002
|
+
const spaced = domains.join(" ");
|
|
2003
|
+
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
2004
|
+
const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
|
|
2005
|
+
const renewServiceName = `rpx-cert-renew-${slug}.service`;
|
|
2006
|
+
const renewTimerName = `rpx-cert-renew-${slug}.timer`;
|
|
2007
|
+
const renewScript = [
|
|
2008
|
+
"#!/bin/sh",
|
|
2009
|
+
"# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
|
|
2010
|
+
"# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
|
|
2011
|
+
"# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
|
|
2012
|
+
"set -u",
|
|
2013
|
+
`CERTS='${certsDir}'`,
|
|
2014
|
+
`WEBROOT='${webroot}'`,
|
|
2015
|
+
`EMAIL='${email}'`,
|
|
2016
|
+
`TLSX="${tlsxCli}"`,
|
|
2017
|
+
`DOMAINS='${csv}'`,
|
|
2018
|
+
'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
2019
|
+
`for d in ${spaced}; do`,
|
|
2020
|
+
' [ -s "$CERTS/$d.crt" ] || $TLSX acme:issue -d "$d" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --prod --email "$EMAIL" || echo "issue $d failed (non-fatal)"',
|
|
2021
|
+
"done",
|
|
2022
|
+
'$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
|
|
2023
|
+
'rm -f "$CERTS"/*.chain.crt',
|
|
2024
|
+
'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
2025
|
+
`[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
|
|
2026
|
+
].join(`
|
|
2027
|
+
`);
|
|
2028
|
+
const renewService = [
|
|
2029
|
+
"[Unit]",
|
|
2030
|
+
`Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
|
|
2031
|
+
`After=network-online.target ${RPX_SERVICE_NAME}`,
|
|
2032
|
+
"Wants=network-online.target",
|
|
2033
|
+
"",
|
|
2034
|
+
"[Service]",
|
|
2035
|
+
"Type=oneshot",
|
|
2036
|
+
`ExecStart=${renewScriptPath}`
|
|
2037
|
+
].join(`
|
|
2038
|
+
`);
|
|
2039
|
+
const renewTimer = [
|
|
2040
|
+
"[Unit]",
|
|
2041
|
+
`Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
|
|
2042
|
+
"",
|
|
2043
|
+
"[Timer]",
|
|
2044
|
+
"OnCalendar=*-*-* 03:30:00",
|
|
2045
|
+
"RandomizedDelaySec=1h",
|
|
2046
|
+
"Persistent=true",
|
|
2047
|
+
"",
|
|
2048
|
+
"[Install]",
|
|
2049
|
+
"WantedBy=timers.target"
|
|
2050
|
+
].join(`
|
|
2051
|
+
`);
|
|
2052
|
+
return [
|
|
2053
|
+
`mkdir -p ${webroot}`,
|
|
2054
|
+
`(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/tlsx@${version}) || true`,
|
|
2055
|
+
...writeFileHeredoc(renewScriptPath, renewScript, "TS_CLOUD_RENEW_EOF"),
|
|
2056
|
+
`chmod +x ${renewScriptPath}`,
|
|
2057
|
+
...writeFileHeredoc(`/etc/systemd/system/${renewServiceName}`, renewService, "TS_CLOUD_RENEW_SVC_EOF"),
|
|
2058
|
+
...writeFileHeredoc(`/etc/systemd/system/${renewTimerName}`, renewTimer, "TS_CLOUD_RENEW_TIMER_EOF"),
|
|
2059
|
+
"systemctl daemon-reload",
|
|
2060
|
+
`systemctl enable --now ${renewTimerName} || true`,
|
|
2061
|
+
`${renewScriptPath} || true`
|
|
2062
|
+
];
|
|
2063
|
+
}
|
|
2064
|
+
function buildRpxProvisionScript(options) {
|
|
2065
|
+
const { config, proxy } = options;
|
|
2066
|
+
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
2067
|
+
const version = proxy.version ?? "latest";
|
|
2068
|
+
const certsDir = config.productionCerts.certsDir;
|
|
2069
|
+
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
2070
|
+
const fragment = JSON.stringify({ slug, ...config }, null, 2);
|
|
2071
|
+
const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
|
|
2072
|
+
const upstreamTimeout = proxy.upstreamTimeout ?? 60;
|
|
2073
|
+
const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
|
|
2074
|
+
if (typeof proxy.maxUpstreamConns === "number")
|
|
2075
|
+
poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
|
|
2076
|
+
return [
|
|
2077
|
+
"set -euo pipefail",
|
|
2078
|
+
`mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
|
|
2079
|
+
`rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
|
|
2080
|
+
`(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
|
|
2081
|
+
`ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
|
|
2082
|
+
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
|
|
2083
|
+
...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
|
|
2084
|
+
...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
|
|
2085
|
+
"[Unit]",
|
|
2086
|
+
"Description=rpx reverse-proxy gateway (managed by ts-cloud)",
|
|
2087
|
+
"After=network.target network-online.target",
|
|
2088
|
+
"Wants=network-online.target",
|
|
2089
|
+
"",
|
|
2090
|
+
"[Service]",
|
|
2091
|
+
"Type=simple",
|
|
2092
|
+
`ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
|
|
2093
|
+
`WorkingDirectory=${RPX_INSTALL_DIR}`,
|
|
2094
|
+
`Environment=BUN_INSTALL=/root/.bun`,
|
|
2095
|
+
...poolEnv,
|
|
2096
|
+
"Restart=always",
|
|
2097
|
+
"RestartSec=5",
|
|
2098
|
+
"LimitNOFILE=1048576",
|
|
2099
|
+
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
|
2100
|
+
"",
|
|
2101
|
+
"[Install]",
|
|
2102
|
+
"WantedBy=multi-user.target"
|
|
2103
|
+
].join(`
|
|
2104
|
+
`), "TS_CLOUD_RPX_UNIT_EOF"),
|
|
2105
|
+
"systemctl daemon-reload",
|
|
2106
|
+
"systemctl disable --now bun-gateway.service 2>/dev/null || true",
|
|
2107
|
+
"systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
|
|
2108
|
+
`systemctl enable ${RPX_SERVICE_NAME}`,
|
|
2109
|
+
`systemctl restart ${RPX_SERVICE_NAME}`,
|
|
2110
|
+
...buildCertManagementCommands(options)
|
|
2111
|
+
];
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// src/drivers/shared/fleet.ts
|
|
2115
|
+
function resolveFleetTopology(compute = {}) {
|
|
2116
|
+
const appServers = Math.max(1, compute.appServers ?? compute.instances ?? 1);
|
|
2117
|
+
const dedicatedServices = !!compute.servicesServer || appServers > 1;
|
|
2118
|
+
const loadBalancer = appServers > 1 || !!compute.server?.loadBalancer;
|
|
2119
|
+
return {
|
|
2120
|
+
appServers,
|
|
2121
|
+
loadBalancer,
|
|
2122
|
+
dedicatedServices,
|
|
2123
|
+
servicesOnApp: !dedicatedServices
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
function buildFleetServicesEnv(servicesPrivateIp, database) {
|
|
2127
|
+
const env = {
|
|
2128
|
+
REDIS_HOST: servicesPrivateIp,
|
|
2129
|
+
MEILISEARCH_HOST: `http://${servicesPrivateIp}:7700`
|
|
2130
|
+
};
|
|
2131
|
+
if (database?.name) {
|
|
2132
|
+
env.DB_CONNECTION = database.engine === "postgres" ? "pgsql" : "mysql";
|
|
2133
|
+
env.DB_HOST = servicesPrivateIp;
|
|
2134
|
+
env.DB_PORT = String(database.port ?? (database.engine === "postgres" ? 5432 : 3306));
|
|
2135
|
+
env.DB_DATABASE = database.name;
|
|
2136
|
+
if (database.username)
|
|
2137
|
+
env.DB_USERNAME = database.username;
|
|
2138
|
+
if (database.password)
|
|
2139
|
+
env.DB_PASSWORD = database.password;
|
|
2140
|
+
}
|
|
2141
|
+
return env;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/drivers/hetzner/firewall-rules.ts
|
|
2145
|
+
function buildHetznerFirewallRules(config) {
|
|
2146
|
+
const openPorts = new Set([80, 443, ...config.sitePorts]);
|
|
2147
|
+
if (config.allowSsh) {
|
|
2148
|
+
openPorts.add(22);
|
|
2149
|
+
}
|
|
2150
|
+
return [...openPorts].map((port) => {
|
|
2151
|
+
return {
|
|
2152
|
+
direction: "in",
|
|
2153
|
+
protocol: "tcp",
|
|
2154
|
+
port: String(port),
|
|
2155
|
+
source_ips: ["0.0.0.0/0", "::/0"],
|
|
2156
|
+
description: `ts-cloud port ${port}`
|
|
2157
|
+
};
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/drivers/hetzner/instance-sizes.ts
|
|
2162
|
+
var HETZNER_INSTANCE_TYPES = {
|
|
2163
|
+
micro: "cpx11",
|
|
2164
|
+
small: "cx23",
|
|
2165
|
+
medium: "cx33",
|
|
2166
|
+
large: "cx43",
|
|
2167
|
+
xlarge: "cx53",
|
|
2168
|
+
"2xlarge": "ccx33"
|
|
2169
|
+
};
|
|
2170
|
+
function resolveHetznerServerType(size) {
|
|
2171
|
+
if (!size)
|
|
2172
|
+
return HETZNER_INSTANCE_TYPES.micro;
|
|
2173
|
+
if (size in HETZNER_INSTANCE_TYPES) {
|
|
2174
|
+
return HETZNER_INSTANCE_TYPES[size];
|
|
2175
|
+
}
|
|
2176
|
+
return size;
|
|
2177
|
+
}
|
|
2178
|
+
var TS_CLOUD_LABEL_PREFIX = "ts-cloud";
|
|
2179
|
+
function tsCloudLabels(slug, environment, role = "app") {
|
|
2180
|
+
return {
|
|
2181
|
+
[`${TS_CLOUD_LABEL_PREFIX}/project`]: slug,
|
|
2182
|
+
[`${TS_CLOUD_LABEL_PREFIX}/environment`]: environment,
|
|
2183
|
+
[`${TS_CLOUD_LABEL_PREFIX}/role`]: role,
|
|
2184
|
+
[`${TS_CLOUD_LABEL_PREFIX}/managed-by`]: "ts-cloud"
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
function matchesTsCloudLabels(labels, slug, environment, role = "app") {
|
|
2188
|
+
if (!labels)
|
|
2189
|
+
return false;
|
|
2190
|
+
return labels[`${TS_CLOUD_LABEL_PREFIX}/project`] === slug && labels[`${TS_CLOUD_LABEL_PREFIX}/environment`] === environment && labels[`${TS_CLOUD_LABEL_PREFIX}/role`] === role;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// src/drivers/hetzner/state.ts
|
|
2194
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2195
|
+
import { join as join2 } from "node:path";
|
|
2196
|
+
var STATE_DIR = ".ts-cloud/state";
|
|
2197
|
+
function driverStatePath(stackName) {
|
|
2198
|
+
return join2(process.cwd(), STATE_DIR, `${stackName}.json`);
|
|
2199
|
+
}
|
|
2200
|
+
async function readDriverState(stackName) {
|
|
2201
|
+
try {
|
|
2202
|
+
const raw = await readFile(driverStatePath(stackName), "utf8");
|
|
2203
|
+
return JSON.parse(raw);
|
|
2204
|
+
} catch {
|
|
2205
|
+
return null;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
async function writeDriverState(stackName, state) {
|
|
2209
|
+
const path = driverStatePath(stackName);
|
|
2210
|
+
await mkdir(join2(process.cwd(), STATE_DIR), { recursive: true });
|
|
2211
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}
|
|
2212
|
+
`, "utf8");
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// src/drivers/hetzner/driver.ts
|
|
2216
|
+
var SSH_MAX_BUFFER = 1024 * 1024 * 256;
|
|
2217
|
+
function expandHome(path) {
|
|
2218
|
+
return path.startsWith("~/") ? join3(homedir(), path.slice(2)) : path;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
class HetznerDriver {
|
|
2222
|
+
name = "hetzner";
|
|
2223
|
+
usesCloudFormation = false;
|
|
2224
|
+
client;
|
|
2225
|
+
sshPrivateKeyPath;
|
|
2226
|
+
sshPublicKeyPath;
|
|
2227
|
+
sshUser;
|
|
2228
|
+
location;
|
|
2229
|
+
waitForBoot;
|
|
2230
|
+
bootWait;
|
|
2231
|
+
constructor(options = {}) {
|
|
2232
|
+
this.client = options.client ?? new HetznerClient({
|
|
2233
|
+
apiToken: resolveHetznerApiToken(options.apiToken)
|
|
2234
|
+
});
|
|
2235
|
+
this.sshPrivateKeyPath = expandHome(options.sshPrivateKeyPath || process.env.HCLOUD_SSH_KEY || "~/.ssh/id_ed25519");
|
|
2236
|
+
this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
|
|
2237
|
+
this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
|
|
2238
|
+
this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
|
|
2239
|
+
this.waitForBoot = options.waitForBoot ?? true;
|
|
2240
|
+
this.bootWait = {
|
|
2241
|
+
sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
|
|
2242
|
+
sshTimeoutMs: options.bootWait?.sshTimeoutMs ?? 300000,
|
|
2243
|
+
cloudInitIntervalMs: options.bootWait?.cloudInitIntervalMs ?? 5000,
|
|
2244
|
+
cloudInitTimeoutMs: options.bootWait?.cloudInitTimeoutMs ?? 600000
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
async provisionComputeInfrastructure(options) {
|
|
2248
|
+
const { config, environment } = options;
|
|
2249
|
+
const slug = config.project.slug;
|
|
2250
|
+
const compute = config.infrastructure?.compute;
|
|
2251
|
+
if (!compute) {
|
|
2252
|
+
throw new Error("infrastructure.compute is required to provision Hetzner compute");
|
|
2253
|
+
}
|
|
2254
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
2255
|
+
const serverName = `${slug}-${environment}-app`;
|
|
2256
|
+
const phpBox = compute.runtime === "php" || !!compute.php;
|
|
2257
|
+
const topology = resolveFleetTopology(compute);
|
|
2258
|
+
if (topology.dedicatedServices || topology.appServers > 1) {
|
|
2259
|
+
return phpBox ? this.provisionFleet(options, topology) : this.provisionBunFleet(options, topology);
|
|
2260
|
+
}
|
|
2261
|
+
const existing = await readDriverState(stackName);
|
|
2262
|
+
if (existing?.serverId) {
|
|
2263
|
+
const server2 = await this.tryGetServer(existing.serverId);
|
|
2264
|
+
if (server2 && server2.status !== "off") {
|
|
2265
|
+
return this.outputsFromState(existing, server2);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
const labels = tsCloudLabels(slug, environment, "app");
|
|
2269
|
+
const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
|
|
2270
|
+
if (alreadyRunning && alreadyRunning.status !== "off") {
|
|
2271
|
+
const rehydrated = {
|
|
2272
|
+
provider: "hetzner",
|
|
2273
|
+
stackName,
|
|
2274
|
+
serverId: alreadyRunning.id,
|
|
2275
|
+
serverName: alreadyRunning.name,
|
|
2276
|
+
publicIp: alreadyRunning.public_net.ipv4?.ip,
|
|
2277
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2278
|
+
sshUser: this.sshUser
|
|
2279
|
+
};
|
|
2280
|
+
await writeDriverState(stackName, rehydrated);
|
|
2281
|
+
return this.outputsFromState(rehydrated, alreadyRunning);
|
|
2282
|
+
}
|
|
2283
|
+
const sites = config.sites || {};
|
|
2284
|
+
const sitePorts = this.collectUpstreamPorts(sites);
|
|
2285
|
+
const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
|
|
2286
|
+
proxy: compute.proxy,
|
|
2287
|
+
config: buildRpxConfig(sites, { proxy: compute.proxy }),
|
|
2288
|
+
slug: config.project.slug,
|
|
2289
|
+
bunBin: compute.runtime === "node" || compute.runtime === "deno" ? undefined : "/usr/local/bin/bun"
|
|
2290
|
+
}) : undefined;
|
|
2291
|
+
const provision = buildComputeProvisionScripts(config);
|
|
2292
|
+
const baked = compute.bakedImage === true;
|
|
2293
|
+
const bootstrap = buildUbuntuBootstrapScript({
|
|
2294
|
+
runtime: provision.runtime,
|
|
2295
|
+
runtimeVersion: provision.runtimeVersion,
|
|
2296
|
+
systemPackages: compute.systemPackages,
|
|
2297
|
+
database: config.infrastructure?.database,
|
|
2298
|
+
phpProvision: provision.phpProvision,
|
|
2299
|
+
servicesProvision: provision.servicesProvision,
|
|
2300
|
+
rpxProvision,
|
|
2301
|
+
baked
|
|
2302
|
+
});
|
|
2303
|
+
const userData = wrapCloudInitUserData(bootstrap);
|
|
2304
|
+
const serverType = resolveHetznerServerType(compute.size);
|
|
2305
|
+
const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
|
|
2306
|
+
const firewallName = `${slug}-${environment}-app-fw`;
|
|
2307
|
+
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
2308
|
+
allowSsh: compute.allowSsh !== false,
|
|
2309
|
+
sitePorts
|
|
2310
|
+
}));
|
|
2311
|
+
const sshKeyId = await this.ensureSshKey(slug, environment, labels);
|
|
2312
|
+
const { server, action } = await this.client.createServer({
|
|
2313
|
+
name: serverName,
|
|
2314
|
+
serverType,
|
|
2315
|
+
image,
|
|
2316
|
+
location: config.hetzner?.location || this.location,
|
|
2317
|
+
userData,
|
|
2318
|
+
labels,
|
|
2319
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2320
|
+
firewalls: [{ firewall: firewall.id }]
|
|
2321
|
+
});
|
|
2322
|
+
await this.client.waitForAction(action.id);
|
|
2323
|
+
const running = await this.client.waitForServerRunning(server.id);
|
|
2324
|
+
const state = {
|
|
2325
|
+
provider: "hetzner",
|
|
2326
|
+
stackName,
|
|
2327
|
+
serverId: running.id,
|
|
2328
|
+
serverName: running.name,
|
|
2329
|
+
firewallId: firewall.id,
|
|
2330
|
+
publicIp: running.public_net.ipv4?.ip,
|
|
2331
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2332
|
+
sshUser: this.sshUser
|
|
2333
|
+
};
|
|
2334
|
+
await writeDriverState(stackName, state);
|
|
2335
|
+
const ip = running.public_net.ipv4?.ip;
|
|
2336
|
+
if (ip && this.waitForBoot) {
|
|
2337
|
+
await this.waitForSshReady(ip);
|
|
2338
|
+
await this.waitForCloudInit(ip);
|
|
2339
|
+
}
|
|
2340
|
+
return this.outputsFromState(state, running);
|
|
2341
|
+
}
|
|
2342
|
+
async provisionFleet(options, topology) {
|
|
2343
|
+
const { config, environment } = options;
|
|
2344
|
+
const slug = config.project.slug;
|
|
2345
|
+
const compute = config.infrastructure.compute;
|
|
2346
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
2347
|
+
const serverType = resolveHetznerServerType(compute.size);
|
|
2348
|
+
const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
|
|
2349
|
+
const location = config.hetzner?.location || this.location;
|
|
2350
|
+
const baked = compute.bakedImage === true;
|
|
2351
|
+
const existingState = await readDriverState(stackName);
|
|
2352
|
+
if (existingState?.loadBalancerId) {
|
|
2353
|
+
const lbs = await this.client.listLoadBalancers().catch(() => []);
|
|
2354
|
+
const lb = lbs.find((l) => l.id === existingState.loadBalancerId);
|
|
2355
|
+
if (lb) {
|
|
2356
|
+
return {
|
|
2357
|
+
appPublicIp: lb.public_net?.ipv4?.ip,
|
|
2358
|
+
loadBalancerIp: lb.public_net?.ipv4?.ip,
|
|
2359
|
+
servicesPrivateIp: existingState.servicesPrivateIp,
|
|
2360
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2361
|
+
sshUser: this.sshUser
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
const sshKeyId = await this.ensureSshKey(slug, environment, tsCloudLabels(slug, environment, "app"));
|
|
2366
|
+
const netName = `${slug}-${environment}-net`;
|
|
2367
|
+
const networks = await this.client.listNetworks().catch(() => []);
|
|
2368
|
+
const network = networks.find((n) => n.name === netName) ?? await this.client.createNetwork({ name: netName, labels: tsCloudLabels(slug, environment, "app") });
|
|
2369
|
+
const { firewall: appFw } = await this.ensureFirewall(`${slug}-${environment}-app-fw`, tsCloudLabels(slug, environment, "app"), buildHetznerFirewallRules({ allowSsh: true, sitePorts: [] }));
|
|
2370
|
+
const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
|
|
2371
|
+
{ direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
|
|
2372
|
+
{ direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
|
|
2373
|
+
{ direction: "in", protocol: "tcp", port: "5432", source_ips: [network.ip_range] },
|
|
2374
|
+
{ direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
|
|
2375
|
+
{ direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
|
|
2376
|
+
]);
|
|
2377
|
+
const servicesProvision = [
|
|
2378
|
+
...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
|
|
2379
|
+
...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
|
|
2380
|
+
...buildAutoUpdatesScript(true),
|
|
2381
|
+
...buildMonitoringScript(true),
|
|
2382
|
+
...buildAuthorizedKeysScript(compute.sshKeys)
|
|
2383
|
+
];
|
|
2384
|
+
const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", servicesProvision, baked }));
|
|
2385
|
+
const all = await this.client.listServers().catch(() => []);
|
|
2386
|
+
const newServerIds = [];
|
|
2387
|
+
let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
|
|
2388
|
+
if (!svcServer) {
|
|
2389
|
+
const { server, action } = await this.client.createServer({
|
|
2390
|
+
name: `${slug}-${environment}-services`,
|
|
2391
|
+
serverType: resolveHetznerServerType(typeof compute.servicesServer === "object" ? compute.servicesServer.size : compute.size),
|
|
2392
|
+
image,
|
|
2393
|
+
location,
|
|
2394
|
+
userData: servicesUserData,
|
|
2395
|
+
labels: tsCloudLabels(slug, environment, "services"),
|
|
2396
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2397
|
+
firewalls: [{ firewall: svcFw.id }],
|
|
2398
|
+
networks: [network.id]
|
|
2399
|
+
});
|
|
2400
|
+
await this.client.waitForAction(action.id);
|
|
2401
|
+
svcServer = await this.client.waitForServerRunning(server.id);
|
|
2402
|
+
newServerIds.push(server.id);
|
|
2403
|
+
}
|
|
2404
|
+
const servicesServerId = svcServer.id;
|
|
2405
|
+
const servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
|
|
2406
|
+
const appProvision = [
|
|
2407
|
+
...buildAutoUpdatesScript(true),
|
|
2408
|
+
...buildMonitoringScript(true),
|
|
2409
|
+
...buildAuthorizedKeysScript(compute.sshKeys),
|
|
2410
|
+
...buildNotifierScript(config.notifications)
|
|
2411
|
+
];
|
|
2412
|
+
const appPhp = buildPhpProvisionScript({
|
|
2413
|
+
versions: compute.php?.versions,
|
|
2414
|
+
default: compute.php?.default,
|
|
2415
|
+
extensions: compute.php?.extensions,
|
|
2416
|
+
installNginx: compute.webServer !== "rpx",
|
|
2417
|
+
optimizeForProduction: compute.php?.optimizeForProduction,
|
|
2418
|
+
ini: compute.php?.ini
|
|
2419
|
+
});
|
|
2420
|
+
const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", phpProvision: appPhp, servicesProvision: appProvision, baked }));
|
|
2421
|
+
const existingApp = all.filter((s) => matchesTsCloudLabels(s.labels, slug, environment, "app"));
|
|
2422
|
+
const appServerIds = existingApp.map((s) => s.id);
|
|
2423
|
+
if (existingApp.length > topology.appServers) {
|
|
2424
|
+
for (const extra of existingApp.slice(topology.appServers)) {
|
|
2425
|
+
await this.client.deleteServer(extra.id).catch(() => {});
|
|
2426
|
+
appServerIds.splice(appServerIds.indexOf(extra.id), 1);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
for (let i = existingApp.length;i < topology.appServers; i++) {
|
|
2430
|
+
const { server, action } = await this.client.createServer({
|
|
2431
|
+
name: `${slug}-${environment}-app-${i + 1}`,
|
|
2432
|
+
serverType,
|
|
2433
|
+
image,
|
|
2434
|
+
location,
|
|
2435
|
+
userData: appUserData,
|
|
2436
|
+
labels: tsCloudLabels(slug, environment, "app"),
|
|
2437
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2438
|
+
firewalls: [{ firewall: appFw.id }],
|
|
2439
|
+
networks: [network.id]
|
|
2440
|
+
});
|
|
2441
|
+
await this.client.waitForAction(action.id);
|
|
2442
|
+
appServerIds.push(server.id);
|
|
2443
|
+
newServerIds.push(server.id);
|
|
2444
|
+
}
|
|
2445
|
+
if (this.waitForBoot && newServerIds.length > 0) {
|
|
2446
|
+
await Promise.all(newServerIds.map(async (id) => {
|
|
2447
|
+
const running = await this.client.waitForServerRunning(id);
|
|
2448
|
+
const ip = running.public_net.ipv4?.ip;
|
|
2449
|
+
if (ip) {
|
|
2450
|
+
await this.waitForSshReady(ip);
|
|
2451
|
+
await this.waitForCloudInit(ip);
|
|
2452
|
+
}
|
|
2453
|
+
}));
|
|
2454
|
+
}
|
|
2455
|
+
const lbName = `${slug}-${environment}-lb`;
|
|
2456
|
+
let lbIp;
|
|
2457
|
+
let lbId;
|
|
2458
|
+
if (topology.loadBalancer) {
|
|
2459
|
+
const lbs = await this.client.listLoadBalancers().catch(() => []);
|
|
2460
|
+
const lb = lbs.find((l) => l.name === lbName) ?? await this.client.createLoadBalancer({
|
|
2461
|
+
name: lbName,
|
|
2462
|
+
location,
|
|
2463
|
+
network: network.id,
|
|
2464
|
+
labels: tsCloudLabels(slug, environment, "lb"),
|
|
2465
|
+
labelSelector: `ts-cloud/project=${slug},ts-cloud/environment=${environment},ts-cloud/role=app`,
|
|
2466
|
+
services: [
|
|
2467
|
+
{ listenPort: 80, destinationPort: 80 },
|
|
2468
|
+
{ listenPort: 443, destinationPort: 443 }
|
|
2469
|
+
]
|
|
2470
|
+
});
|
|
2471
|
+
lbId = lb.id;
|
|
2472
|
+
lbIp = lb.public_net?.ipv4?.ip;
|
|
2473
|
+
}
|
|
2474
|
+
const appPublicIp = lbIp ?? (await this.client.getServer(appServerIds[0]).catch(() => {
|
|
2475
|
+
return;
|
|
2476
|
+
}))?.public_net.ipv4?.ip;
|
|
2477
|
+
const state = {
|
|
2478
|
+
provider: "hetzner",
|
|
2479
|
+
stackName,
|
|
2480
|
+
networkId: network.id,
|
|
2481
|
+
loadBalancerId: lbId,
|
|
2482
|
+
servicesServerId,
|
|
2483
|
+
servicesPrivateIp,
|
|
2484
|
+
publicIp: appPublicIp,
|
|
2485
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2486
|
+
sshUser: this.sshUser
|
|
2487
|
+
};
|
|
2488
|
+
await writeDriverState(stackName, state);
|
|
2489
|
+
return {
|
|
2490
|
+
appPublicIp,
|
|
2491
|
+
loadBalancerIp: lbIp,
|
|
2492
|
+
servicesPrivateIp,
|
|
2493
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2494
|
+
sshUser: this.sshUser
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
async provisionBunFleet(options, topology) {
|
|
2498
|
+
const { config, environment } = options;
|
|
2499
|
+
const slug = config.project.slug;
|
|
2500
|
+
const compute = config.infrastructure.compute;
|
|
2501
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
2502
|
+
const serverType = resolveHetznerServerType(compute.size);
|
|
2503
|
+
const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
|
|
2504
|
+
const location = config.hetzner?.location || this.location;
|
|
2505
|
+
const baked = compute.bakedImage === true;
|
|
2506
|
+
const sites = config.sites || {};
|
|
2507
|
+
const existingState = await readDriverState(stackName);
|
|
2508
|
+
if (existingState?.lbServerId) {
|
|
2509
|
+
const lb = await this.tryGetServer(existingState.lbServerId);
|
|
2510
|
+
if (lb && lb.status !== "off") {
|
|
2511
|
+
return {
|
|
2512
|
+
appPublicIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
|
|
2513
|
+
loadBalancerIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
|
|
2514
|
+
servicesPrivateIp: existingState.servicesPrivateIp,
|
|
2515
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2516
|
+
sshUser: this.sshUser
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
const sshKeyId = await this.ensureSshKey(slug, environment, tsCloudLabels(slug, environment, "app"));
|
|
2521
|
+
const netName = `${slug}-${environment}-net`;
|
|
2522
|
+
const networks = await this.client.listNetworks().catch(() => []);
|
|
2523
|
+
const network = networks.find((n) => n.name === netName) ?? await this.client.createNetwork({ name: netName, labels: tsCloudLabels(slug, environment, "app") });
|
|
2524
|
+
const sitePorts = this.collectUpstreamPorts(sites);
|
|
2525
|
+
const { firewall: appFw } = await this.ensureFirewall(`${slug}-${environment}-app-fw`, tsCloudLabels(slug, environment, "app"), [
|
|
2526
|
+
{ direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
|
|
2527
|
+
...sitePorts.map((port) => ({
|
|
2528
|
+
direction: "in",
|
|
2529
|
+
protocol: "tcp",
|
|
2530
|
+
port: String(port),
|
|
2531
|
+
source_ips: [network.ip_range],
|
|
2532
|
+
description: `ts-cloud port ${port} (private, LB-only)`
|
|
2533
|
+
}))
|
|
2534
|
+
]);
|
|
2535
|
+
const { firewall: lbFw } = await this.ensureFirewall(`${slug}-${environment}-lb-fw`, tsCloudLabels(slug, environment, "lb"), buildHetznerFirewallRules({ allowSsh: true, sitePorts: [] }));
|
|
2536
|
+
const all = await this.client.listServers().catch(() => []);
|
|
2537
|
+
const newServerIds = [];
|
|
2538
|
+
let servicesServerId;
|
|
2539
|
+
let servicesPrivateIp;
|
|
2540
|
+
const wantsServicesBox = !!compute.servicesServer || !!compute.managedServices;
|
|
2541
|
+
if (topology.dedicatedServices && wantsServicesBox) {
|
|
2542
|
+
const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
|
|
2543
|
+
{ direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
|
|
2544
|
+
{ direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
|
|
2545
|
+
{ direction: "in", protocol: "tcp", port: "5432", source_ips: [network.ip_range] },
|
|
2546
|
+
{ direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
|
|
2547
|
+
{ direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
|
|
2548
|
+
]);
|
|
2549
|
+
const servicesProvision = [
|
|
2550
|
+
...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
|
|
2551
|
+
...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
|
|
2552
|
+
...buildAutoUpdatesScript(true),
|
|
2553
|
+
...buildMonitoringScript(true),
|
|
2554
|
+
...buildAuthorizedKeysScript(compute.sshKeys)
|
|
2555
|
+
];
|
|
2556
|
+
const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "bun", servicesProvision, baked }));
|
|
2557
|
+
let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
|
|
2558
|
+
if (!svcServer) {
|
|
2559
|
+
const { server, action } = await this.client.createServer({
|
|
2560
|
+
name: `${slug}-${environment}-services`,
|
|
2561
|
+
serverType: resolveHetznerServerType(typeof compute.servicesServer === "object" ? compute.servicesServer.size : compute.size),
|
|
2562
|
+
image,
|
|
2563
|
+
location,
|
|
2564
|
+
userData: servicesUserData,
|
|
2565
|
+
labels: tsCloudLabels(slug, environment, "services"),
|
|
2566
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2567
|
+
firewalls: [{ firewall: svcFw.id }],
|
|
2568
|
+
networks: [network.id]
|
|
2569
|
+
});
|
|
2570
|
+
await this.client.waitForAction(action.id);
|
|
2571
|
+
svcServer = await this.client.waitForServerRunning(server.id);
|
|
2572
|
+
newServerIds.push(server.id);
|
|
2573
|
+
}
|
|
2574
|
+
servicesServerId = svcServer.id;
|
|
2575
|
+
servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
|
|
2576
|
+
}
|
|
2577
|
+
const appProvisionScripts = buildComputeProvisionScripts(config);
|
|
2578
|
+
const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
|
|
2579
|
+
runtime: appProvisionScripts.runtime,
|
|
2580
|
+
runtimeVersion: appProvisionScripts.runtimeVersion,
|
|
2581
|
+
systemPackages: compute.systemPackages,
|
|
2582
|
+
database: config.infrastructure?.database,
|
|
2583
|
+
servicesProvision: appProvisionScripts.servicesProvision,
|
|
2584
|
+
baked
|
|
2585
|
+
}));
|
|
2586
|
+
const existingApp = all.filter((s) => matchesTsCloudLabels(s.labels, slug, environment, "app"));
|
|
2587
|
+
const appServerIds = existingApp.map((s) => s.id);
|
|
2588
|
+
if (existingApp.length > topology.appServers) {
|
|
2589
|
+
for (const extra of existingApp.slice(topology.appServers)) {
|
|
2590
|
+
await this.client.deleteServer(extra.id).catch(() => {});
|
|
2591
|
+
appServerIds.splice(appServerIds.indexOf(extra.id), 1);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
for (let i = existingApp.length;i < topology.appServers; i++) {
|
|
2595
|
+
const { server, action } = await this.client.createServer({
|
|
2596
|
+
name: `${slug}-${environment}-app-${i + 1}`,
|
|
2597
|
+
serverType,
|
|
2598
|
+
image,
|
|
2599
|
+
location,
|
|
2600
|
+
userData: appUserData,
|
|
2601
|
+
labels: tsCloudLabels(slug, environment, "app"),
|
|
2602
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2603
|
+
firewalls: [{ firewall: appFw.id }],
|
|
2604
|
+
networks: [network.id]
|
|
2605
|
+
});
|
|
2606
|
+
await this.client.waitForAction(action.id);
|
|
2607
|
+
appServerIds.push(server.id);
|
|
2608
|
+
newServerIds.push(server.id);
|
|
2609
|
+
}
|
|
2610
|
+
if (this.waitForBoot && newServerIds.length > 0) {
|
|
2611
|
+
await Promise.all(newServerIds.map(async (id) => {
|
|
2612
|
+
const running = await this.client.waitForServerRunning(id);
|
|
2613
|
+
const ip = running.public_net.ipv4?.ip;
|
|
2614
|
+
if (ip) {
|
|
2615
|
+
await this.waitForSshReady(ip);
|
|
2616
|
+
await this.waitForCloudInit(ip);
|
|
2617
|
+
}
|
|
2618
|
+
}));
|
|
2619
|
+
}
|
|
2620
|
+
const appBoxes = [];
|
|
2621
|
+
for (const id of appServerIds) {
|
|
2622
|
+
let server = all.find((s) => s.id === id);
|
|
2623
|
+
if (!server || !server.private_net?.[0]?.ip)
|
|
2624
|
+
server = await this.client.getServer(id).catch(() => server);
|
|
2625
|
+
appBoxes.push({ privateIp: server?.private_net?.[0]?.ip, publicIp: server?.public_net.ipv4?.ip });
|
|
2626
|
+
}
|
|
2627
|
+
const lbName = `${slug}-${environment}-lb`;
|
|
2628
|
+
let lbId;
|
|
2629
|
+
let lbIp;
|
|
2630
|
+
if (topology.loadBalancer) {
|
|
2631
|
+
let lbServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "lb"));
|
|
2632
|
+
if (!lbServer) {
|
|
2633
|
+
const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
|
|
2634
|
+
const lbRpxProvision = buildRpxProvisionScript({
|
|
2635
|
+
proxy: rpxProxy,
|
|
2636
|
+
config: buildRpxLbConfig(sites, appBoxes, { proxy: rpxProxy }),
|
|
2637
|
+
slug,
|
|
2638
|
+
bunBin: appProvisionScripts.runtime === "node" || appProvisionScripts.runtime === "deno" ? undefined : "/usr/local/bin/bun"
|
|
2639
|
+
});
|
|
2640
|
+
const lbUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
|
|
2641
|
+
runtime: "bun",
|
|
2642
|
+
rpxProvision: lbRpxProvision,
|
|
2643
|
+
baked: false
|
|
2644
|
+
}));
|
|
2645
|
+
const lbSize = typeof compute.loadBalancer === "object" ? compute.loadBalancer.size : undefined;
|
|
2646
|
+
const { server, action } = await this.client.createServer({
|
|
2647
|
+
name: lbName,
|
|
2648
|
+
serverType: resolveHetznerServerType(lbSize ?? "micro"),
|
|
2649
|
+
image,
|
|
2650
|
+
location,
|
|
2651
|
+
userData: lbUserData,
|
|
2652
|
+
labels: tsCloudLabels(slug, environment, "lb"),
|
|
2653
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
2654
|
+
firewalls: [{ firewall: lbFw.id }],
|
|
2655
|
+
networks: [network.id]
|
|
2656
|
+
});
|
|
2657
|
+
await this.client.waitForAction(action.id);
|
|
2658
|
+
lbServer = await this.client.waitForServerRunning(server.id);
|
|
2659
|
+
if (this.waitForBoot) {
|
|
2660
|
+
const ip = lbServer.public_net.ipv4?.ip;
|
|
2661
|
+
if (ip) {
|
|
2662
|
+
await this.waitForSshReady(ip);
|
|
2663
|
+
await this.waitForCloudInit(ip);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
lbId = lbServer.id;
|
|
2668
|
+
lbIp = lbServer.public_net.ipv4?.ip;
|
|
2669
|
+
}
|
|
2670
|
+
const appPublicIp = lbIp ?? (await this.client.getServer(appServerIds[0]).catch(() => {
|
|
2671
|
+
return;
|
|
2672
|
+
}))?.public_net.ipv4?.ip;
|
|
2673
|
+
const state = {
|
|
2674
|
+
provider: "hetzner",
|
|
2675
|
+
stackName,
|
|
2676
|
+
networkId: network.id,
|
|
2677
|
+
lbServerId: lbId,
|
|
2678
|
+
appServerIds,
|
|
2679
|
+
servicesServerId,
|
|
2680
|
+
servicesPrivateIp,
|
|
2681
|
+
publicIp: appPublicIp,
|
|
2682
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2683
|
+
sshUser: this.sshUser
|
|
2684
|
+
};
|
|
2685
|
+
await writeDriverState(stackName, state);
|
|
2686
|
+
return {
|
|
2687
|
+
appPublicIp,
|
|
2688
|
+
loadBalancerIp: lbIp,
|
|
2689
|
+
servicesPrivateIp,
|
|
2690
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2691
|
+
sshUser: this.sshUser
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
async destroyCompute(options) {
|
|
2695
|
+
const { config, environment } = options;
|
|
2696
|
+
const slug = config.project.slug;
|
|
2697
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
2698
|
+
const state = await readDriverState(stackName);
|
|
2699
|
+
const destroyed = [];
|
|
2700
|
+
const lbName = `${slug}-${environment}-lb`;
|
|
2701
|
+
const lbs = await this.client.listLoadBalancers().catch(() => []);
|
|
2702
|
+
const lb = lbs.find((l) => l.name === lbName);
|
|
2703
|
+
if (lb) {
|
|
2704
|
+
try {
|
|
2705
|
+
await this.client.deleteLoadBalancer(lb.id);
|
|
2706
|
+
destroyed.push(`load balancer ${lbName}`);
|
|
2707
|
+
} catch {}
|
|
2708
|
+
}
|
|
2709
|
+
const allServers = await this.client.listServers().catch(() => []);
|
|
2710
|
+
const serverIds = new Set;
|
|
2711
|
+
for (const s of allServers) {
|
|
2712
|
+
if (matchesTsCloudLabels(s.labels, slug, environment, "app") || matchesTsCloudLabels(s.labels, slug, environment, "services") || matchesTsCloudLabels(s.labels, slug, environment, "lb"))
|
|
2713
|
+
serverIds.add(s.id);
|
|
2714
|
+
}
|
|
2715
|
+
if (state?.serverId)
|
|
2716
|
+
serverIds.add(state.serverId);
|
|
2717
|
+
if (state?.servicesServerId)
|
|
2718
|
+
serverIds.add(state.servicesServerId);
|
|
2719
|
+
if (state?.lbServerId)
|
|
2720
|
+
serverIds.add(state.lbServerId);
|
|
2721
|
+
for (const id of state?.appServerIds ?? [])
|
|
2722
|
+
serverIds.add(id);
|
|
2723
|
+
for (const id of serverIds) {
|
|
2724
|
+
try {
|
|
2725
|
+
await this.client.deleteServer(id);
|
|
2726
|
+
destroyed.push(`server ${id}`);
|
|
2727
|
+
} catch {}
|
|
2728
|
+
}
|
|
2729
|
+
const firewalls = await this.client.listFirewalls().catch(() => []);
|
|
2730
|
+
for (const name of [`${slug}-${environment}-app-fw`, `${slug}-${environment}-services-fw`, `${slug}-${environment}-lb-fw`]) {
|
|
2731
|
+
const fw = firewalls.find((f) => f.name === name);
|
|
2732
|
+
if (!fw)
|
|
2733
|
+
continue;
|
|
2734
|
+
for (let i = 0;i < 12; i++) {
|
|
2735
|
+
try {
|
|
2736
|
+
await this.client.deleteFirewall(fw.id);
|
|
2737
|
+
destroyed.push(`firewall ${name}`);
|
|
2738
|
+
break;
|
|
2739
|
+
} catch {
|
|
2740
|
+
await this.sleep(3000);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
const netName = `${slug}-${environment}-net`;
|
|
2745
|
+
const networks = await this.client.listNetworks().catch(() => []);
|
|
2746
|
+
const net = networks.find((n) => n.name === netName);
|
|
2747
|
+
if (net) {
|
|
2748
|
+
for (let i = 0;i < 12; i++) {
|
|
2749
|
+
try {
|
|
2750
|
+
await this.client.deleteNetwork(net.id);
|
|
2751
|
+
destroyed.push(`network ${netName}`);
|
|
2752
|
+
break;
|
|
2753
|
+
} catch {
|
|
2754
|
+
await this.sleep(3000);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
await writeDriverState(stackName, { provider: "hetzner", stackName }).catch(() => {});
|
|
2759
|
+
return { destroyed };
|
|
2760
|
+
}
|
|
2761
|
+
async getComputeOutputs(options) {
|
|
2762
|
+
const stackName = resolveProjectStackName(options.config, options.environment);
|
|
2763
|
+
const state = await readDriverState(stackName);
|
|
2764
|
+
if (state?.serverId) {
|
|
2765
|
+
const server = await this.client.getServer(state.serverId);
|
|
2766
|
+
return this.outputsFromState(state, server);
|
|
2767
|
+
}
|
|
2768
|
+
if (state?.lbServerId) {
|
|
2769
|
+
const lb = await this.tryGetServer(state.lbServerId);
|
|
2770
|
+
const lbIp = lb?.public_net.ipv4?.ip ?? state.publicIp;
|
|
2771
|
+
return {
|
|
2772
|
+
...this.outputsFromState(state),
|
|
2773
|
+
appPublicIp: lbIp,
|
|
2774
|
+
loadBalancerIp: lbIp
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
if (state?.loadBalancerId || state?.servicesPrivateIp) {
|
|
2778
|
+
return this.outputsFromState(state);
|
|
2779
|
+
}
|
|
2780
|
+
const targets = await this.findComputeTargets({
|
|
2781
|
+
slug: options.config.project.slug,
|
|
2782
|
+
environment: options.environment,
|
|
2783
|
+
role: "app",
|
|
2784
|
+
stackName: resolveProjectStackName(options.config, options.environment)
|
|
2785
|
+
});
|
|
2786
|
+
const first = targets[0];
|
|
2787
|
+
return {
|
|
2788
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
2789
|
+
appInstanceId: first?.id,
|
|
2790
|
+
appPublicIp: first?.publicIp,
|
|
2791
|
+
sshUser: this.sshUser
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
async uploadRelease(options) {
|
|
2795
|
+
const targets = options.targets?.length ? options.targets : await this.findComputeTargets({
|
|
2796
|
+
slug: options.config.project.slug,
|
|
2797
|
+
environment: options.environment,
|
|
2798
|
+
role: "app",
|
|
2799
|
+
stackName: resolveProjectStackName(options.config, options.environment)
|
|
2800
|
+
});
|
|
2801
|
+
if (targets.length === 0) {
|
|
2802
|
+
throw new Error("No Hetzner compute targets found for release upload");
|
|
2803
|
+
}
|
|
2804
|
+
const stagingName = options.remoteKey.replace(/^releases\//, "").replace(/\//g, "-");
|
|
2805
|
+
const remotePath = `/var/ts-cloud/staging/${stagingName}`;
|
|
2806
|
+
for (const target of targets) {
|
|
2807
|
+
if (!target.publicIp) {
|
|
2808
|
+
throw new Error(`Target ${target.id} has no public IP for SCP upload`);
|
|
2809
|
+
}
|
|
2810
|
+
this.scpToHost(target.publicIp, options.localPath, remotePath);
|
|
2811
|
+
}
|
|
2812
|
+
return { artifactRef: remotePath };
|
|
2813
|
+
}
|
|
2814
|
+
async findComputeTargets(options) {
|
|
2815
|
+
const servers = await this.client.listServers();
|
|
2816
|
+
const role = options.role || "app";
|
|
2817
|
+
const toTarget = (server) => ({
|
|
2818
|
+
id: String(server.id),
|
|
2819
|
+
name: server.name,
|
|
2820
|
+
publicIp: server.public_net.ipv4?.ip,
|
|
2821
|
+
privateIp: server.private_net?.[0]?.ip,
|
|
2822
|
+
status: server.status
|
|
2823
|
+
});
|
|
2824
|
+
const exact = servers.filter((server) => matchesTsCloudLabels(server.labels, options.slug, options.environment, role));
|
|
2825
|
+
if (exact.length > 0)
|
|
2826
|
+
return exact.map(toTarget);
|
|
2827
|
+
if (role === "app") {
|
|
2828
|
+
const state = await readDriverState(options.stackName ?? `${options.slug}-${options.environment}`);
|
|
2829
|
+
const pinnedIds = [state?.serverId, ...state?.appServerIds ?? []].filter((id) => typeof id === "number");
|
|
2830
|
+
if (pinnedIds.length > 0) {
|
|
2831
|
+
const pinned = [];
|
|
2832
|
+
for (const id of pinnedIds) {
|
|
2833
|
+
const server = servers.find((candidate) => candidate.id === id) ?? await this.tryGetServer(id);
|
|
2834
|
+
if (server && server.status !== "off")
|
|
2835
|
+
pinned.push(server);
|
|
2836
|
+
}
|
|
2837
|
+
if (pinned.length > 0)
|
|
2838
|
+
return pinned.map(toTarget);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
if (role === "app") {
|
|
2842
|
+
const candidates = servers.filter((server) => server.status !== "off" && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/managed-by`] === "ts-cloud" && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/environment`] === options.environment && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/role`] === "app");
|
|
2843
|
+
if (candidates.length === 1)
|
|
2844
|
+
return candidates.map(toTarget);
|
|
2845
|
+
}
|
|
2846
|
+
return [];
|
|
2847
|
+
}
|
|
2848
|
+
async runRemoteDeploy(options) {
|
|
2849
|
+
if (options.targets.length === 0) {
|
|
2850
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "No targets provided" };
|
|
2851
|
+
}
|
|
2852
|
+
const script = options.commands.join(`
|
|
2853
|
+
`);
|
|
2854
|
+
const perInstance = [];
|
|
2855
|
+
for (const target of options.targets) {
|
|
2856
|
+
if (!target.publicIp) {
|
|
2857
|
+
perInstance.push({
|
|
2858
|
+
instanceId: target.id,
|
|
2859
|
+
status: "Failed",
|
|
2860
|
+
error: "Missing public IP"
|
|
2861
|
+
});
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
try {
|
|
2865
|
+
const output = this.sshExec(target.publicIp, script);
|
|
2866
|
+
perInstance.push({
|
|
2867
|
+
instanceId: target.id,
|
|
2868
|
+
status: "Success",
|
|
2869
|
+
output
|
|
2870
|
+
});
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
perInstance.push({
|
|
2873
|
+
instanceId: target.id,
|
|
2874
|
+
status: "Failed",
|
|
2875
|
+
error: err.message
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
const success = perInstance.every((r) => r.status === "Success");
|
|
2880
|
+
return {
|
|
2881
|
+
success,
|
|
2882
|
+
instanceCount: options.targets.length,
|
|
2883
|
+
perInstance,
|
|
2884
|
+
error: success ? undefined : "One or more SSH deploy commands failed"
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
async ensureSshKey(slug, environment, labels) {
|
|
2888
|
+
if (!existsSync(this.sshPublicKeyPath)) {
|
|
2889
|
+
throw new Error(`SSH public key not found at ${this.sshPublicKeyPath}. ts-cloud deploys to Hetzner over SSH and needs a public key to authorize on the server. ` + `Generate one (\`ssh-keygen -t ed25519\`) or set hetzner.sshPrivateKeyPath / HCLOUD_SSH_PUBLIC_KEY.`);
|
|
2890
|
+
}
|
|
2891
|
+
const publicKey = readFileSync2(this.sshPublicKeyPath, "utf8").trim();
|
|
2892
|
+
const normalized = normalizeSshPublicKey(publicKey);
|
|
2893
|
+
const existing = await this.client.listSshKeys();
|
|
2894
|
+
const match = existing.find((key) => normalizeSshPublicKey(key.public_key) === normalized);
|
|
2895
|
+
if (match)
|
|
2896
|
+
return match.id;
|
|
2897
|
+
const created = await this.client.createSshKey({
|
|
2898
|
+
name: `${slug}-${environment}-deploy`,
|
|
2899
|
+
publicKey,
|
|
2900
|
+
labels
|
|
2901
|
+
});
|
|
2902
|
+
return created.id;
|
|
2903
|
+
}
|
|
2904
|
+
async tryGetServer(id) {
|
|
2905
|
+
try {
|
|
2906
|
+
return await this.client.getServer(id);
|
|
2907
|
+
} catch {
|
|
2908
|
+
return null;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
async findExistingServer(slug, environment, serverName) {
|
|
2912
|
+
const servers = await this.client.listServers();
|
|
2913
|
+
const exact = servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
|
|
2914
|
+
if (exact)
|
|
2915
|
+
return exact;
|
|
2916
|
+
const candidates = servers.filter((server) => server.status !== "off" && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/managed-by`] === "ts-cloud" && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/environment`] === environment && server.labels?.[`${TS_CLOUD_LABEL_PREFIX}/role`] === "app");
|
|
2917
|
+
if (candidates.length === 1) {
|
|
2918
|
+
const adopted = candidates[0];
|
|
2919
|
+
const adoptedProject = adopted.labels?.[`${TS_CLOUD_LABEL_PREFIX}/project`] ?? "unknown";
|
|
2920
|
+
console.warn(`[ts-cloud] No server named '${serverName}' found; adopting existing ts-cloud app server ` + `'${adopted.name}' (project label '${adoptedProject}') for project '${slug}' — ` + `updating it in place instead of provisioning a new server.`);
|
|
2921
|
+
return adopted;
|
|
2922
|
+
}
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
async ensureFirewall(name, labels, rules) {
|
|
2926
|
+
const existing = await this.client.listFirewalls();
|
|
2927
|
+
const match = existing.find((fw) => fw.name === name);
|
|
2928
|
+
if (match) {
|
|
2929
|
+
await this.client.setFirewallRules(match.id, rules);
|
|
2930
|
+
return { firewall: match };
|
|
2931
|
+
}
|
|
2932
|
+
const { firewall } = await this.client.createFirewall({ name, labels, rules });
|
|
2933
|
+
return { firewall };
|
|
2934
|
+
}
|
|
2935
|
+
collectUpstreamPorts(sites) {
|
|
2936
|
+
const ports = new Set;
|
|
2937
|
+
for (const site of Object.values(sites)) {
|
|
2938
|
+
if (typeof site.port === "number")
|
|
2939
|
+
ports.add(site.port);
|
|
2940
|
+
}
|
|
2941
|
+
return [...ports].filter((port) => ![80, 443].includes(port));
|
|
2942
|
+
}
|
|
2943
|
+
async sleep(ms) {
|
|
2944
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
2945
|
+
}
|
|
2946
|
+
async waitForSshReady(host) {
|
|
2947
|
+
const { sshIntervalMs, sshTimeoutMs } = this.bootWait;
|
|
2948
|
+
const start = Date.now();
|
|
2949
|
+
let lastErr;
|
|
2950
|
+
while (Date.now() - start < sshTimeoutMs) {
|
|
2951
|
+
try {
|
|
2952
|
+
execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} true`, {
|
|
2953
|
+
stdio: "pipe",
|
|
2954
|
+
maxBuffer: SSH_MAX_BUFFER
|
|
2955
|
+
});
|
|
2956
|
+
return;
|
|
2957
|
+
} catch (err) {
|
|
2958
|
+
lastErr = err;
|
|
2959
|
+
await this.sleep(sshIntervalMs);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
throw new Error(`Timed out waiting for SSH on ${host} after ${sshTimeoutMs}ms: ${lastErr?.message ?? "unknown error"}`);
|
|
2963
|
+
}
|
|
2964
|
+
async waitForCloudInit(host) {
|
|
2965
|
+
const { cloudInitIntervalMs, cloudInitTimeoutMs } = this.bootWait;
|
|
2966
|
+
const start = Date.now();
|
|
2967
|
+
while (Date.now() - start < cloudInitTimeoutMs) {
|
|
2968
|
+
try {
|
|
2969
|
+
const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
|
|
2970
|
+
if (/status:\s*done/.test(out))
|
|
2971
|
+
return;
|
|
2972
|
+
if (/status:\s*error/.test(out))
|
|
2973
|
+
throw new Error(`cloud-init reported an error on ${host}:
|
|
2974
|
+
${out}`);
|
|
2975
|
+
} catch (err) {
|
|
2976
|
+
if (err instanceof Error && /cloud-init reported an error/.test(err.message))
|
|
2977
|
+
throw err;
|
|
2978
|
+
}
|
|
2979
|
+
await this.sleep(cloudInitIntervalMs);
|
|
2980
|
+
}
|
|
2981
|
+
throw new Error(`Timed out waiting for cloud-init to finish on ${host} after ${cloudInitTimeoutMs}ms`);
|
|
2982
|
+
}
|
|
2983
|
+
outputsFromState(state, server) {
|
|
2984
|
+
return {
|
|
2985
|
+
deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
|
|
2986
|
+
appInstanceId: state.serverId ? String(state.serverId) : undefined,
|
|
2987
|
+
appPublicIp: server?.public_net.ipv4?.ip || state.publicIp,
|
|
2988
|
+
sshUser: state.sshUser || this.sshUser,
|
|
2989
|
+
servicesPrivateIp: state.servicesPrivateIp
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
static SSH_HOST_KEY_OPTS = [
|
|
2993
|
+
"-o",
|
|
2994
|
+
"StrictHostKeyChecking=no",
|
|
2995
|
+
"-o",
|
|
2996
|
+
"UserKnownHostsFile=/dev/null",
|
|
2997
|
+
"-o",
|
|
2998
|
+
"LogLevel=ERROR"
|
|
2999
|
+
];
|
|
3000
|
+
sshBaseArgs(host, extra = []) {
|
|
3001
|
+
return [
|
|
3002
|
+
"-i",
|
|
3003
|
+
this.sshPrivateKeyPath,
|
|
3004
|
+
...HetznerDriver.SSH_HOST_KEY_OPTS,
|
|
3005
|
+
"-o",
|
|
3006
|
+
"BatchMode=yes",
|
|
3007
|
+
...extra,
|
|
3008
|
+
`${this.sshUser}@${host}`
|
|
3009
|
+
];
|
|
3010
|
+
}
|
|
3011
|
+
scpToHost(host, localPath, remotePath) {
|
|
3012
|
+
execSync([
|
|
3013
|
+
"scp",
|
|
3014
|
+
"-i",
|
|
3015
|
+
this.sshPrivateKeyPath,
|
|
3016
|
+
...HetznerDriver.SSH_HOST_KEY_OPTS,
|
|
3017
|
+
"-o",
|
|
3018
|
+
"BatchMode=yes",
|
|
3019
|
+
localPath,
|
|
3020
|
+
`${this.sshUser}@${host}:${remotePath}`
|
|
3021
|
+
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
|
|
3022
|
+
}
|
|
3023
|
+
sshExec(host, script) {
|
|
3024
|
+
const escaped = script.replace(/'/g, `'\\''`);
|
|
3025
|
+
return execSync(`ssh ${this.sshBaseArgs(host).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} '${escaped}'`, {
|
|
3026
|
+
encoding: "utf8",
|
|
3027
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3028
|
+
maxBuffer: SSH_MAX_BUFFER
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// src/drivers/local-box/driver.ts
|
|
3034
|
+
class LocalBoxDriver {
|
|
3035
|
+
name = "hetzner";
|
|
3036
|
+
usesCloudFormation = false;
|
|
3037
|
+
async getComputeOutputs() {
|
|
3038
|
+
return { appPublicIp: "127.0.0.1", sshUser: "root" };
|
|
3039
|
+
}
|
|
3040
|
+
async uploadRelease(options) {
|
|
3041
|
+
return { artifactRef: options.localPath };
|
|
3042
|
+
}
|
|
3043
|
+
async findComputeTargets(_options) {
|
|
3044
|
+
return [{ id: "localhost", name: "localhost", publicIp: "127.0.0.1", status: "running" }];
|
|
3045
|
+
}
|
|
3046
|
+
async runRemoteDeploy(options) {
|
|
3047
|
+
const script = options.commands.join(`
|
|
3048
|
+
`);
|
|
3049
|
+
try {
|
|
3050
|
+
const proc = Bun.spawn(["bash", "-c", script], { stdout: "pipe", stderr: "pipe", env: process.env });
|
|
3051
|
+
const [output, error, exitCode] = await Promise.all([
|
|
3052
|
+
new Response(proc.stdout).text(),
|
|
3053
|
+
new Response(proc.stderr).text(),
|
|
3054
|
+
proc.exited
|
|
3055
|
+
]);
|
|
3056
|
+
return {
|
|
3057
|
+
success: exitCode === 0,
|
|
3058
|
+
instanceCount: 1,
|
|
3059
|
+
perInstance: [{ instanceId: "localhost", status: exitCode === 0 ? "Success" : "Failed", output, error }]
|
|
3060
|
+
};
|
|
3061
|
+
} catch (err) {
|
|
3062
|
+
return {
|
|
3063
|
+
success: false,
|
|
3064
|
+
instanceCount: 1,
|
|
3065
|
+
perInstance: [{ instanceId: "localhost", status: "Failed", error: err?.message ?? String(err) }],
|
|
3066
|
+
error: err?.message ?? String(err)
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
function isBoxMode() {
|
|
3072
|
+
const v = process.env.TS_CLOUD_DASHBOARD_BOX;
|
|
3073
|
+
return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
// src/drivers/factory.ts
|
|
3077
|
+
function createCloudDriver(options) {
|
|
3078
|
+
if (isBoxMode())
|
|
3079
|
+
return new LocalBoxDriver;
|
|
3080
|
+
const provider = options.provider ?? resolveCloudProvider(options.config);
|
|
3081
|
+
switch (provider) {
|
|
3082
|
+
case "aws":
|
|
3083
|
+
return new AwsDriver({ region: options.config.project.region });
|
|
3084
|
+
case "hetzner":
|
|
3085
|
+
return new HetznerDriver({
|
|
3086
|
+
apiToken: options.config.hetzner?.apiToken,
|
|
3087
|
+
sshPrivateKeyPath: options.config.hetzner?.sshPrivateKeyPath,
|
|
3088
|
+
sshUser: options.config.hetzner?.sshUser,
|
|
3089
|
+
location: options.config.hetzner?.location
|
|
3090
|
+
});
|
|
3091
|
+
default:
|
|
3092
|
+
throw new Error(`Unknown cloud provider: ${options.provider ?? resolveCloudProvider(options.config)}`);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
class CloudDriverFactory {
|
|
3097
|
+
drivers = new Map;
|
|
3098
|
+
getDriver(config, provider) {
|
|
3099
|
+
const name = provider ?? resolveCloudProvider(config);
|
|
3100
|
+
const cacheKey = `${name}:${config.project.slug}:${config.project.region || "default"}`;
|
|
3101
|
+
const cached = this.drivers.get(cacheKey);
|
|
3102
|
+
if (cached)
|
|
3103
|
+
return cached;
|
|
3104
|
+
const driver = createCloudDriver({ config, provider: name });
|
|
3105
|
+
this.drivers.set(cacheKey, driver);
|
|
3106
|
+
return driver;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
var cloudDrivers = new CloudDriverFactory;
|
|
3110
|
+
// src/drivers/hetzner/provision.ts
|
|
3111
|
+
async function ensureSshKey(client, options) {
|
|
3112
|
+
const body = normalizeSshPublicKey(options.publicKey);
|
|
3113
|
+
const existing = (await client.listSshKeys()).find((k) => normalizeSshPublicKey(k.public_key) === body);
|
|
3114
|
+
if (existing)
|
|
3115
|
+
return { id: existing.id, name: existing.name, created: false };
|
|
3116
|
+
const created = await client.createSshKey({ name: options.name, publicKey: options.publicKey.trim(), labels: options.labels });
|
|
3117
|
+
return { id: created.id, name: created.name, created: true };
|
|
3118
|
+
}
|
|
3119
|
+
async function ensureFirewall(client, options) {
|
|
3120
|
+
const existing = (await client.listFirewalls()).find((f) => f.name === options.name);
|
|
3121
|
+
if (existing) {
|
|
3122
|
+
await client.setFirewallRules(existing.id, options.rules);
|
|
3123
|
+
return { id: existing.id, name: existing.name, created: false };
|
|
3124
|
+
}
|
|
3125
|
+
const { firewall } = await client.createFirewall({ name: options.name, rules: options.rules, labels: options.labels });
|
|
3126
|
+
return { id: firewall.id, name: firewall.name, created: true };
|
|
3127
|
+
}
|
|
3128
|
+
async function ensureServer(client, options) {
|
|
3129
|
+
const { waitForRunning = true, ...createOptions } = options;
|
|
3130
|
+
let server = (await client.listServers()).find((s) => s.name === options.name);
|
|
3131
|
+
const created = !server;
|
|
3132
|
+
if (!server)
|
|
3133
|
+
server = (await client.createServer(createOptions)).server;
|
|
3134
|
+
if (waitForRunning)
|
|
3135
|
+
server = await client.waitForServerRunning(server.id);
|
|
3136
|
+
return { server, created };
|
|
3137
|
+
}
|
|
3138
|
+
function serverPublicIpv4(server) {
|
|
3139
|
+
const ip = server.public_net.ipv4?.ip;
|
|
3140
|
+
if (!ip)
|
|
3141
|
+
throw new Error(`Hetzner server "${server.name}" (#${server.id}) has no public IPv4`);
|
|
3142
|
+
return ip;
|
|
3143
|
+
}
|
|
3144
|
+
// src/drivers/shared/remote-exec.ts
|
|
3145
|
+
function buildSshArgs(options = {}) {
|
|
3146
|
+
const args = [];
|
|
3147
|
+
if (options.identityFile)
|
|
3148
|
+
args.push("-i", options.identityFile);
|
|
3149
|
+
args.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", `ConnectTimeout=${options.connectTimeoutSec ?? 10}`);
|
|
3150
|
+
return args;
|
|
3151
|
+
}
|
|
3152
|
+
async function sshExec(host, command, options = {}) {
|
|
3153
|
+
const proc = Bun.spawn(["ssh", ...buildSshArgs(options), `${options.user ?? "root"}@${host}`, command], {
|
|
3154
|
+
stdout: "pipe",
|
|
3155
|
+
stderr: "pipe"
|
|
3156
|
+
});
|
|
3157
|
+
const [stdout, stderr] = await Promise.all([
|
|
3158
|
+
new Response(proc.stdout).text(),
|
|
3159
|
+
new Response(proc.stderr).text()
|
|
3160
|
+
]);
|
|
3161
|
+
const code = await proc.exited;
|
|
3162
|
+
return { code, stdout, stderr };
|
|
3163
|
+
}
|
|
3164
|
+
async function sshExecOrThrow(host, command, options = {}) {
|
|
3165
|
+
const result = await sshExec(host, command, options);
|
|
3166
|
+
if (result.code !== 0)
|
|
3167
|
+
throw new Error(`ssh \`${command}\` on ${host} failed (${result.code}): ${result.stderr.trim() || result.stdout.trim()}`);
|
|
3168
|
+
return result.stdout;
|
|
3169
|
+
}
|
|
3170
|
+
async function scpUpload(host, localPaths, remoteDir, options = {}) {
|
|
3171
|
+
const proc = Bun.spawn(["scp", ...buildSshArgs(options), ...localPaths, `${options.user ?? "root"}@${host}:${remoteDir}`], {
|
|
3172
|
+
stdout: "pipe",
|
|
3173
|
+
stderr: "pipe"
|
|
3174
|
+
});
|
|
3175
|
+
const code = await proc.exited;
|
|
3176
|
+
if (code !== 0) {
|
|
3177
|
+
const stderr = await new Response(proc.stderr).text();
|
|
3178
|
+
throw new Error(`scp to ${host}:${remoteDir} failed (${code}): ${stderr.trim()}`);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
async function waitForSsh(host, options = {}) {
|
|
3182
|
+
const timeoutMs = options.timeoutMs ?? 180000;
|
|
3183
|
+
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
3184
|
+
const start = Date.now();
|
|
3185
|
+
while (Date.now() - start < timeoutMs) {
|
|
3186
|
+
const r = await sshExec(host, "echo ready", options);
|
|
3187
|
+
if (r.code === 0 && r.stdout.includes("ready"))
|
|
3188
|
+
return;
|
|
3189
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
3190
|
+
}
|
|
3191
|
+
throw new Error(`SSH not ready on ${host} after ${timeoutMs}ms`);
|
|
3192
|
+
}
|
|
3193
|
+
async function waitForCloudInit(host, options = {}) {
|
|
3194
|
+
const timeoutMs = options.timeoutMs ?? 300000;
|
|
3195
|
+
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
3196
|
+
const start = Date.now();
|
|
3197
|
+
while (Date.now() - start < timeoutMs) {
|
|
3198
|
+
const r = await sshExec(host, "cloud-init status 2>/dev/null || echo unknown", options);
|
|
3199
|
+
if (r.stdout.includes("status: done"))
|
|
3200
|
+
return;
|
|
3201
|
+
if (r.stdout.includes("status: error"))
|
|
3202
|
+
throw new Error(`cloud-init reported an error on ${host}; check /var/log/cloud-init-output.log`);
|
|
3203
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
3204
|
+
}
|
|
3205
|
+
throw new Error(`cloud-init did not finish on ${host} after ${timeoutMs}ms`);
|
|
3206
|
+
}
|
|
3207
|
+
// src/drivers/shared/env-file.ts
|
|
3208
|
+
function formatEnvFile(env) {
|
|
3209
|
+
return Object.entries(env).map(([k, v]) => `${k}=${quoteEnvValue(String(v))}`).join(`
|
|
3210
|
+
`);
|
|
3211
|
+
}
|
|
3212
|
+
function quoteEnvValue(value) {
|
|
3213
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
3214
|
+
return `"${escaped}"`;
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// src/drivers/shared/releases.ts
|
|
3218
|
+
var DEFAULT_SHARED_PATHS = ["storage", ".env"];
|
|
3219
|
+
var DEFAULT_KEEP_RELEASES = 4;
|
|
3220
|
+
var DEFAULT_KEEP_DEPLOY_LOGS = 20;
|
|
3221
|
+
function deployMetaDir(base) {
|
|
3222
|
+
return `${base.replace(/\/+$/, "")}/.ts-cloud`;
|
|
3223
|
+
}
|
|
3224
|
+
function deployHistoryPath(base) {
|
|
3225
|
+
return `${deployMetaDir(base)}/deploy-history.log`;
|
|
3226
|
+
}
|
|
3227
|
+
function deployLogPath(base, releaseId) {
|
|
3228
|
+
return `${deployMetaDir(base)}/deploys/${releaseId}.log`;
|
|
3229
|
+
}
|
|
3230
|
+
function releasePaths(base, releaseId) {
|
|
3231
|
+
const root = base.replace(/\/+$/, "");
|
|
3232
|
+
return {
|
|
3233
|
+
base: root,
|
|
3234
|
+
releases: `${root}/releases`,
|
|
3235
|
+
shared: `${root}/shared`,
|
|
3236
|
+
current: `${root}/current`,
|
|
3237
|
+
release: `${root}/releases/${releaseId}`
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
function isFileSharedPath(p) {
|
|
3241
|
+
const name = p.split("/").pop() || p;
|
|
3242
|
+
return name.startsWith(".") || /\.[a-z0-9]+$/i.test(name);
|
|
3243
|
+
}
|
|
3244
|
+
function buildEnsureReleaseLayout(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
|
|
3245
|
+
const lines = [
|
|
3246
|
+
`mkdir -p ${paths.releases} ${paths.shared}`
|
|
3247
|
+
];
|
|
3248
|
+
for (const p of sharedPaths) {
|
|
3249
|
+
if (isFileSharedPath(p)) {
|
|
3250
|
+
lines.push(`mkdir -p "$(dirname ${paths.shared}/${p})"`, `touch ${paths.shared}/${p}`);
|
|
3251
|
+
} else if (p === "storage") {
|
|
3252
|
+
lines.push(`mkdir -p ${paths.shared}/storage/app/public`, `mkdir -p ${paths.shared}/storage/framework/cache/data`, `mkdir -p ${paths.shared}/storage/framework/sessions`, `mkdir -p ${paths.shared}/storage/framework/testing`, `mkdir -p ${paths.shared}/storage/framework/views`, `mkdir -p ${paths.shared}/storage/logs`);
|
|
3253
|
+
} else {
|
|
3254
|
+
lines.push(`mkdir -p ${paths.shared}/${p}`);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
return lines;
|
|
3258
|
+
}
|
|
3259
|
+
function buildLinkSharedPaths(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
|
|
3260
|
+
const lines = [];
|
|
3261
|
+
for (const p of sharedPaths) {
|
|
3262
|
+
const target = `${paths.shared}/${p}`;
|
|
3263
|
+
const link = `${paths.release}/${p}`;
|
|
3264
|
+
lines.push(`rm -rf ${link}`, `mkdir -p "$(dirname ${link})"`, `ln -sfn ${target} ${link}`);
|
|
3265
|
+
}
|
|
3266
|
+
return lines;
|
|
3267
|
+
}
|
|
3268
|
+
function buildActivateRelease(paths) {
|
|
3269
|
+
return [
|
|
3270
|
+
`ln -sfn ${paths.release} ${paths.current}.tmp`,
|
|
3271
|
+
`mv -Tf ${paths.current}.tmp ${paths.current}`
|
|
3272
|
+
];
|
|
3273
|
+
}
|
|
3274
|
+
function buildRollbackScript(paths, options = {}) {
|
|
3275
|
+
if (options.to) {
|
|
3276
|
+
const target = `${paths.releases}/${options.to}`;
|
|
3277
|
+
return [
|
|
3278
|
+
`[ -d ${target} ] || { echo "rollback target ${target} not found" >&2; exit 1; }`,
|
|
3279
|
+
`ln -sfn ${target} ${paths.current}.tmp`,
|
|
3280
|
+
`mv -Tf ${paths.current}.tmp ${paths.current}`
|
|
3281
|
+
];
|
|
3282
|
+
}
|
|
3283
|
+
return [
|
|
3284
|
+
`TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
|
|
3285
|
+
`TS_CLOUD_PREV=$(ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | while read -r r; do ` + '[ "$(readlink -f "$r")" != "$TS_CLOUD_CURRENT" ] && { echo "$r"; break; }; done)',
|
|
3286
|
+
'[ -n "$TS_CLOUD_PREV" ] || { echo "no previous release to roll back to" >&2; exit 1; }',
|
|
3287
|
+
`ln -sfn "$TS_CLOUD_PREV" ${paths.current}.tmp`,
|
|
3288
|
+
`mv -Tf ${paths.current}.tmp ${paths.current}`,
|
|
3289
|
+
'echo "rolled back to $TS_CLOUD_PREV"'
|
|
3290
|
+
];
|
|
3291
|
+
}
|
|
3292
|
+
function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
|
|
3293
|
+
const n = Math.max(1, keep);
|
|
3294
|
+
return [
|
|
3295
|
+
`TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
|
|
3296
|
+
`ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | tail -n +${n + 1} | while read -r TS_CLOUD_OLD; do`,
|
|
3297
|
+
' [ "$(readlink -f "$TS_CLOUD_OLD")" = "$TS_CLOUD_CURRENT" ] || rm -rf "$TS_CLOUD_OLD"',
|
|
3298
|
+
"done"
|
|
3299
|
+
];
|
|
3300
|
+
}
|
|
3301
|
+
function buildDeployHistoryHeader(base, options) {
|
|
3302
|
+
const meta = deployMetaDir(base);
|
|
3303
|
+
const log = deployLogPath(base, options.releaseId);
|
|
3304
|
+
const history = deployHistoryPath(base);
|
|
3305
|
+
const keepLogs = Math.max(1, options.keepLogs ?? DEFAULT_KEEP_DEPLOY_LOGS);
|
|
3306
|
+
const commit = options.commit || "";
|
|
3307
|
+
const branch = options.branch || "";
|
|
3308
|
+
return [
|
|
3309
|
+
`mkdir -p ${meta}/deploys`,
|
|
3310
|
+
`exec > >(tee -a ${log}) 2>&1`,
|
|
3311
|
+
`echo "[ts-cloud] deploy ${options.releaseId} commit=${commit} branch=${branch} starting $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
|
|
3312
|
+
"ts_cloud_record_deploy() {",
|
|
3313
|
+
" TS_CLOUD_RC=$?",
|
|
3314
|
+
' if [ "$TS_CLOUD_RC" -eq 0 ]; then TS_CLOUD_ST=success; else TS_CLOUD_ST=failed; fi',
|
|
3315
|
+
` printf '%s\\t%s\\t%s\\t%s\\trc=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${options.releaseId}" "${commit}" "$TS_CLOUD_ST" "$TS_CLOUD_RC" >> ${history}`,
|
|
3316
|
+
"}",
|
|
3317
|
+
"trap ts_cloud_record_deploy EXIT",
|
|
3318
|
+
`ls -1t ${meta}/deploys/*.log 2>/dev/null | tail -n +${keepLogs + 1} | while read -r TS_CLOUD_OLDLOG; do rm -f "$TS_CLOUD_OLDLOG"; done`
|
|
3319
|
+
];
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
// src/drivers/shared/deploy-script.ts
|
|
3323
|
+
function resolveExecStart(start, runtime) {
|
|
3324
|
+
const bin = runtime === "bun" ? "/usr/local/bin/bun" : runtime === "deno" ? "/usr/local/bin/deno" : "/usr/local/bin/node";
|
|
3325
|
+
const args = start.replace(/^(bun|node|deno)\s+/, "");
|
|
3326
|
+
return `${bin} ${args}`;
|
|
3327
|
+
}
|
|
3328
|
+
function buildSiteDeployScript(options) {
|
|
3329
|
+
const {
|
|
3330
|
+
siteName,
|
|
3331
|
+
slug,
|
|
3332
|
+
artifactFetch,
|
|
3333
|
+
releaseId,
|
|
3334
|
+
execStart,
|
|
3335
|
+
envEntries,
|
|
3336
|
+
port,
|
|
3337
|
+
keepReleases = DEFAULT_KEEP_RELEASES,
|
|
3338
|
+
preStartCommands = []
|
|
3339
|
+
} = options;
|
|
3340
|
+
const base = options.appDir ?? `/var/www/${siteName}`;
|
|
3341
|
+
const paths = releasePaths(base, releaseId);
|
|
3342
|
+
const serviceName = `${slug}-${siteName}.service`;
|
|
3343
|
+
const sharedPaths = [".env"];
|
|
3344
|
+
const envFile = formatEnvFile(envEntries);
|
|
3345
|
+
const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
|
|
3346
|
+
return [
|
|
3347
|
+
"set -euo pipefail",
|
|
3348
|
+
...artifactFetch,
|
|
3349
|
+
...buildEnsureReleaseLayout(paths, sharedPaths),
|
|
3350
|
+
`rm -rf ${paths.release}`,
|
|
3351
|
+
`mkdir -p ${paths.release}`,
|
|
3352
|
+
`tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
|
|
3353
|
+
`cat > ${paths.shared}/.env <<'TS_CLOUD_ENV_EOF'`,
|
|
3354
|
+
envFile,
|
|
3355
|
+
"TS_CLOUD_ENV_EOF",
|
|
3356
|
+
`chmod 600 ${paths.shared}/.env`,
|
|
3357
|
+
...buildLinkSharedPaths(paths, sharedPaths),
|
|
3358
|
+
...preStart,
|
|
3359
|
+
`cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
|
|
3360
|
+
"[Unit]",
|
|
3361
|
+
`Description=${siteName} (managed by ts-cloud)`,
|
|
3362
|
+
"After=network.target",
|
|
3363
|
+
"",
|
|
3364
|
+
"[Service]",
|
|
3365
|
+
"Type=simple",
|
|
3366
|
+
`WorkingDirectory=${paths.current}`,
|
|
3367
|
+
`ExecStart=${execStart}`,
|
|
3368
|
+
"Restart=always",
|
|
3369
|
+
"RestartSec=5",
|
|
3370
|
+
`EnvironmentFile=${paths.current}/.env`,
|
|
3371
|
+
...port ? [`Environment=PORT=${port}`] : [],
|
|
3372
|
+
"",
|
|
3373
|
+
"[Install]",
|
|
3374
|
+
"WantedBy=multi-user.target",
|
|
3375
|
+
"TS_CLOUD_UNIT_EOF",
|
|
3376
|
+
"systemctl daemon-reload",
|
|
3377
|
+
`systemctl enable ${serviceName}`,
|
|
3378
|
+
...buildActivateRelease(paths),
|
|
3379
|
+
`systemctl restart ${serviceName}`,
|
|
3380
|
+
`systemctl is-active ${serviceName}`,
|
|
3381
|
+
...buildPruneReleases(paths, keepReleases)
|
|
3382
|
+
];
|
|
3383
|
+
}
|
|
3384
|
+
function buildStaticSiteDeployScript(options) {
|
|
3385
|
+
const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
|
|
3386
|
+
const base = options.appDir ?? `/var/www/${siteName}`;
|
|
3387
|
+
const paths = releasePaths(base, releaseId);
|
|
3388
|
+
const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
|
|
3389
|
+
return [
|
|
3390
|
+
"set -euo pipefail",
|
|
3391
|
+
...artifactFetch,
|
|
3392
|
+
...buildEnsureReleaseLayout(paths, []),
|
|
3393
|
+
`rm -rf ${paths.release}`,
|
|
3394
|
+
`mkdir -p ${paths.release}`,
|
|
3395
|
+
`tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
|
|
3396
|
+
...preStart,
|
|
3397
|
+
...buildActivateRelease(paths),
|
|
3398
|
+
...buildPruneReleases(paths, keepReleases)
|
|
3399
|
+
];
|
|
3400
|
+
}
|
|
3401
|
+
function buildAwsArtifactFetch(bucket, key, region, siteName) {
|
|
3402
|
+
return [
|
|
3403
|
+
`aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
|
|
3404
|
+
];
|
|
3405
|
+
}
|
|
3406
|
+
function buildLocalArtifactFetch(localPath, siteName) {
|
|
3407
|
+
return [
|
|
3408
|
+
`cp "${localPath}" /tmp/${siteName}-release.tar.gz`
|
|
3409
|
+
];
|
|
3410
|
+
}
|
|
3411
|
+
// src/deploy/management-dashboard.ts
|
|
3412
|
+
import { execSync as execSync2 } from "node:child_process";
|
|
3413
|
+
import { randomBytes } from "node:crypto";
|
|
3414
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
3415
|
+
import { tmpdir } from "node:os";
|
|
3416
|
+
import { dirname, isAbsolute, join as join4 } from "node:path";
|
|
3417
|
+
import { fileURLToPath } from "node:url";
|
|
3418
|
+
var MANAGEMENT_DASHBOARD_SITE = "dashboard";
|
|
3419
|
+
var DASHBOARD_CREDENTIALS_FILE = join4(".ts-cloud", "dashboard-credentials.json");
|
|
3420
|
+
function generatePassword() {
|
|
3421
|
+
return randomBytes(24).toString("base64url");
|
|
3422
|
+
}
|
|
3423
|
+
function resolveDashboardAuth(cwd, username, logger) {
|
|
3424
|
+
const explicit = process.env.TS_CLOUD_UI_PASSWORD?.trim();
|
|
3425
|
+
if (explicit)
|
|
3426
|
+
return { password: explicit, source: "env" };
|
|
3427
|
+
if (truthy(process.env.TS_CLOUD_UI_PUBLIC))
|
|
3428
|
+
return { password: undefined, source: "public" };
|
|
3429
|
+
const file = join4(cwd, DASHBOARD_CREDENTIALS_FILE);
|
|
3430
|
+
try {
|
|
3431
|
+
if (existsSync2(file)) {
|
|
3432
|
+
const saved = JSON.parse(readFileSync3(file, "utf8"));
|
|
3433
|
+
if (saved?.password)
|
|
3434
|
+
return { password: saved.password, source: "generated" };
|
|
3435
|
+
}
|
|
3436
|
+
} catch {}
|
|
3437
|
+
const password = generatePassword();
|
|
3438
|
+
try {
|
|
3439
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
3440
|
+
writeFileSync(file, `${JSON.stringify({ username, password, generatedAt: new Date().toISOString() }, null, 2)}
|
|
3441
|
+
`);
|
|
3442
|
+
chmodSync(file, 384);
|
|
3443
|
+
logger.info(`Management dashboard: generated a password and saved it to ${DASHBOARD_CREDENTIALS_FILE} (user: ${username}, pass: ${password}). Set TS_CLOUD_UI_PASSWORD to pin your own, or TS_CLOUD_UI_PUBLIC=1 to serve without auth.`);
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
logger.warn(`Management dashboard: could not persist the generated password (${error?.message ?? error}). Using it for this deploy only — pass: ${password}`);
|
|
3446
|
+
}
|
|
3447
|
+
return { password, source: "generated" };
|
|
3448
|
+
}
|
|
3449
|
+
var noopLogger = { info: () => {}, warn: () => {} };
|
|
3450
|
+
function truthy(v) {
|
|
3451
|
+
return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
3452
|
+
}
|
|
3453
|
+
function resolveUiSource(cwd) {
|
|
3454
|
+
if (existsSync2(join4(cwd, "packages", "ui", "pages")) || existsSync2(join4(cwd, "packages", "ui", "package.json"))) {
|
|
3455
|
+
return { uiRoot: "packages/ui/dist", build: "cd packages/ui && bun install && bun run build" };
|
|
3456
|
+
}
|
|
3457
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
3458
|
+
const candidates = [
|
|
3459
|
+
join4(here, "ui"),
|
|
3460
|
+
join4(here, "..", "ui"),
|
|
3461
|
+
join4(here, "..", "..", "ui"),
|
|
3462
|
+
join4(here, "..", "dist", "ui")
|
|
3463
|
+
];
|
|
3464
|
+
for (const dir of candidates) {
|
|
3465
|
+
if (existsSync2(join4(dir, "index.html")) || existsSync2(join4(dir, "serverless.html")))
|
|
3466
|
+
return { uiRoot: dir, build: false };
|
|
3467
|
+
}
|
|
3468
|
+
return null;
|
|
3469
|
+
}
|
|
3470
|
+
function ensureManagementDashboard(config, options = {}) {
|
|
3471
|
+
const logger = options.logger ?? noopLogger;
|
|
3472
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3473
|
+
if (truthy(process.env.TS_CLOUD_UI_DISABLE)) {
|
|
3474
|
+
logger.info("Management dashboard: skipped (TS_CLOUD_UI_DISABLE set).");
|
|
3475
|
+
return config;
|
|
3476
|
+
}
|
|
3477
|
+
if (hasManagementDashboardSite(config))
|
|
3478
|
+
return config;
|
|
3479
|
+
const ui = resolveUiSource(cwd);
|
|
3480
|
+
if (!ui) {
|
|
3481
|
+
logger.info("Management dashboard: UI not found (no local ui/ or packaged dist/ui) — skipping auto-deploy.");
|
|
3482
|
+
return config;
|
|
3483
|
+
}
|
|
3484
|
+
const username = process.env.TS_CLOUD_UI_USERNAME?.trim() || "admin";
|
|
3485
|
+
const auth = resolveDashboardAuth(cwd, username, logger);
|
|
3486
|
+
const environment = config.environments && Object.keys(config.environments)[0];
|
|
3487
|
+
const live = truthy(process.env.TS_CLOUD_UI_LIVE);
|
|
3488
|
+
const port = Number(process.env.TS_CLOUD_UI_PORT) || undefined;
|
|
3489
|
+
const resolved = resolveManagementDashboardSite(config, environment ?? "production", {
|
|
3490
|
+
uiRoot: ui.uiRoot,
|
|
3491
|
+
build: ui.build,
|
|
3492
|
+
domain: process.env.TS_CLOUD_UI_DOMAIN?.trim() || undefined,
|
|
3493
|
+
username,
|
|
3494
|
+
password: auth.password,
|
|
3495
|
+
realm: process.env.TS_CLOUD_UI_REALM?.trim() || undefined,
|
|
3496
|
+
live,
|
|
3497
|
+
port
|
|
3498
|
+
});
|
|
3499
|
+
if (!resolved) {
|
|
3500
|
+
logger.info("Management dashboard: no domain resolved (set TS_CLOUD_UI_DOMAIN or configure a site domain) — skipping.");
|
|
3501
|
+
return config;
|
|
3502
|
+
}
|
|
3503
|
+
config.sites = { ...config.sites ?? {}, [resolved.name]: resolved.site };
|
|
3504
|
+
const authNote = auth.source === "public" ? "NO AUTH — TS_CLOUD_UI_PUBLIC is set (dashboard is publicly reachable)" : auth.source === "env" ? "htpasswd-protected (TS_CLOUD_UI_PASSWORD)" : `htpasswd-protected (auto-generated — see ${DASHBOARD_CREDENTIALS_FILE})`;
|
|
3505
|
+
logger.info(`Management dashboard → https://${resolved.site.domain} (${authNote})`);
|
|
3506
|
+
return config;
|
|
3507
|
+
}
|
|
3508
|
+
function buildManagementDashboardArtifact(site, options) {
|
|
3509
|
+
if (!site?.root)
|
|
3510
|
+
return null;
|
|
3511
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3512
|
+
const logger = options.logger ?? noopLogger;
|
|
3513
|
+
try {
|
|
3514
|
+
if (typeof site.build === "string" && site.build.trim()) {
|
|
3515
|
+
logger.info(`Management dashboard: building UI (${site.build})`);
|
|
3516
|
+
execSync2(site.build, { cwd, stdio: "inherit" });
|
|
3517
|
+
}
|
|
3518
|
+
const root = isAbsolute(site.root) ? site.root : join4(cwd, site.root);
|
|
3519
|
+
if (!existsSync2(root)) {
|
|
3520
|
+
logger.warn(`Management dashboard: build output not found at ${root} — skipping dashboard artifact.`);
|
|
3521
|
+
return null;
|
|
3522
|
+
}
|
|
3523
|
+
const tarball = join4(tmpdir(), `${options.slug}-${MANAGEMENT_DASHBOARD_SITE}-${options.sha}.tar.gz`);
|
|
3524
|
+
execSync2(`tar czf "${tarball}" -C "${root}" .`, { stdio: "inherit" });
|
|
3525
|
+
return tarball;
|
|
3526
|
+
} catch (error) {
|
|
3527
|
+
logger.warn(`Management dashboard: failed to build artifact — ${error?.message ?? error}. Skipping dashboard deploy.`);
|
|
3528
|
+
return null;
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
// src/drivers/shared/certbot.ts
|
|
3533
|
+
function resolveSslProvider(site) {
|
|
3534
|
+
if (site.ssl?.provider)
|
|
3535
|
+
return site.ssl.provider;
|
|
3536
|
+
return site.domain ? "letsencrypt" : "none";
|
|
3537
|
+
}
|
|
3538
|
+
var DNS_PLUGINS = {
|
|
3539
|
+
cloudflare: { pkg: "python3-certbot-dns-cloudflare", plugin: "dns-cloudflare" },
|
|
3540
|
+
route53: { pkg: "python3-certbot-dns-route53", plugin: "dns-route53" },
|
|
3541
|
+
digitalocean: { pkg: "python3-certbot-dns-digitalocean", plugin: "dns-digitalocean" },
|
|
3542
|
+
google: { pkg: "python3-certbot-dns-google", plugin: "dns-google" }
|
|
3543
|
+
};
|
|
3544
|
+
function dnsCredentialsPath(provider) {
|
|
3545
|
+
return `/etc/letsencrypt/ts-cloud-${provider}.ini`;
|
|
3546
|
+
}
|
|
3547
|
+
function buildCertbotInstallScript(dns) {
|
|
3548
|
+
const plugin = dns ? DNS_PLUGINS[dns.provider] : undefined;
|
|
3549
|
+
const pkgs = ["certbot", "python3-certbot-nginx", ...plugin ? [plugin.pkg] : []].join(" ");
|
|
3550
|
+
const installLine = plugin ? `apt-get update -y && apt-get install -y ${pkgs}` : `command -v certbot >/dev/null 2>&1 || { apt-get update -y && apt-get install -y ${pkgs}; }`;
|
|
3551
|
+
return [
|
|
3552
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
3553
|
+
installLine,
|
|
3554
|
+
"mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
|
|
3555
|
+
"cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
|
|
3556
|
+
"#!/bin/sh",
|
|
3557
|
+
"systemctl reload nginx",
|
|
3558
|
+
"TS_CLOUD_HOOK_EOF",
|
|
3559
|
+
"chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh",
|
|
3560
|
+
"systemctl enable certbot.timer 2>/dev/null || true",
|
|
3561
|
+
"systemctl start certbot.timer 2>/dev/null || true"
|
|
3562
|
+
];
|
|
3563
|
+
}
|
|
3564
|
+
function buildDnsCredentialsScript(dns) {
|
|
3565
|
+
if (!dns.credentials || Object.keys(dns.credentials).length === 0)
|
|
3566
|
+
return [];
|
|
3567
|
+
const file = dnsCredentialsPath(dns.provider);
|
|
3568
|
+
const lines = Object.entries(dns.credentials).map(([k, v]) => `${k} = ${v}`).join(`
|
|
3569
|
+
`);
|
|
3570
|
+
return [
|
|
3571
|
+
`cat > ${file} <<'TS_CLOUD_DNSCREDS_EOF'`,
|
|
3572
|
+
lines,
|
|
3573
|
+
"TS_CLOUD_DNSCREDS_EOF",
|
|
3574
|
+
`chmod 600 ${file}`
|
|
3575
|
+
];
|
|
3576
|
+
}
|
|
3577
|
+
function buildCertbotIssueScript(options) {
|
|
3578
|
+
const dns = options.dns;
|
|
3579
|
+
const domains = options.wildcard ? [`*.${options.domain}`, options.domain] : [options.domain, ...options.aliases || []].filter(Boolean);
|
|
3580
|
+
const args = ["certbot", "--non-interactive", "--agree-tos", "--keep-until-expiring"];
|
|
3581
|
+
if (dns) {
|
|
3582
|
+
const plugin = DNS_PLUGINS[dns.provider].plugin;
|
|
3583
|
+
args.push(`--${plugin}`);
|
|
3584
|
+
if (dns.provider !== "route53" && dns.credentials)
|
|
3585
|
+
args.push(`--${plugin}-credentials ${dnsCredentialsPath(dns.provider)}`);
|
|
3586
|
+
if (typeof dns.propagationSeconds === "number" && dns.provider !== "route53")
|
|
3587
|
+
args.push(`--${plugin}-propagation-seconds ${dns.propagationSeconds}`);
|
|
3588
|
+
args.push("certonly");
|
|
3589
|
+
} else {
|
|
3590
|
+
args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
|
|
3591
|
+
}
|
|
3592
|
+
if (options.email)
|
|
3593
|
+
args.push(`-m ${options.email}`);
|
|
3594
|
+
else
|
|
3595
|
+
args.push("--register-unsafely-without-email");
|
|
3596
|
+
for (const d of domains)
|
|
3597
|
+
args.push(`-d ${d}`);
|
|
3598
|
+
return [args.join(" ")];
|
|
3599
|
+
}
|
|
3600
|
+
function buildSslScript(site) {
|
|
3601
|
+
if (resolveSslProvider(site) !== "letsencrypt" || !site.domain)
|
|
3602
|
+
return [];
|
|
3603
|
+
const ssl = site.ssl;
|
|
3604
|
+
const dns = ssl?.dns;
|
|
3605
|
+
const wildcard = ssl?.wildcard === true;
|
|
3606
|
+
if (wildcard && !dns)
|
|
3607
|
+
return [];
|
|
3608
|
+
return [
|
|
3609
|
+
...buildCertbotInstallScript(dns),
|
|
3610
|
+
...dns ? buildDnsCredentialsScript(dns) : [],
|
|
3611
|
+
...buildCertbotIssueScript({
|
|
3612
|
+
domain: site.domain,
|
|
3613
|
+
aliases: site.aliases,
|
|
3614
|
+
email: ssl?.email,
|
|
3615
|
+
wildcard,
|
|
3616
|
+
dns
|
|
3617
|
+
})
|
|
3618
|
+
];
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// src/drivers/shared/git-deploy.ts
|
|
3622
|
+
var DEFAULT_DEPLOY_BRANCH = "main";
|
|
3623
|
+
var DEFAULT_TAG_PATTERN = "v*";
|
|
3624
|
+
function buildGitCheckoutScript(options) {
|
|
3625
|
+
const { repository, releaseDir, commit } = options;
|
|
3626
|
+
const branch = repository.branch || DEFAULT_DEPLOY_BRANCH;
|
|
3627
|
+
const url = repository.url;
|
|
3628
|
+
const lines = [
|
|
3629
|
+
`rm -rf ${releaseDir}`,
|
|
3630
|
+
`mkdir -p ${releaseDir}`
|
|
3631
|
+
];
|
|
3632
|
+
if (repository.strategy === "tag") {
|
|
3633
|
+
if (repository.tag) {
|
|
3634
|
+
lines.push(`git clone -q --depth 1 --branch ${repository.tag} ${url} ${releaseDir}`);
|
|
3635
|
+
lines.push(`printf '%s' ${shellQuote(repository.tag)} > ${releaseDir}/.ts-cloud-tag`);
|
|
3636
|
+
} else {
|
|
3637
|
+
const pattern = repository.tagPattern || DEFAULT_TAG_PATTERN;
|
|
3638
|
+
lines.push(`TS_CLOUD_TAG="$(git ls-remote --tags --refs --sort=-v:refname ${url} ${shellQuote(`refs/tags/${pattern}`)} | head -n1 | sed 's#.*refs/tags/##')"`, `test -n "$TS_CLOUD_TAG" || { echo "no tags matching ${pattern} found in ${url}" >&2; exit 1; }`, `git clone -q --depth 1 --branch "$TS_CLOUD_TAG" ${url} ${releaseDir}`, `printf '%s' "$TS_CLOUD_TAG" > ${releaseDir}/.ts-cloud-tag`);
|
|
3639
|
+
}
|
|
3640
|
+
} else if (commit) {
|
|
3641
|
+
lines.push(`git -C ${releaseDir} init -q`, `git -C ${releaseDir} remote add origin ${url}`, `git -C ${releaseDir} fetch -q --depth 1 origin ${commit}`, `git -C ${releaseDir} checkout -q FETCH_HEAD`);
|
|
3642
|
+
} else {
|
|
3643
|
+
lines.push(`git clone -q --depth 1 --branch ${branch} ${url} ${releaseDir}`);
|
|
3644
|
+
}
|
|
3645
|
+
lines.push(`git -C ${releaseDir} rev-parse HEAD > ${releaseDir}/.ts-cloud-sha`);
|
|
3646
|
+
return lines;
|
|
3647
|
+
}
|
|
3648
|
+
function shellQuote(value) {
|
|
3649
|
+
const escaped = value.split("'").join("'\\''");
|
|
3650
|
+
return `'${escaped}'`;
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
// src/drivers/shared/laravel-deploy.ts
|
|
3654
|
+
var MACRO_CREATE_RELEASE = "$CREATE_RELEASE";
|
|
3655
|
+
var MACRO_ACTIVATE_RELEASE = "$ACTIVATE_RELEASE";
|
|
3656
|
+
var MACRO_RESTART_QUEUES = "$RESTART_QUEUES";
|
|
3657
|
+
var COMPOSER_INSTALL = "composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev";
|
|
3658
|
+
var LARAVEL_ARTISAN_STEPS = [
|
|
3659
|
+
"php artisan migrate --force",
|
|
3660
|
+
"php artisan config:cache",
|
|
3661
|
+
"php artisan route:cache",
|
|
3662
|
+
"php artisan view:cache",
|
|
3663
|
+
"php artisan event:cache",
|
|
3664
|
+
"php artisan storage:link"
|
|
3665
|
+
];
|
|
3666
|
+
function defaultDeployScriptFor(type) {
|
|
3667
|
+
switch (type) {
|
|
3668
|
+
case "laravel":
|
|
3669
|
+
case "statamic":
|
|
3670
|
+
return [
|
|
3671
|
+
MACRO_CREATE_RELEASE,
|
|
3672
|
+
COMPOSER_INSTALL,
|
|
3673
|
+
...LARAVEL_ARTISAN_STEPS,
|
|
3674
|
+
MACRO_ACTIVATE_RELEASE,
|
|
3675
|
+
MACRO_RESTART_QUEUES
|
|
3676
|
+
];
|
|
3677
|
+
case "php":
|
|
3678
|
+
case "wordpress":
|
|
3679
|
+
return [
|
|
3680
|
+
MACRO_CREATE_RELEASE,
|
|
3681
|
+
`${COMPOSER_INSTALL} || true`,
|
|
3682
|
+
MACRO_ACTIVATE_RELEASE
|
|
3683
|
+
];
|
|
3684
|
+
case "static":
|
|
3685
|
+
case "spa":
|
|
3686
|
+
return [
|
|
3687
|
+
MACRO_CREATE_RELEASE,
|
|
3688
|
+
MACRO_ACTIVATE_RELEASE
|
|
3689
|
+
];
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
function substituteBins(line, phpBin) {
|
|
3693
|
+
return line.replace(/^php\s+/, `${phpBin} `).replace(/(\s)php\s+artisan\s+/g, `$1${phpBin} artisan `);
|
|
3694
|
+
}
|
|
3695
|
+
function writeSharedEnv(sharedEnvPath, env) {
|
|
3696
|
+
const body = formatEnvFile(env);
|
|
3697
|
+
return [
|
|
3698
|
+
`cat > ${sharedEnvPath} <<'TS_CLOUD_ENV_EOF'`,
|
|
3699
|
+
body,
|
|
3700
|
+
"TS_CLOUD_ENV_EOF",
|
|
3701
|
+
`chmod 600 ${sharedEnvPath}`
|
|
3702
|
+
];
|
|
3703
|
+
}
|
|
3704
|
+
function writeFileHeredoc2(path, body, marker) {
|
|
3705
|
+
return [`cat > ${path} <<'${marker}'`, body.replace(/\n$/, ""), marker, `chmod 600 ${path}`];
|
|
3706
|
+
}
|
|
3707
|
+
function buildCredentialFiles(releaseDir, creds) {
|
|
3708
|
+
if (!creds)
|
|
3709
|
+
return [];
|
|
3710
|
+
const out = [];
|
|
3711
|
+
if (creds.composerAuth) {
|
|
3712
|
+
const json = typeof creds.composerAuth === "string" ? creds.composerAuth : JSON.stringify(creds.composerAuth, null, 2);
|
|
3713
|
+
out.push(...writeFileHeredoc2(`${releaseDir}/auth.json`, json, "TS_CLOUD_AUTHJSON_EOF"));
|
|
3714
|
+
}
|
|
3715
|
+
if (creds.npmrc)
|
|
3716
|
+
out.push(...writeFileHeredoc2(`${releaseDir}/.npmrc`, creds.npmrc, "TS_CLOUD_NPMRC_EOF"));
|
|
3717
|
+
return out;
|
|
3718
|
+
}
|
|
3719
|
+
function buildHealthCheckScript(site) {
|
|
3720
|
+
const path = site.healthCheck?.path;
|
|
3721
|
+
if (!path || !site.domain)
|
|
3722
|
+
return [];
|
|
3723
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
3724
|
+
const url = `http://127.0.0.1${p}`;
|
|
3725
|
+
return [
|
|
3726
|
+
`echo "[ts-cloud] health check ${url} (Host: ${site.domain})"`,
|
|
3727
|
+
"TS_CLOUD_HC_OK=0",
|
|
3728
|
+
`for i in $(seq 1 10); do TS_CLOUD_HC=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: ${site.domain}" -m 10 "${url}" 2>/dev/null || echo 000); case "$TS_CLOUD_HC" in 2*|3*) TS_CLOUD_HC_OK=1; break ;; esac; sleep 3; done`,
|
|
3729
|
+
'[ "$TS_CLOUD_HC_OK" = 1 ] && echo "health check passed ($TS_CLOUD_HC)" || { echo "health check FAILED (last=$TS_CLOUD_HC)" >&2; exit 1; }'
|
|
3730
|
+
];
|
|
3731
|
+
}
|
|
3732
|
+
function buildLaravelDeployScript(options) {
|
|
3733
|
+
const { siteName, site, releaseId, commit } = options;
|
|
3734
|
+
if (!site.repository?.url)
|
|
3735
|
+
throw new Error(`Site '${siteName}' is a PHP/git site but has no repository.url to clone`);
|
|
3736
|
+
const base = options.appBase ?? `/var/www/${siteName}`;
|
|
3737
|
+
const phpBin = "php";
|
|
3738
|
+
const paths = releasePaths(base, releaseId);
|
|
3739
|
+
const sharedPaths = site.sharedPaths ?? DEFAULT_SHARED_PATHS;
|
|
3740
|
+
const keepReleases = site.keepReleases ?? DEFAULT_KEEP_RELEASES;
|
|
3741
|
+
const template = site.deployScript?.length ? site.deployScript : defaultDeployScriptFor(site.type ?? "laravel");
|
|
3742
|
+
const out = [
|
|
3743
|
+
"set -euo pipefail",
|
|
3744
|
+
'export HOME="${HOME:-/root}"',
|
|
3745
|
+
'export COMPOSER_HOME="${COMPOSER_HOME:-/root/.composer}"',
|
|
3746
|
+
"export COMPOSER_ALLOW_SUPERUSER=1",
|
|
3747
|
+
pantryEnvActivation()
|
|
3748
|
+
];
|
|
3749
|
+
out.push(...buildDeployHistoryHeader(base, {
|
|
3750
|
+
releaseId,
|
|
3751
|
+
commit,
|
|
3752
|
+
branch: site.repository.branch,
|
|
3753
|
+
keepLogs: keepReleases
|
|
3754
|
+
}));
|
|
3755
|
+
out.push(...buildEnsureReleaseLayout(paths, sharedPaths));
|
|
3756
|
+
if (site.env && Object.keys(site.env).length > 0)
|
|
3757
|
+
out.push(...writeSharedEnv(`${paths.shared}/.env`, site.env));
|
|
3758
|
+
for (const raw of template) {
|
|
3759
|
+
const line = raw.trim();
|
|
3760
|
+
if (line === MACRO_CREATE_RELEASE) {
|
|
3761
|
+
out.push(...buildGitCheckoutScript({ repository: site.repository, releaseDir: paths.release, commit }));
|
|
3762
|
+
out.push(...buildLinkSharedPaths(paths, sharedPaths));
|
|
3763
|
+
out.push(`cd ${paths.release}`);
|
|
3764
|
+
out.push(...buildCredentialFiles(paths.release, site.credentials));
|
|
3765
|
+
out.push(`chown -R www-data:www-data ${paths.shared}/storage 2>/dev/null || true`, `[ -d ${paths.release}/bootstrap/cache ] && chown -R www-data:www-data ${paths.release}/bootstrap/cache 2>/dev/null || true`, `chmod -R ug+rwX ${paths.shared}/storage 2>/dev/null || true`);
|
|
3766
|
+
} else if (line === MACRO_ACTIVATE_RELEASE) {
|
|
3767
|
+
out.push(...buildActivateRelease(paths));
|
|
3768
|
+
out.push(...buildPruneReleases(paths, keepReleases));
|
|
3769
|
+
out.push(`(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`);
|
|
3770
|
+
} else if (line === MACRO_RESTART_QUEUES) {
|
|
3771
|
+
out.push(`${phpBin} artisan queue:restart || true`);
|
|
3772
|
+
} else {
|
|
3773
|
+
out.push(substituteBins(raw, phpBin));
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
return out;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
// src/drivers/shared/app-frameworks.ts
|
|
3780
|
+
var PHP_SITE_TYPES2 = new Set(["laravel", "php", "statamic", "wordpress"]);
|
|
3781
|
+
function resolveSiteFramework(site) {
|
|
3782
|
+
if (site.framework)
|
|
3783
|
+
return site.framework;
|
|
3784
|
+
if (site.type && PHP_SITE_TYPES2.has(site.type))
|
|
3785
|
+
return "laravel";
|
|
3786
|
+
return "stacks";
|
|
3787
|
+
}
|
|
3788
|
+
var PANTRY_ENV_EVAL = `eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/null)"`;
|
|
3789
|
+
var BUN_BIN = "/usr/local/bin/bun";
|
|
3790
|
+
var STACKS_CLI = "storage/framework/core/buddy/src/cli.ts";
|
|
3791
|
+
var stacksDriver = {
|
|
3792
|
+
id: "stacks",
|
|
3793
|
+
wrapExec: (command) => command,
|
|
3794
|
+
execEnv: { PATH: "/usr/local/bin:/usr/bin:/bin", BUN_INSTALL: "/root/.bun" },
|
|
3795
|
+
schedulerMode: "daemon",
|
|
3796
|
+
schedulerCommand: () => `${BUN_BIN} ${STACKS_CLI} schedule:run`,
|
|
3797
|
+
queueWorkerCommand: (worker, { current }) => {
|
|
3798
|
+
const flags = [
|
|
3799
|
+
`--queue=${worker.queue || "default"}`,
|
|
3800
|
+
`--sleep=${worker.sleep ?? 3}`,
|
|
3801
|
+
`--tries=${worker.tries ?? 3}`,
|
|
3802
|
+
`--timeout=${worker.timeout ?? 60}`
|
|
3803
|
+
];
|
|
3804
|
+
return `${BUN_BIN} ${current}/${STACKS_CLI} queue:work ${flags.join(" ")}`;
|
|
3805
|
+
}
|
|
3806
|
+
};
|
|
3807
|
+
var laravelDriver = {
|
|
3808
|
+
id: "laravel",
|
|
3809
|
+
wrapExec: (command) => `/bin/sh -lc '${PANTRY_ENV_EVAL}; exec ${command}'`,
|
|
3810
|
+
schedulerMode: "cron",
|
|
3811
|
+
schedulerCommand: ({ current }) => `cd ${current} && ${PANTRY_ENV_EVAL} && php artisan schedule:run`,
|
|
3812
|
+
queueWorkerCommand: (worker, { current }) => {
|
|
3813
|
+
const artisan = `${current}/artisan`;
|
|
3814
|
+
if (worker.horizon)
|
|
3815
|
+
return `php ${artisan} horizon`;
|
|
3816
|
+
const flags = [
|
|
3817
|
+
worker.connection || "default",
|
|
3818
|
+
`--queue=${worker.queue || "default"}`,
|
|
3819
|
+
`--sleep=${worker.sleep ?? 3}`,
|
|
3820
|
+
`--tries=${worker.tries ?? 3}`,
|
|
3821
|
+
`--timeout=${worker.timeout ?? 60}`,
|
|
3822
|
+
`--memory=${worker.memory ?? 128}`
|
|
3823
|
+
];
|
|
3824
|
+
if (worker.maxJobs)
|
|
3825
|
+
flags.push(`--max-jobs=${worker.maxJobs}`);
|
|
3826
|
+
if (worker.maxTime)
|
|
3827
|
+
flags.push(`--max-time=${worker.maxTime}`);
|
|
3828
|
+
return `php ${artisan} queue:work ${flags.join(" ")}`;
|
|
3829
|
+
}
|
|
3830
|
+
};
|
|
3831
|
+
var DRIVERS = {
|
|
3832
|
+
stacks: stacksDriver,
|
|
3833
|
+
laravel: laravelDriver
|
|
3834
|
+
};
|
|
3835
|
+
function getAppFrameworkDriver(framework) {
|
|
3836
|
+
return DRIVERS[framework] ?? stacksDriver;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
// src/drivers/shared/app-services.ts
|
|
3840
|
+
function reEscape(value) {
|
|
3841
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3842
|
+
}
|
|
3843
|
+
function cronQuote(value) {
|
|
3844
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
3845
|
+
}
|
|
3846
|
+
function slugify(value) {
|
|
3847
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
3848
|
+
}
|
|
3849
|
+
function queueUnitName(slug, siteName, index) {
|
|
3850
|
+
return `${slug}-${siteName}-queue-${index}`;
|
|
3851
|
+
}
|
|
3852
|
+
function daemonUnitName(slug, siteName, daemon, index) {
|
|
3853
|
+
return `${slug}-${siteName}-daemon-${daemon.name ? slugify(daemon.name) : slugify(daemon.command).slice(0, 32) || String(index)}`;
|
|
3854
|
+
}
|
|
3855
|
+
function systemdUnit(opts) {
|
|
3856
|
+
const lines = [
|
|
3857
|
+
"[Unit]",
|
|
3858
|
+
`Description=${opts.description}`,
|
|
3859
|
+
"After=network.target",
|
|
3860
|
+
"",
|
|
3861
|
+
"[Service]",
|
|
3862
|
+
"Type=simple",
|
|
3863
|
+
`WorkingDirectory=${opts.workingDir}`,
|
|
3864
|
+
...Object.entries(opts.environment ?? {}).map(([k, v]) => `Environment="${k}=${v}"`),
|
|
3865
|
+
`ExecStart=${opts.execStart}`,
|
|
3866
|
+
`Restart=${opts.restart || "always"}`,
|
|
3867
|
+
"RestartSec=5"
|
|
3868
|
+
];
|
|
3869
|
+
if (opts.user)
|
|
3870
|
+
lines.push(`User=${opts.user}`);
|
|
3871
|
+
if (typeof opts.stopWaitSecs === "number")
|
|
3872
|
+
lines.push(`TimeoutStopSec=${opts.stopWaitSecs}`);
|
|
3873
|
+
lines.push("", "[Install]", "WantedBy=multi-user.target");
|
|
3874
|
+
return `${lines.join(`
|
|
3875
|
+
`)}
|
|
3876
|
+
`;
|
|
3877
|
+
}
|
|
3878
|
+
function writeUnitScript(name, body) {
|
|
3879
|
+
return [
|
|
3880
|
+
`cat > /etc/systemd/system/${name}.service <<'TS_CLOUD_UNIT_EOF'`,
|
|
3881
|
+
body.replace(/\n$/, ""),
|
|
3882
|
+
"TS_CLOUD_UNIT_EOF"
|
|
3883
|
+
];
|
|
3884
|
+
}
|
|
3885
|
+
function schedulerCronPath(slug, siteName) {
|
|
3886
|
+
return `/etc/cron.d/${slug}-${siteName}-scheduler`;
|
|
3887
|
+
}
|
|
3888
|
+
function buildSiteServicesScript(options) {
|
|
3889
|
+
const { slug, siteName, site } = options;
|
|
3890
|
+
const base = options.appBase ?? `/var/www/${siteName}`;
|
|
3891
|
+
const current = `${base}/current`;
|
|
3892
|
+
const driver = getAppFrameworkDriver(resolveSiteFramework(site));
|
|
3893
|
+
const ctx = { current };
|
|
3894
|
+
const out = [];
|
|
3895
|
+
const desiredUnits = [];
|
|
3896
|
+
const queues = site.queues || [];
|
|
3897
|
+
queues.forEach((worker, qIndex) => {
|
|
3898
|
+
const processes = Math.max(1, worker.processes ?? 1);
|
|
3899
|
+
for (let p = 0;p < processes; p++) {
|
|
3900
|
+
const name = queueUnitName(slug, siteName, desiredUnits.length);
|
|
3901
|
+
desiredUnits.push(name);
|
|
3902
|
+
out.push(...writeUnitScript(name, systemdUnit({
|
|
3903
|
+
description: `${siteName} queue worker ${qIndex}.${p} (managed by ts-cloud)`,
|
|
3904
|
+
workingDir: current,
|
|
3905
|
+
execStart: driver.wrapExec(driver.queueWorkerCommand(worker, ctx)),
|
|
3906
|
+
environment: driver.execEnv,
|
|
3907
|
+
stopWaitSecs: worker.stopWaitSecs ?? 90
|
|
3908
|
+
})));
|
|
3909
|
+
}
|
|
3910
|
+
});
|
|
3911
|
+
const daemons = site.daemons || [];
|
|
3912
|
+
daemons.forEach((daemon, dIndex) => {
|
|
3913
|
+
const processes = Math.max(1, daemon.processes ?? 1);
|
|
3914
|
+
for (let p = 0;p < processes; p++) {
|
|
3915
|
+
const name = `${daemonUnitName(slug, siteName, daemon, dIndex)}-${p}`;
|
|
3916
|
+
desiredUnits.push(name);
|
|
3917
|
+
out.push(...writeUnitScript(name, systemdUnit({
|
|
3918
|
+
description: `${siteName} daemon ${daemon.name || daemon.command} (managed by ts-cloud)`,
|
|
3919
|
+
workingDir: daemon.directory || current,
|
|
3920
|
+
execStart: driver.wrapExec(daemon.command),
|
|
3921
|
+
environment: driver.execEnv,
|
|
3922
|
+
restart: daemon.restart,
|
|
3923
|
+
user: daemon.user
|
|
3924
|
+
})));
|
|
3925
|
+
}
|
|
3926
|
+
});
|
|
3927
|
+
const scheduler = site.scheduler;
|
|
3928
|
+
const schedulerEnabled = scheduler === true || typeof scheduler === "object" && scheduler !== null;
|
|
3929
|
+
const schedulerUnit = `${slug}-${siteName}-scheduler`;
|
|
3930
|
+
if (schedulerEnabled && driver.schedulerMode === "daemon") {
|
|
3931
|
+
desiredUnits.push(schedulerUnit);
|
|
3932
|
+
out.push(...writeUnitScript(schedulerUnit, systemdUnit({
|
|
3933
|
+
description: `${siteName} scheduler (managed by ts-cloud)`,
|
|
3934
|
+
workingDir: current,
|
|
3935
|
+
execStart: driver.wrapExec(driver.schedulerCommand(ctx)),
|
|
3936
|
+
environment: driver.execEnv
|
|
3937
|
+
})));
|
|
3938
|
+
}
|
|
3939
|
+
const desiredList = desiredUnits.map((n) => `${n}.service`).join(" ");
|
|
3940
|
+
out.push("systemctl daemon-reload", `TS_CLOUD_DESIRED="${desiredList}"`, `for unit in $(ls /etc/systemd/system/ 2>/dev/null | grep -E '^${reEscape(slug)}-${reEscape(siteName)}-((queue|daemon)-.*|scheduler)\\.service$' || true); do`, ' case " $TS_CLOUD_DESIRED " in', ' *" $unit "*) ;;', ' *) systemctl stop "$unit" 2>/dev/null || true; systemctl disable "$unit" 2>/dev/null || true; rm -f "/etc/systemd/system/$unit" ;;', " esac", "done", "systemctl daemon-reload");
|
|
3941
|
+
for (const name of desiredUnits) {
|
|
3942
|
+
out.push(`systemctl enable ${name}.service`, `systemctl restart ${name}.service`);
|
|
3943
|
+
}
|
|
3944
|
+
const cronPath = schedulerCronPath(slug, siteName);
|
|
3945
|
+
if (schedulerEnabled && driver.schedulerMode === "cron") {
|
|
3946
|
+
const heartbeat = typeof scheduler === "object" && scheduler !== null ? scheduler : undefined;
|
|
3947
|
+
let command = `${driver.schedulerCommand(ctx)} >> /dev/null 2>&1`;
|
|
3948
|
+
if (heartbeat?.heartbeatUrl) {
|
|
3949
|
+
const method = heartbeat.heartbeatMethod || "GET";
|
|
3950
|
+
const methodFlag = method === "GET" ? "" : `-X ${method} `;
|
|
3951
|
+
command += ` && curl -fsS -m 10 ${methodFlag}${cronQuote(heartbeat.heartbeatUrl)} >/dev/null 2>&1`;
|
|
3952
|
+
}
|
|
3953
|
+
const cron = `* * * * * root ${command}
|
|
3954
|
+
`.replace(/%/g, "\\%");
|
|
3955
|
+
out.push(`cat > ${cronPath} <<'TS_CLOUD_CRON_EOF'`, cron.replace(/\n$/, ""), "TS_CLOUD_CRON_EOF", `chmod 644 ${cronPath}`);
|
|
3956
|
+
} else {
|
|
3957
|
+
out.push(`rm -f ${cronPath}`);
|
|
3958
|
+
}
|
|
3959
|
+
return out;
|
|
3960
|
+
}
|
|
3961
|
+
function siteHasServices(site) {
|
|
3962
|
+
return !!(site.queues?.length || site.daemons?.length || site.scheduler);
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
// src/drivers/shared/php-fpm-pool.ts
|
|
3966
|
+
var PHP_FPM_POOL_DIR = "/var/lib/pantry/php-fpm/pool.d";
|
|
3967
|
+
var POOL_PORT_BASE = 9100;
|
|
3968
|
+
var POOL_PORT_SPAN = 400;
|
|
3969
|
+
function siteToken(siteName) {
|
|
3970
|
+
const base = siteName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
3971
|
+
return (base || "site").slice(0, 28);
|
|
3972
|
+
}
|
|
3973
|
+
function siteUser(siteName) {
|
|
3974
|
+
return `web_${siteToken(siteName)}`;
|
|
3975
|
+
}
|
|
3976
|
+
function phpFpmPoolPort(siteName) {
|
|
3977
|
+
const token = siteToken(siteName);
|
|
3978
|
+
let h = 0;
|
|
3979
|
+
for (let i = 0;i < token.length; i++)
|
|
3980
|
+
h = h * 31 + token.charCodeAt(i) >>> 0;
|
|
3981
|
+
return POOL_PORT_BASE + h % POOL_PORT_SPAN;
|
|
3982
|
+
}
|
|
3983
|
+
function phpFpmPoolListen(siteName) {
|
|
3984
|
+
return `127.0.0.1:${phpFpmPoolPort(siteName)}`;
|
|
3985
|
+
}
|
|
3986
|
+
function buildPhpFpmPoolConf(options) {
|
|
3987
|
+
const pool = siteToken(options.siteName);
|
|
3988
|
+
const user = siteUser(options.siteName);
|
|
3989
|
+
const maxChildren = options.maxChildren ?? 10;
|
|
3990
|
+
return [
|
|
3991
|
+
`; ts-cloud-managed pool for ${options.siteName} (site isolation)`,
|
|
3992
|
+
`[${pool}]`,
|
|
3993
|
+
`user = ${user}`,
|
|
3994
|
+
`group = ${user}`,
|
|
3995
|
+
`listen = ${phpFpmPoolListen(options.siteName)}`,
|
|
3996
|
+
"pm = dynamic",
|
|
3997
|
+
`pm.max_children = ${maxChildren}`,
|
|
3998
|
+
"pm.start_servers = 2",
|
|
3999
|
+
"pm.min_spare_servers = 1",
|
|
4000
|
+
"pm.max_spare_servers = 3",
|
|
4001
|
+
"pm.max_requests = 500",
|
|
4002
|
+
"catch_workers_output = yes",
|
|
4003
|
+
`php_admin_value[open_basedir] = ${options.appBase}:/tmp`,
|
|
4004
|
+
""
|
|
4005
|
+
].join(`
|
|
4006
|
+
`);
|
|
4007
|
+
}
|
|
4008
|
+
function buildPhpFpmPoolScript(options) {
|
|
4009
|
+
const user = siteUser(options.siteName);
|
|
4010
|
+
const pool = siteToken(options.siteName);
|
|
4011
|
+
const conf = `${PHP_FPM_POOL_DIR}/${pool}.conf`;
|
|
4012
|
+
return [
|
|
4013
|
+
`getent group ${user} >/dev/null || groupadd --system ${user}`,
|
|
4014
|
+
`id -u ${user} >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin -g ${user} ${user}`,
|
|
4015
|
+
`usermod -aG ${user} www-data 2>/dev/null || true`,
|
|
4016
|
+
`chown -R ${user}:${user} ${options.appBase} || true`,
|
|
4017
|
+
`chmod -R g+rX ${options.appBase} || true`,
|
|
4018
|
+
`chmod 600 ${options.appBase}/shared/.env 2>/dev/null || true`,
|
|
4019
|
+
`mkdir -p ${PHP_FPM_POOL_DIR}`,
|
|
4020
|
+
`cat > ${conf} <<'TS_CLOUD_FPMPOOL_EOF'`,
|
|
4021
|
+
buildPhpFpmPoolConf(options).replace(/\n$/, ""),
|
|
4022
|
+
"TS_CLOUD_FPMPOOL_EOF",
|
|
4023
|
+
`(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`
|
|
4024
|
+
];
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
// src/drivers/shared/compute-deploy.ts
|
|
4028
|
+
var noopLogger2 = {
|
|
4029
|
+
info: () => {},
|
|
4030
|
+
warn: () => {},
|
|
4031
|
+
error: () => {},
|
|
4032
|
+
step: () => {},
|
|
4033
|
+
success: () => {}
|
|
4034
|
+
};
|
|
4035
|
+
async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
4036
|
+
const {
|
|
4037
|
+
config,
|
|
4038
|
+
environment,
|
|
4039
|
+
siteName,
|
|
4040
|
+
site,
|
|
4041
|
+
slug,
|
|
4042
|
+
sha,
|
|
4043
|
+
runtime,
|
|
4044
|
+
localTarballPath
|
|
4045
|
+
} = options;
|
|
4046
|
+
const remoteKey = `releases/${siteName}/${sha}.tar.gz`;
|
|
4047
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
4048
|
+
const outputs = await driver.getComputeOutputs({ config, environment });
|
|
4049
|
+
const targets = await driver.findComputeTargets({
|
|
4050
|
+
slug,
|
|
4051
|
+
environment,
|
|
4052
|
+
role: "app",
|
|
4053
|
+
stackName
|
|
4054
|
+
});
|
|
4055
|
+
if (targets.length === 0) {
|
|
4056
|
+
const hint = driver.name === "aws" ? `Stack '${stackName}' has no EC2 instances tagged Project=${slug} Environment=${environment} Role=app, and .ts-cloud/state/${stackName}.json pins no live instance. For a shared box, record its instanceId there.` : `No Hetzner servers labeled ts-cloud/project=${slug} ts-cloud/environment=${environment} ts-cloud/role=app, and .ts-cloud/state/${stackName}.json pins no live server. For a shared box, record its serverId there.`;
|
|
4057
|
+
return { success: false, error: hint };
|
|
4058
|
+
}
|
|
4059
|
+
if (isPhpSite(site)) {
|
|
4060
|
+
const compute2 = config.infrastructure?.compute;
|
|
4061
|
+
const phpVersion = site.phpVersion ?? compute2?.php?.default ?? compute2?.php?.versions?.[0];
|
|
4062
|
+
const appBase = `/var/www/${siteName}`;
|
|
4063
|
+
const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, config.infrastructure?.appDatabase) : buildManagedDbEnv(config.infrastructure?.appDatabase);
|
|
4064
|
+
const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: { ...dbEnv, ...site.env || {} } } : site;
|
|
4065
|
+
const deployScript = buildLaravelDeployScript({
|
|
4066
|
+
siteName,
|
|
4067
|
+
site: siteWithEnv,
|
|
4068
|
+
releaseId: sha,
|
|
4069
|
+
appBase,
|
|
4070
|
+
defaultPhpVersion: phpVersion
|
|
4071
|
+
});
|
|
4072
|
+
const useNginx = compute2?.webServer !== "rpx";
|
|
4073
|
+
const sslProvider = resolveSslProvider(site);
|
|
4074
|
+
const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
|
|
4075
|
+
const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase }) : [];
|
|
4076
|
+
const vhostScript = useNginx ? buildNginxVhostScript({
|
|
4077
|
+
siteName,
|
|
4078
|
+
domain: site.domain || siteName,
|
|
4079
|
+
aliases: site.aliases,
|
|
4080
|
+
type: site.type,
|
|
4081
|
+
appDir: `${appBase}/current`,
|
|
4082
|
+
webDirectory: site.webDirectory,
|
|
4083
|
+
phpVersion,
|
|
4084
|
+
fastcgiPass: site.isolation ? phpFpmPoolListen(siteName) : undefined,
|
|
4085
|
+
redirects: site.redirects,
|
|
4086
|
+
ssl: customCert,
|
|
4087
|
+
auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
|
|
4088
|
+
serverSnippet: resolveNginxSnippet(site.nginx, compute2?.nginxTemplates),
|
|
4089
|
+
clientMaxBodySize: site.nginx?.clientMaxBodySize,
|
|
4090
|
+
hsts: site.ssl?.hsts,
|
|
4091
|
+
tlsProtocols: site.ssl?.tlsProtocols,
|
|
4092
|
+
security: site.security
|
|
4093
|
+
}) : [];
|
|
4094
|
+
const sslScript = useNginx ? buildSslScript(site) : [];
|
|
4095
|
+
const servicesScript2 = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, phpVersion, appBase }) : [];
|
|
4096
|
+
const healthCheckScript = useNginx ? buildHealthCheckScript(site) : [];
|
|
4097
|
+
logger.step(`Deploying PHP site '${siteName}' to ${targets.length} target(s)...`);
|
|
4098
|
+
const phpResult = await driver.runRemoteDeploy({
|
|
4099
|
+
targets,
|
|
4100
|
+
commands: [...deployScript, ...poolScript, ...vhostScript, ...sslScript, ...servicesScript2, ...healthCheckScript],
|
|
4101
|
+
comment: `ts-cloud deploy ${slug}/${siteName}@${sha}`,
|
|
4102
|
+
tags: { Project: slug, Environment: environment, Role: "app" }
|
|
4103
|
+
});
|
|
4104
|
+
const notifications = resolveNotifications(config.notifications, site.notifications);
|
|
4105
|
+
if (!phpResult.success) {
|
|
4106
|
+
await sendNotifications(notifications, "deploy-failed", `❌ Deploy of ${slug}/${siteName}@${sha} failed: ${phpResult.error || "unknown error"}`);
|
|
4107
|
+
return {
|
|
4108
|
+
success: false,
|
|
4109
|
+
error: phpResult.error || "Remote PHP deploy failed",
|
|
4110
|
+
instanceCount: phpResult.instanceCount,
|
|
4111
|
+
perInstance: phpResult.perInstance
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
const deployedUrl = site.domain ? ` → https://${site.domain}` : "";
|
|
4115
|
+
await sendNotifications(notifications, "deploy", `✅ Deployed ${slug}/${siteName}@${sha}${deployedUrl}`);
|
|
4116
|
+
return {
|
|
4117
|
+
success: true,
|
|
4118
|
+
instanceCount: phpResult.instanceCount,
|
|
4119
|
+
perInstance: phpResult.perInstance
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
if (!localTarballPath)
|
|
4123
|
+
return { success: false, error: `Site '${siteName}' requires a release tarball but none was provided` };
|
|
4124
|
+
const uploadResult = await driver.uploadRelease({
|
|
4125
|
+
config,
|
|
4126
|
+
environment,
|
|
4127
|
+
localPath: localTarballPath,
|
|
4128
|
+
remoteKey,
|
|
4129
|
+
targets
|
|
4130
|
+
});
|
|
4131
|
+
const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
|
|
4132
|
+
const kind = resolveSiteKind(site);
|
|
4133
|
+
const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
|
|
4134
|
+
siteName,
|
|
4135
|
+
artifactFetch,
|
|
4136
|
+
releaseId: sha,
|
|
4137
|
+
preStartCommands: site.preStart
|
|
4138
|
+
}) : buildSiteDeployScript({
|
|
4139
|
+
siteName,
|
|
4140
|
+
slug,
|
|
4141
|
+
artifactFetch,
|
|
4142
|
+
releaseId: sha,
|
|
4143
|
+
execStart: resolveExecStart(site.start, runtime),
|
|
4144
|
+
envEntries: site.env || {},
|
|
4145
|
+
port: site.port,
|
|
4146
|
+
preStartCommands: site.preStart
|
|
4147
|
+
});
|
|
4148
|
+
const compute = config.infrastructure?.compute;
|
|
4149
|
+
const wantsNginxStatic = kind === "server-static" && compute?.webServer !== "rpx" && !!site.domain;
|
|
4150
|
+
const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
|
|
4151
|
+
siteName,
|
|
4152
|
+
domain: site.domain,
|
|
4153
|
+
aliases: site.aliases,
|
|
4154
|
+
type: site.type === "spa" ? "spa" : "static",
|
|
4155
|
+
appDir: `/var/www/${siteName}/current`,
|
|
4156
|
+
webDirectory: "",
|
|
4157
|
+
redirects: site.redirects,
|
|
4158
|
+
auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
|
|
4159
|
+
serverSnippet: resolveNginxSnippet(site.nginx, compute?.nginxTemplates),
|
|
4160
|
+
clientMaxBodySize: site.nginx?.clientMaxBodySize,
|
|
4161
|
+
hsts: site.ssl?.hsts,
|
|
4162
|
+
tlsProtocols: site.ssl?.tlsProtocols,
|
|
4163
|
+
security: site.security
|
|
4164
|
+
}) : [];
|
|
4165
|
+
const staticSsl = wantsNginxStatic ? buildSslScript(site) : [];
|
|
4166
|
+
const servicesScript = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, appBase: `/var/www/${siteName}` }) : [];
|
|
4167
|
+
const remoteScript = [
|
|
4168
|
+
...buildDeployHistoryHeader(`/var/www/${siteName}`, {
|
|
4169
|
+
releaseId: sha,
|
|
4170
|
+
commit: sha,
|
|
4171
|
+
branch: site.branch ?? "main"
|
|
4172
|
+
}),
|
|
4173
|
+
...baseScript,
|
|
4174
|
+
...staticVhost,
|
|
4175
|
+
...staticSsl,
|
|
4176
|
+
...servicesScript
|
|
4177
|
+
];
|
|
4178
|
+
logger.step(`Deploying to ${targets.length} target(s)...`);
|
|
4179
|
+
const result = await driver.runRemoteDeploy({
|
|
4180
|
+
targets,
|
|
4181
|
+
commands: remoteScript,
|
|
4182
|
+
comment: `ts-cloud deploy ${slug}/${siteName}@${sha}`,
|
|
4183
|
+
tags: {
|
|
4184
|
+
Project: slug,
|
|
4185
|
+
Environment: environment,
|
|
4186
|
+
Role: "app"
|
|
4187
|
+
}
|
|
4188
|
+
});
|
|
4189
|
+
if (!result.success) {
|
|
4190
|
+
return {
|
|
4191
|
+
success: false,
|
|
4192
|
+
error: result.error || "Remote deploy failed",
|
|
4193
|
+
instanceCount: result.instanceCount,
|
|
4194
|
+
perInstance: result.perInstance
|
|
4195
|
+
};
|
|
4196
|
+
}
|
|
4197
|
+
return {
|
|
4198
|
+
success: true,
|
|
4199
|
+
instanceCount: result.instanceCount,
|
|
4200
|
+
perInstance: result.perInstance
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
async function deployAllComputeSites(options) {
|
|
4204
|
+
const { config, environment, driver, sha, runtime, tarballForSite, logger = noopLogger2 } = options;
|
|
4205
|
+
const slug = config.project.slug;
|
|
4206
|
+
const cwd = options.cwd ?? process.cwd();
|
|
4207
|
+
const hadDashboard = hasManagementDashboardSite(config);
|
|
4208
|
+
ensureManagementDashboard(config, { cwd, logger: { info: logger.info, warn: logger.warn } });
|
|
4209
|
+
const injectedDashboard = !hadDashboard && hasManagementDashboardSite(config);
|
|
4210
|
+
if (options.rpxConfig && options.rpxConfig !== config && !hasManagementDashboardSite(options.rpxConfig))
|
|
4211
|
+
ensureManagementDashboard(options.rpxConfig, { cwd, logger: { info: () => {}, warn: () => {} } });
|
|
4212
|
+
let dashboardTarball = null;
|
|
4213
|
+
if (injectedDashboard) {
|
|
4214
|
+
dashboardTarball = buildManagementDashboardArtifact(config.sites?.[MANAGEMENT_DASHBOARD_SITE], { cwd, slug, sha, logger: { info: logger.info, warn: logger.warn } });
|
|
4215
|
+
if (!dashboardTarball) {
|
|
4216
|
+
logger.warn("Management dashboard: no artifact available — skipping dashboard site for this deploy.");
|
|
4217
|
+
if (config.sites)
|
|
4218
|
+
delete config.sites[MANAGEMENT_DASHBOARD_SITE];
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
const sites = config.sites || {};
|
|
4222
|
+
const deployable = Object.entries(sites).filter(([name, site]) => {
|
|
4223
|
+
if (!site)
|
|
4224
|
+
return false;
|
|
4225
|
+
const kind = resolveSiteKind(site);
|
|
4226
|
+
if (kind === "bucket") {
|
|
4227
|
+
logger.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
|
|
4228
|
+
return false;
|
|
4229
|
+
}
|
|
4230
|
+
if (kind === "redirect")
|
|
4231
|
+
return false;
|
|
4232
|
+
return true;
|
|
4233
|
+
});
|
|
4234
|
+
if (deployable.length === 0)
|
|
4235
|
+
return true;
|
|
4236
|
+
const tarballFor = (siteName) => siteName === MANAGEMENT_DASHBOARD_SITE && dashboardTarball ? dashboardTarball : tarballForSite(siteName);
|
|
4237
|
+
for (const [siteName, site] of deployable) {
|
|
4238
|
+
logger.step(`Deploying site: ${siteName}`);
|
|
4239
|
+
const localTarballPath = isPhpSite(site) ? undefined : tarballFor(siteName);
|
|
4240
|
+
const result = await deploySiteRelease(driver, {
|
|
4241
|
+
config,
|
|
4242
|
+
environment,
|
|
4243
|
+
siteName,
|
|
4244
|
+
site,
|
|
4245
|
+
slug,
|
|
4246
|
+
sha,
|
|
4247
|
+
runtime,
|
|
4248
|
+
localTarballPath
|
|
4249
|
+
}, logger);
|
|
4250
|
+
if (!result.success) {
|
|
4251
|
+
logger.error(`Deploy of '${siteName}' failed: ${result.error || "unknown error"}`);
|
|
4252
|
+
if (result.perInstance) {
|
|
4253
|
+
for (const inst of result.perInstance) {
|
|
4254
|
+
logger.error(` ${inst.instanceId}: ${inst.status}${inst.error ? ` — ${inst.error}` : ""}`);
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
return false;
|
|
4258
|
+
}
|
|
4259
|
+
logger.success(`Deployed ${slug}/${siteName}@${sha} to ${result.instanceCount} target(s)`);
|
|
4260
|
+
if (result.perInstance) {
|
|
4261
|
+
for (const inst of result.perInstance) {
|
|
4262
|
+
logger.info(` ✓ ${inst.instanceId}: ${inst.status}`);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
const reloaded = await reloadRpxGateway(options);
|
|
4267
|
+
if (!reloaded)
|
|
4268
|
+
return false;
|
|
4269
|
+
return true;
|
|
4270
|
+
}
|
|
4271
|
+
async function reloadRpxGateway(options) {
|
|
4272
|
+
const { config, environment, driver, logger = noopLogger2 } = options;
|
|
4273
|
+
const routeSource = options.rpxConfig ?? config;
|
|
4274
|
+
const proxy = routeSource.infrastructure?.compute?.proxy ?? config.infrastructure?.compute?.proxy;
|
|
4275
|
+
if (proxy?.engine !== "rpx")
|
|
4276
|
+
return true;
|
|
4277
|
+
const sites = routeSource.sites || {};
|
|
4278
|
+
const rpxConfig = buildRpxConfig(sites, { proxy });
|
|
4279
|
+
if (rpxConfig.proxies.length === 0) {
|
|
4280
|
+
logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
|
|
4281
|
+
return true;
|
|
4282
|
+
}
|
|
4283
|
+
const targets = await driver.findComputeTargets({
|
|
4284
|
+
slug: config.project.slug,
|
|
4285
|
+
environment,
|
|
4286
|
+
role: "app",
|
|
4287
|
+
stackName: resolveProjectStackName(config, environment)
|
|
4288
|
+
});
|
|
4289
|
+
if (targets.length === 0) {
|
|
4290
|
+
logger.warn("rpx gateway: no compute targets found — skipping gateway reload.");
|
|
4291
|
+
return true;
|
|
4292
|
+
}
|
|
4293
|
+
logger.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
|
|
4294
|
+
const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug: config.project.slug });
|
|
4295
|
+
const result = await driver.runRemoteDeploy({
|
|
4296
|
+
targets,
|
|
4297
|
+
commands: script,
|
|
4298
|
+
comment: `ts-cloud rpx gateway reload ${config.project.slug}`,
|
|
4299
|
+
tags: {
|
|
4300
|
+
Project: config.project.slug,
|
|
4301
|
+
Environment: environment,
|
|
4302
|
+
Role: "app"
|
|
4303
|
+
}
|
|
4304
|
+
});
|
|
4305
|
+
if (!result.success) {
|
|
4306
|
+
logger.error(`rpx gateway reload failed: ${result.error || "unknown error"}`);
|
|
4307
|
+
return false;
|
|
4308
|
+
}
|
|
4309
|
+
logger.success(`rpx gateway reloaded on ${result.instanceCount} target(s)`);
|
|
4310
|
+
return true;
|
|
4311
|
+
}
|
|
4312
|
+
// src/drivers/shared/cloudfront-origin.ts
|
|
4313
|
+
var MANAGED_CACHE_POLICY_OPTIMIZED = "658327ea-f89d-4fab-a63d-7e88639e58f6";
|
|
4314
|
+
var MANAGED_CACHE_POLICY_DISABLED = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
|
|
4315
|
+
var MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER = "216adef6-5c7f-47e4-b989-5492eafa07d3";
|
|
4316
|
+
var ORIGIN_ID = "origin";
|
|
4317
|
+
function cacheBehavior(pathPattern, kind) {
|
|
4318
|
+
const base = {
|
|
4319
|
+
TargetOriginId: ORIGIN_ID,
|
|
4320
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
4321
|
+
Compress: true,
|
|
4322
|
+
CachePolicyId: kind === "dynamic" ? MANAGED_CACHE_POLICY_DISABLED : MANAGED_CACHE_POLICY_OPTIMIZED,
|
|
4323
|
+
OriginRequestPolicyId: MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER,
|
|
4324
|
+
SmoothStreaming: false,
|
|
4325
|
+
FieldLevelEncryptionId: "",
|
|
4326
|
+
TrustedSigners: { Enabled: false, Quantity: 0 },
|
|
4327
|
+
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
|
|
4328
|
+
LambdaFunctionAssociations: { Quantity: 0 },
|
|
4329
|
+
FunctionAssociations: { Quantity: 0 },
|
|
4330
|
+
AllowedMethods: kind === "dynamic" ? { Quantity: 7, Items: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], CachedMethods: { Quantity: 2, Items: ["GET", "HEAD"] } } : { Quantity: 3, Items: ["GET", "HEAD", "OPTIONS"], CachedMethods: { Quantity: 2, Items: ["GET", "HEAD"] } }
|
|
4331
|
+
};
|
|
4332
|
+
if (pathPattern !== null)
|
|
4333
|
+
base.PathPattern = pathPattern;
|
|
4334
|
+
return base;
|
|
4335
|
+
}
|
|
4336
|
+
function buildCloudFrontOriginConfig(options) {
|
|
4337
|
+
const aliases = options.aliases;
|
|
4338
|
+
if (aliases.length === 0)
|
|
4339
|
+
throw new Error("buildCloudFrontOriginConfig: at least one alias is required");
|
|
4340
|
+
if (aliases.includes(options.originDomain))
|
|
4341
|
+
throw new Error(`buildCloudFrontOriginConfig: originDomain ${options.originDomain} must not be one of the aliases (it would loop)`);
|
|
4342
|
+
const header = options.originSecretHeader ?? "X-Origin-Verify";
|
|
4343
|
+
const customHeaders = options.originSecret ? { Quantity: 1, Items: [{ HeaderName: header, HeaderValue: options.originSecret }] } : { Quantity: 0 };
|
|
4344
|
+
const behaviors = [...options.behaviors ?? []].sort((a, b) => b.pathPattern.length - a.pathPattern.length);
|
|
4345
|
+
return {
|
|
4346
|
+
CallerReference: options.callerReference ?? options.originDomain,
|
|
4347
|
+
Comment: options.comment ?? `Origin-fronted distribution for ${aliases[0]} → ${options.originDomain}`,
|
|
4348
|
+
Enabled: true,
|
|
4349
|
+
Aliases: { Quantity: aliases.length, Items: aliases },
|
|
4350
|
+
DefaultRootObject: "",
|
|
4351
|
+
Origins: {
|
|
4352
|
+
Quantity: 1,
|
|
4353
|
+
Items: [{
|
|
4354
|
+
Id: ORIGIN_ID,
|
|
4355
|
+
DomainName: options.originDomain,
|
|
4356
|
+
OriginPath: "",
|
|
4357
|
+
CustomHeaders: customHeaders,
|
|
4358
|
+
CustomOriginConfig: {
|
|
4359
|
+
HTTPPort: 80,
|
|
4360
|
+
HTTPSPort: 443,
|
|
4361
|
+
OriginProtocolPolicy: "https-only",
|
|
4362
|
+
OriginSslProtocols: { Quantity: 1, Items: ["TLSv1.2"] },
|
|
4363
|
+
OriginReadTimeout: 30,
|
|
4364
|
+
OriginKeepaliveTimeout: 5
|
|
4365
|
+
},
|
|
4366
|
+
ConnectionAttempts: 3,
|
|
4367
|
+
ConnectionTimeout: 10,
|
|
4368
|
+
OriginShield: { Enabled: false },
|
|
4369
|
+
OriginAccessControlId: ""
|
|
4370
|
+
}]
|
|
4371
|
+
},
|
|
4372
|
+
OriginGroups: { Quantity: 0 },
|
|
4373
|
+
DefaultCacheBehavior: cacheBehavior(null, "static"),
|
|
4374
|
+
CacheBehaviors: { Quantity: behaviors.length, Items: behaviors.map((b) => cacheBehavior(b.pathPattern, b.kind)) },
|
|
4375
|
+
ViewerCertificate: {
|
|
4376
|
+
CloudFrontDefaultCertificate: false,
|
|
4377
|
+
ACMCertificateArn: options.viewerCertificateArn,
|
|
4378
|
+
Certificate: options.viewerCertificateArn,
|
|
4379
|
+
CertificateSource: "acm",
|
|
4380
|
+
SSLSupportMethod: "sni-only",
|
|
4381
|
+
MinimumProtocolVersion: "TLSv1.2_2021"
|
|
4382
|
+
},
|
|
4383
|
+
PriceClass: options.priceClass ?? "PriceClass_All",
|
|
4384
|
+
HttpVersion: "http2and3",
|
|
4385
|
+
IsIPV6Enabled: true,
|
|
4386
|
+
Restrictions: { GeoRestriction: { RestrictionType: "none", Quantity: 0 } },
|
|
4387
|
+
Logging: { Enabled: false, IncludeCookies: false, Bucket: "", Prefix: "" },
|
|
4388
|
+
WebACLId: "",
|
|
4389
|
+
CustomErrorResponses: { Quantity: 0 }
|
|
4390
|
+
};
|
|
4391
|
+
}
|
|
4392
|
+
export { isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, HetznerClient, resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
|