@stacksjs/ts-cloud 0.7.30 → 0.7.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +781 -774
- package/dist/{chunk-xdd6qvre.js → chunk-dah449r1.js} +268 -105
- package/dist/{chunk-4ew46pj6.js → chunk-ebx9gjnn.js} +2 -2
- package/dist/{chunk-rr3j38qr.js → chunk-jgenfdz6.js} +28 -26
- package/dist/{chunk-k56wpkhg.js → chunk-stt1z5cx.js} +4 -1
- package/dist/{chunk-3mcfbrw8.js → chunk-tskj9fay.js} +1 -1
- package/dist/deploy/dashboard-database.d.ts +8 -6
- package/dist/deploy/index.js +4 -4
- package/dist/drivers/hetzner/client.d.ts +8 -0
- package/dist/drivers/hetzner/driver.d.ts +16 -0
- package/dist/drivers/index.d.ts +3 -3
- package/dist/drivers/index.js +6 -2
- package/dist/drivers/local-box/driver.d.ts +1 -1
- package/dist/drivers/shared/backups.d.ts +3 -1
- package/dist/drivers/shared/compute-deploy.d.ts +9 -3
- package/dist/drivers/shared/db-provision.d.ts +24 -2
- package/dist/drivers/shared/deploy-script.d.ts +12 -2
- package/dist/drivers/shared/fleet.d.ts +12 -1
- package/dist/drivers/shared/rpx-gateway.d.ts +26 -0
- package/dist/drivers/shared/ssh-keys.d.ts +3 -2
- package/dist/index.js +6 -4
- 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/team.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
|
@@ -2,10 +2,11 @@ import {
|
|
|
2
2
|
deploymentCoexistenceError,
|
|
3
3
|
hasManagementDashboardSite,
|
|
4
4
|
isManagementDashboardSiteName,
|
|
5
|
+
resolveAppDatabase,
|
|
5
6
|
resolveCloudProvider,
|
|
6
7
|
resolveManagementDashboardSites,
|
|
7
8
|
resolveProjectStackName
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-stt1z5cx.js";
|
|
9
10
|
import {
|
|
10
11
|
EC2Client,
|
|
11
12
|
SSMClient
|
|
@@ -77,6 +78,17 @@ function buildPantryServiceScript(services) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// src/drivers/shared/db-provision.ts
|
|
81
|
+
function isLocalDatabase(database) {
|
|
82
|
+
return !database?.host || database.host === "127.0.0.1" || database.host === "localhost";
|
|
83
|
+
}
|
|
84
|
+
function pgAdminCommand(database, tool = "psql") {
|
|
85
|
+
const port = database?.port ?? 5432;
|
|
86
|
+
if (isLocalDatabase(database))
|
|
87
|
+
return `${tool} -p ${port} -U postgres`;
|
|
88
|
+
const user = database?.username || "postgres";
|
|
89
|
+
const env = database?.password ? `PGPASSWORD='${database.password.replace(/'/g, `'\\''`)}' ` : "";
|
|
90
|
+
return `${env}${tool} -h ${database.host} -p ${port} -U ${user} -w`;
|
|
91
|
+
}
|
|
80
92
|
function enabled(spec) {
|
|
81
93
|
return spec === true || typeof spec === "object" && spec != null;
|
|
82
94
|
}
|
|
@@ -120,7 +132,7 @@ function buildServicesProvisionScript(services = {}, _options = {}) {
|
|
|
120
132
|
function buildDatabaseSetupScript(database, services = {}) {
|
|
121
133
|
if (!database?.name)
|
|
122
134
|
return [];
|
|
123
|
-
if (database
|
|
135
|
+
if (!isLocalDatabase(database))
|
|
124
136
|
return [];
|
|
125
137
|
const name = database.name;
|
|
126
138
|
const user = database.username || name;
|
|
@@ -145,7 +157,7 @@ function buildDatabaseSetupScript(database, services = {}) {
|
|
|
145
157
|
const lines = [];
|
|
146
158
|
for (const db of dbs) {
|
|
147
159
|
if (u.access === "readonly") {
|
|
148
|
-
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");
|
|
160
|
+
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 FOR ROLE ${pgIdent(user)} IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
|
|
149
161
|
} else {
|
|
150
162
|
lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
|
|
151
163
|
}
|
|
@@ -153,10 +165,11 @@ function buildDatabaseSetupScript(database, services = {}) {
|
|
|
153
165
|
return lines;
|
|
154
166
|
};
|
|
155
167
|
const extraUsers2 = database.users || [];
|
|
168
|
+
const pgPort = database.port ?? 5432;
|
|
156
169
|
return [
|
|
157
170
|
pantryEnvActivation(),
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
`for i in $(seq 1 30); do pg_isready -p ${pgPort} -q && break; sleep 2; done`,
|
|
172
|
+
`${pgAdminCommand(database)} <<'TS_CLOUD_PG_EOF'`,
|
|
160
173
|
...pgEnsureRole(user, pass),
|
|
161
174
|
`SELECT 'CREATE DATABASE ${pgIdent(name)} OWNER ${pgIdent(user)}' ` + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`,
|
|
162
175
|
...extraUsers2.flatMap((u) => [...pgEnsureRole(u.username, u.password), ...pgGrant(u)]),
|
|
@@ -810,7 +823,9 @@ import { startProxies } from '@stacksjs/rpx'
|
|
|
810
823
|
|
|
811
824
|
const config = ${json} as const
|
|
812
825
|
|
|
813
|
-
|
|
826
|
+
// Verbose is the hard default for ts-cloud-installed gateways (RPX_VERBOSE=false
|
|
827
|
+
// opts out) — without it rpx's TLS/routing diagnostics never reach the journal.
|
|
828
|
+
await startProxies({ verbose: process.env.RPX_VERBOSE !== 'false', ...config } as any)
|
|
814
829
|
`;
|
|
815
830
|
}
|
|
816
831
|
function usesRpxProxy(compute) {
|
|
@@ -869,6 +884,12 @@ const config = {
|
|
|
869
884
|
https: true,
|
|
870
885
|
hostsManagement: false,
|
|
871
886
|
cleanup: { hosts: false, certs: false },
|
|
887
|
+
// Verbose is the hard default for ts-cloud-installed gateways: without it,
|
|
888
|
+
// rpx's TLS/routing diagnostics (tlsx on-demand 'refused issuance',
|
|
889
|
+
// 'issuance failed', 'adopted existing on-disk cert') never reach the systemd
|
|
890
|
+
// journal and a production TLS failure looks like "nothing happens". Silence
|
|
891
|
+
// it by setting RPX_VERBOSE=false on the systemd unit.
|
|
892
|
+
verbose: process.env.RPX_VERBOSE !== 'false',
|
|
872
893
|
...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
|
|
873
894
|
...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
|
|
874
895
|
...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
|
|
@@ -877,14 +898,14 @@ const config = {
|
|
|
877
898
|
await startProxies(config)
|
|
878
899
|
`;
|
|
879
900
|
}
|
|
880
|
-
function writeFileHeredoc(path, content, delimiter) {
|
|
901
|
+
function writeFileHeredoc(path, content, delimiter, mode = "0644") {
|
|
881
902
|
return [
|
|
882
903
|
`__tsc_tmp="$(mktemp "${path}.XXXXXX")"`,
|
|
883
904
|
`cat > "$__tsc_tmp" <<'${delimiter}'`,
|
|
884
905
|
content,
|
|
885
906
|
delimiter,
|
|
886
907
|
`mv -f "$__tsc_tmp" ${path}`,
|
|
887
|
-
`chmod
|
|
908
|
+
`chmod ${mode} ${path}`
|
|
888
909
|
];
|
|
889
910
|
}
|
|
890
911
|
function certDomainsForConfig(config) {
|
|
@@ -986,10 +1007,14 @@ function buildRpxProvisionScript(options) {
|
|
|
986
1007
|
return [
|
|
987
1008
|
"set -euo pipefail",
|
|
988
1009
|
`mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
|
|
989
|
-
`rm -rf ${RPX_INSTALL_DIR}
|
|
990
|
-
`
|
|
1010
|
+
`rm -rf ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}.prev`,
|
|
1011
|
+
`mkdir -p ${RPX_INSTALL_DIR}.next`,
|
|
1012
|
+
`(cd ${RPX_INSTALL_DIR}.next && ${bunBin} add @stacksjs/rpx@${version})`,
|
|
1013
|
+
`mv ${RPX_INSTALL_DIR} ${RPX_INSTALL_DIR}.prev`,
|
|
1014
|
+
`mv ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}`,
|
|
1015
|
+
`rm -rf ${RPX_INSTALL_DIR}.prev`,
|
|
991
1016
|
`ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
|
|
992
|
-
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
|
|
1017
|
+
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
|
|
993
1018
|
...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
|
|
994
1019
|
...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
|
|
995
1020
|
"[Unit]",
|
|
@@ -1020,13 +1045,23 @@ function buildRpxProvisionScript(options) {
|
|
|
1020
1045
|
...buildCertManagementCommands(options)
|
|
1021
1046
|
];
|
|
1022
1047
|
}
|
|
1048
|
+
function buildRpxFragmentRefreshScript(options) {
|
|
1049
|
+
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
1050
|
+
const fragment = JSON.stringify({ slug, ...options.config }, null, 2);
|
|
1051
|
+
return [
|
|
1052
|
+
"set -euo pipefail",
|
|
1053
|
+
`mkdir -p ${RPX_SITES_DIR}`,
|
|
1054
|
+
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
|
|
1055
|
+
`systemctl restart ${RPX_SERVICE_NAME}`
|
|
1056
|
+
];
|
|
1057
|
+
}
|
|
1023
1058
|
|
|
1024
1059
|
// src/drivers/shared/ufw.ts
|
|
1025
1060
|
var UFW_BASE_PORTS = [80, 443];
|
|
1026
1061
|
function buildUfwScript(firewall = {}) {
|
|
1027
1062
|
if (firewall.enabled === false)
|
|
1028
1063
|
return [];
|
|
1029
|
-
const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p
|
|
1064
|
+
const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => Number.isInteger(p) && p >= 1 && p <= 65535).sort((a, b) => a - b);
|
|
1030
1065
|
const lines = [
|
|
1031
1066
|
"export DEBIAN_FRONTEND=noninteractive",
|
|
1032
1067
|
"apt-get install -y ufw",
|
|
@@ -1157,25 +1192,23 @@ var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
|
|
|
1157
1192
|
var BLOCK_END = "# <<< ts-cloud managed keys <<<";
|
|
1158
1193
|
var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
1159
1194
|
function buildAuthorizedKeysScript(keys = [], options = {}) {
|
|
1160
|
-
if (keys.length === 0)
|
|
1161
|
-
return [];
|
|
1162
1195
|
const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
|
|
1163
1196
|
const dir = path.replace(/\/[^/]*$/, "");
|
|
1164
|
-
const
|
|
1165
|
-
BLOCK_BEGIN,
|
|
1166
|
-
...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
|
|
1167
|
-
BLOCK_END
|
|
1168
|
-
].join(`
|
|
1169
|
-
`);
|
|
1170
|
-
return [
|
|
1197
|
+
const lines = [
|
|
1171
1198
|
`mkdir -p ${dir}`,
|
|
1172
1199
|
`touch ${path}`,
|
|
1173
|
-
`sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}
|
|
1174
|
-
`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
|
|
1175
|
-
block,
|
|
1176
|
-
"TS_CLOUD_KEYS_EOF",
|
|
1177
|
-
`chmod 600 ${path}`
|
|
1200
|
+
`sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`
|
|
1178
1201
|
];
|
|
1202
|
+
if (keys.length > 0) {
|
|
1203
|
+
const block = [
|
|
1204
|
+
BLOCK_BEGIN,
|
|
1205
|
+
...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
|
|
1206
|
+
BLOCK_END
|
|
1207
|
+
].join(`
|
|
1208
|
+
`);
|
|
1209
|
+
lines.push(`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`, block, "TS_CLOUD_KEYS_EOF", `chmod 600 ${path}`);
|
|
1210
|
+
}
|
|
1211
|
+
return lines;
|
|
1179
1212
|
}
|
|
1180
1213
|
function escapeSed(value) {
|
|
1181
1214
|
return value.replace(/[.*[\]\\/^$]/g, "\\$&");
|
|
@@ -1367,7 +1400,7 @@ function buildBackupRestoreScript(database, options = {}) {
|
|
|
1367
1400
|
const name = database.name;
|
|
1368
1401
|
const isPg = database.engine === "postgres";
|
|
1369
1402
|
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)"`;
|
|
1370
|
-
const client = isPg ?
|
|
1403
|
+
const client = isPg ? `${pgAdminCommand(database)} -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
|
|
1371
1404
|
return [
|
|
1372
1405
|
"set -uo pipefail",
|
|
1373
1406
|
'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
|
|
@@ -1399,11 +1432,12 @@ function buildComputeProvisionScripts(config) {
|
|
|
1399
1432
|
...useNginx ? buildNginxServiceScript() : []
|
|
1400
1433
|
] : undefined;
|
|
1401
1434
|
const extras = [];
|
|
1435
|
+
const appDatabase = resolveAppDatabase(config);
|
|
1402
1436
|
if (!phpBox && needsPantry)
|
|
1403
1437
|
extras.push(...pantryBootstrap);
|
|
1404
1438
|
extras.push(...buildNotifierScript(config.notifications));
|
|
1405
1439
|
if (compute.managedServices) {
|
|
1406
|
-
extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(
|
|
1440
|
+
extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(appDatabase, compute.managedServices));
|
|
1407
1441
|
}
|
|
1408
1442
|
extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
|
|
1409
1443
|
extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
|
|
@@ -1411,7 +1445,7 @@ function buildComputeProvisionScripts(config) {
|
|
|
1411
1445
|
extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
|
|
1412
1446
|
if (compute.backups?.enabled) {
|
|
1413
1447
|
extras.push(...buildBackupProvisionScript({
|
|
1414
|
-
database:
|
|
1448
|
+
database: appDatabase,
|
|
1415
1449
|
backups: compute.backups
|
|
1416
1450
|
}));
|
|
1417
1451
|
}
|
|
@@ -1514,7 +1548,6 @@ ln -sf /root/.deno/bin/deno /usr/local/bin/deno
|
|
|
1514
1548
|
mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
|
|
1515
1549
|
`;
|
|
1516
1550
|
if (caddyfile) {
|
|
1517
|
-
const escaped = caddyfile.replace(/\$/g, "\\$");
|
|
1518
1551
|
script += `
|
|
1519
1552
|
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
|
1520
1553
|
curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
|
|
@@ -1552,7 +1585,7 @@ WantedBy=multi-user.target
|
|
|
1552
1585
|
CADDY_UNIT_EOF
|
|
1553
1586
|
|
|
1554
1587
|
cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
|
|
1555
|
-
${
|
|
1588
|
+
${caddyfile}
|
|
1556
1589
|
CADDY_CONFIG_EOF
|
|
1557
1590
|
|
|
1558
1591
|
systemctl daemon-reload
|
|
@@ -1858,7 +1891,7 @@ class AwsDriver {
|
|
|
1858
1891
|
if (!sendResult.CommandId) {
|
|
1859
1892
|
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1860
1893
|
}
|
|
1861
|
-
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1894
|
+
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length, ((options.timeoutSeconds || 600) + 30) * 1000);
|
|
1862
1895
|
}
|
|
1863
1896
|
if (!options.tags || Object.keys(options.tags).length === 0) {
|
|
1864
1897
|
return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
|
|
@@ -1881,9 +1914,8 @@ class AwsDriver {
|
|
|
1881
1914
|
error: result.error
|
|
1882
1915
|
};
|
|
1883
1916
|
}
|
|
1884
|
-
async pollSsmCommand(ssm, commandId, expectedCount) {
|
|
1917
|
+
async pollSsmCommand(ssm, commandId, expectedCount, maxWait = 600000) {
|
|
1885
1918
|
const pollInterval = 3000;
|
|
1886
|
-
const maxWait = 600000;
|
|
1887
1919
|
const startTime = Date.now();
|
|
1888
1920
|
const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
|
|
1889
1921
|
let lastInvocations = [];
|
|
@@ -2019,9 +2051,21 @@ class HetznerClient {
|
|
|
2019
2051
|
}
|
|
2020
2052
|
return data;
|
|
2021
2053
|
}
|
|
2054
|
+
async requestAll(path, key) {
|
|
2055
|
+
const items = [];
|
|
2056
|
+
let page = 1;
|
|
2057
|
+
for (;; ) {
|
|
2058
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
2059
|
+
const data = await this.request("GET", `${path}${sep}per_page=50&page=${page}`);
|
|
2060
|
+
items.push(...data[key] ?? []);
|
|
2061
|
+
const next = data.meta?.pagination?.next_page;
|
|
2062
|
+
if (!next)
|
|
2063
|
+
return items;
|
|
2064
|
+
page = next;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2022
2067
|
async listServers() {
|
|
2023
|
-
|
|
2024
|
-
return data.servers;
|
|
2068
|
+
return this.requestAll("/servers", "servers");
|
|
2025
2069
|
}
|
|
2026
2070
|
async getServer(id) {
|
|
2027
2071
|
const data = await this.request("GET", `/servers/${id}`);
|
|
@@ -2044,8 +2088,7 @@ class HetznerClient {
|
|
|
2044
2088
|
return { server: data.server, action: data.action };
|
|
2045
2089
|
}
|
|
2046
2090
|
async listNetworks() {
|
|
2047
|
-
|
|
2048
|
-
return data.networks;
|
|
2091
|
+
return this.requestAll("/networks", "networks");
|
|
2049
2092
|
}
|
|
2050
2093
|
async createNetwork(options) {
|
|
2051
2094
|
const ipRange = options.ipRange ?? "10.0.0.0/16";
|
|
@@ -2061,8 +2104,7 @@ class HetznerClient {
|
|
|
2061
2104
|
await this.request("DELETE", `/networks/${id}`);
|
|
2062
2105
|
}
|
|
2063
2106
|
async listLoadBalancers() {
|
|
2064
|
-
|
|
2065
|
-
return data.load_balancers;
|
|
2107
|
+
return this.requestAll("/load_balancers", "load_balancers");
|
|
2066
2108
|
}
|
|
2067
2109
|
async createLoadBalancer(options) {
|
|
2068
2110
|
const data = await this.request("POST", "/load_balancers", {
|
|
@@ -2097,8 +2139,7 @@ class HetznerClient {
|
|
|
2097
2139
|
return data.action;
|
|
2098
2140
|
}
|
|
2099
2141
|
async listFirewalls() {
|
|
2100
|
-
|
|
2101
|
-
return data.firewalls;
|
|
2142
|
+
return this.requestAll("/firewalls", "firewalls");
|
|
2102
2143
|
}
|
|
2103
2144
|
async createFirewall(options) {
|
|
2104
2145
|
const data = await this.request("POST", "/firewalls", {
|
|
@@ -2125,8 +2166,7 @@ class HetznerClient {
|
|
|
2125
2166
|
return data.actions;
|
|
2126
2167
|
}
|
|
2127
2168
|
async listSshKeys() {
|
|
2128
|
-
|
|
2129
|
-
return data.ssh_keys;
|
|
2169
|
+
return this.requestAll("/ssh_keys", "ssh_keys");
|
|
2130
2170
|
}
|
|
2131
2171
|
async createSshKey(options) {
|
|
2132
2172
|
const data = await this.request("POST", "/ssh_keys", {
|
|
@@ -2223,6 +2263,19 @@ function buildFleetServicesEnv(servicesPrivateIp, database) {
|
|
|
2223
2263
|
}
|
|
2224
2264
|
return env2;
|
|
2225
2265
|
}
|
|
2266
|
+
function buildFleetServicesBoxProvision(config) {
|
|
2267
|
+
const compute = config.infrastructure?.compute ?? {};
|
|
2268
|
+
const appDatabase = resolveAppDatabase(config);
|
|
2269
|
+
return [
|
|
2270
|
+
...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
|
|
2271
|
+
...buildDatabaseSetupScript(appDatabase, compute.managedServices ?? { mysql: true }),
|
|
2272
|
+
...buildAutoUpdatesScript(true),
|
|
2273
|
+
...buildMonitoringScript(true),
|
|
2274
|
+
...buildAuthorizedKeysScript(compute.sshKeys),
|
|
2275
|
+
...buildNotifierScript(config.notifications),
|
|
2276
|
+
...compute.backups?.enabled ? [...buildBackupProvisionScript({ database: appDatabase, backups: compute.backups })] : []
|
|
2277
|
+
];
|
|
2278
|
+
}
|
|
2226
2279
|
|
|
2227
2280
|
// src/drivers/hetzner/firewall-rules.ts
|
|
2228
2281
|
function buildHetznerFirewallRules(config) {
|
|
@@ -2274,7 +2327,7 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
|
|
|
2274
2327
|
}
|
|
2275
2328
|
|
|
2276
2329
|
// src/drivers/hetzner/state.ts
|
|
2277
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2330
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2278
2331
|
import { join as join3 } from "node:path";
|
|
2279
2332
|
var STATE_DIR = "storage/cloud/state";
|
|
2280
2333
|
function driverStatePath(stackName) {
|
|
@@ -2291,8 +2344,10 @@ async function readDriverState(stackName) {
|
|
|
2291
2344
|
async function writeDriverState(stackName, state) {
|
|
2292
2345
|
const path = driverStatePath(stackName);
|
|
2293
2346
|
await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
|
|
2294
|
-
|
|
2347
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2348
|
+
await writeFile(tmp, `${JSON.stringify(state, null, 2)}
|
|
2295
2349
|
`, "utf8");
|
|
2350
|
+
await rename(tmp, path);
|
|
2296
2351
|
}
|
|
2297
2352
|
|
|
2298
2353
|
// src/drivers/hetzner/driver.ts
|
|
@@ -2300,7 +2355,7 @@ var SSH_MAX_BUFFER = 1024 * 1024 * 256;
|
|
|
2300
2355
|
var SSH_ERROR_OUTPUT_LIMIT = 8000;
|
|
2301
2356
|
function sshErrorOutput(value) {
|
|
2302
2357
|
const output = Buffer.isBuffer(value) ? value.toString("utf8") : typeof value === "string" ? value : "";
|
|
2303
|
-
return output.replace(/(^|\n)([A-
|
|
2358
|
+
return output.replace(/(^|\n)(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=).*$/gm, "$1$2[redacted]").replace(/encrypted:[A-Za-z0-9+/=]+/g, "encrypted:[redacted]").trim().slice(-SSH_ERROR_OUTPUT_LIMIT);
|
|
2304
2359
|
}
|
|
2305
2360
|
function formatSshFailure(error) {
|
|
2306
2361
|
const childError = error;
|
|
@@ -2358,6 +2413,15 @@ class HetznerDriver {
|
|
|
2358
2413
|
if (topology.dedicatedServices || topology.appServers > 1) {
|
|
2359
2414
|
return phpBox ? this.provisionFleet(options, topology) : this.provisionBunFleet(options, topology);
|
|
2360
2415
|
}
|
|
2416
|
+
const labels = tsCloudLabels(slug, environment, "app");
|
|
2417
|
+
const sites = config.sites || {};
|
|
2418
|
+
const sitePorts = this.collectUpstreamPorts(sites);
|
|
2419
|
+
const firewallName = `${slug}-${environment}-app-fw`;
|
|
2420
|
+
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
2421
|
+
allowSsh: compute.allowSsh !== false,
|
|
2422
|
+
sitePorts
|
|
2423
|
+
}));
|
|
2424
|
+
const sshKeyId = await this.ensureSshKey(slug, environment, labels);
|
|
2361
2425
|
const existing = await readDriverState(stackName);
|
|
2362
2426
|
if (existing?.serverId) {
|
|
2363
2427
|
const server2 = await this.tryGetServer(existing.serverId);
|
|
@@ -2365,7 +2429,6 @@ class HetznerDriver {
|
|
|
2365
2429
|
return this.outputsFromState(existing, server2);
|
|
2366
2430
|
}
|
|
2367
2431
|
}
|
|
2368
|
-
const labels = tsCloudLabels(slug, environment, "app");
|
|
2369
2432
|
const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
|
|
2370
2433
|
if (alreadyRunning && alreadyRunning.status !== "off") {
|
|
2371
2434
|
const rehydrated = {
|
|
@@ -2380,8 +2443,6 @@ class HetznerDriver {
|
|
|
2380
2443
|
await writeDriverState(stackName, rehydrated);
|
|
2381
2444
|
return this.outputsFromState(rehydrated, alreadyRunning);
|
|
2382
2445
|
}
|
|
2383
|
-
const sites = config.sites || {};
|
|
2384
|
-
const sitePorts = this.collectUpstreamPorts(sites);
|
|
2385
2446
|
const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
|
|
2386
2447
|
proxy: compute.proxy,
|
|
2387
2448
|
config: buildRpxConfig(sites, { proxy: compute.proxy, slug: config.project.slug }),
|
|
@@ -2403,12 +2464,6 @@ class HetznerDriver {
|
|
|
2403
2464
|
const userData = wrapCloudInitUserData(bootstrap);
|
|
2404
2465
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2405
2466
|
const image = resolveHetznerImage(config);
|
|
2406
|
-
const firewallName = `${slug}-${environment}-app-fw`;
|
|
2407
|
-
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
2408
|
-
allowSsh: compute.allowSsh !== false,
|
|
2409
|
-
sitePorts
|
|
2410
|
-
}));
|
|
2411
|
-
const sshKeyId = await this.ensureSshKey(slug, environment, labels);
|
|
2412
2467
|
const { server, action } = await this.client.createServer({
|
|
2413
2468
|
name: serverName,
|
|
2414
2469
|
serverType,
|
|
@@ -2474,13 +2529,7 @@ class HetznerDriver {
|
|
|
2474
2529
|
{ direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
|
|
2475
2530
|
{ direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
|
|
2476
2531
|
]);
|
|
2477
|
-
const servicesProvision =
|
|
2478
|
-
...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
|
|
2479
|
-
...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
|
|
2480
|
-
...buildAutoUpdatesScript(true),
|
|
2481
|
-
...buildMonitoringScript(true),
|
|
2482
|
-
...buildAuthorizedKeysScript(compute.sshKeys)
|
|
2483
|
-
];
|
|
2532
|
+
const servicesProvision = buildFleetServicesBoxProvision(config);
|
|
2484
2533
|
const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", servicesProvision, baked }));
|
|
2485
2534
|
const all = await this.client.listServers().catch(() => []);
|
|
2486
2535
|
const newServerIds = [];
|
|
@@ -2608,6 +2657,9 @@ class HetznerDriver {
|
|
|
2608
2657
|
if (existingState?.lbServerId) {
|
|
2609
2658
|
const lb = await this.tryGetServer(existingState.lbServerId);
|
|
2610
2659
|
if (lb && lb.status !== "off") {
|
|
2660
|
+
const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
|
|
2661
|
+
const appTargets = await this.findComputeTargets({ slug, environment, role: "app", stackName });
|
|
2662
|
+
this.refreshLbRoutes(lb, sites, appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp })), rpxProxy, slug);
|
|
2611
2663
|
return {
|
|
2612
2664
|
appPublicIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
|
|
2613
2665
|
loadBalancerIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
|
|
@@ -2638,7 +2690,8 @@ class HetznerDriver {
|
|
|
2638
2690
|
let servicesServerId;
|
|
2639
2691
|
let servicesPrivateIp;
|
|
2640
2692
|
const wantsServicesBox = !!compute.servicesServer || !!compute.managedServices;
|
|
2641
|
-
|
|
2693
|
+
const dedicatedServicesHere = topology.dedicatedServices && wantsServicesBox;
|
|
2694
|
+
if (dedicatedServicesHere) {
|
|
2642
2695
|
const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
|
|
2643
2696
|
{ direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
|
|
2644
2697
|
{ direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
|
|
@@ -2646,13 +2699,7 @@ class HetznerDriver {
|
|
|
2646
2699
|
{ direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
|
|
2647
2700
|
{ direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
|
|
2648
2701
|
]);
|
|
2649
|
-
const servicesProvision =
|
|
2650
|
-
...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
|
|
2651
|
-
...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
|
|
2652
|
-
...buildAutoUpdatesScript(true),
|
|
2653
|
-
...buildMonitoringScript(true),
|
|
2654
|
-
...buildAuthorizedKeysScript(compute.sshKeys)
|
|
2655
|
-
];
|
|
2702
|
+
const servicesProvision = buildFleetServicesBoxProvision(config);
|
|
2656
2703
|
const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "bun", servicesProvision, baked }));
|
|
2657
2704
|
let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
|
|
2658
2705
|
if (!svcServer) {
|
|
@@ -2674,7 +2721,11 @@ class HetznerDriver {
|
|
|
2674
2721
|
servicesServerId = svcServer.id;
|
|
2675
2722
|
servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
|
|
2676
2723
|
}
|
|
2677
|
-
const
|
|
2724
|
+
const appBoxCompute = dedicatedServicesHere ? { ...compute, managedServices: undefined, backups: undefined } : compute;
|
|
2725
|
+
const appProvisionScripts = buildComputeProvisionScripts({
|
|
2726
|
+
...config,
|
|
2727
|
+
infrastructure: { ...config.infrastructure, compute: appBoxCompute }
|
|
2728
|
+
});
|
|
2678
2729
|
const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
|
|
2679
2730
|
runtime: appProvisionScripts.runtime,
|
|
2680
2731
|
runtimeVersion: appProvisionScripts.runtimeVersion,
|
|
@@ -2729,8 +2780,8 @@ class HetznerDriver {
|
|
|
2729
2780
|
let lbIp;
|
|
2730
2781
|
if (topology.loadBalancer) {
|
|
2731
2782
|
let lbServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "lb"));
|
|
2783
|
+
const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
|
|
2732
2784
|
if (!lbServer) {
|
|
2733
|
-
const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
|
|
2734
2785
|
const lbRpxProvision = buildRpxProvisionScript({
|
|
2735
2786
|
proxy: rpxProxy,
|
|
2736
2787
|
config: buildRpxLbConfig(sites, appBoxes, { proxy: rpxProxy, slug }),
|
|
@@ -2763,6 +2814,8 @@ class HetznerDriver {
|
|
|
2763
2814
|
await this.waitForCloudInit(ip);
|
|
2764
2815
|
}
|
|
2765
2816
|
}
|
|
2817
|
+
} else {
|
|
2818
|
+
this.refreshLbRoutes(lbServer, sites, appBoxes, rpxProxy, slug);
|
|
2766
2819
|
}
|
|
2767
2820
|
lbId = lbServer.id;
|
|
2768
2821
|
lbIp = lbServer.public_net.ipv4?.ip;
|
|
@@ -2862,8 +2915,9 @@ class HetznerDriver {
|
|
|
2862
2915
|
const stackName = resolveProjectStackName(options.config, options.environment);
|
|
2863
2916
|
const state = await readDriverState(stackName);
|
|
2864
2917
|
if (state?.serverId) {
|
|
2865
|
-
const server = await this.
|
|
2866
|
-
|
|
2918
|
+
const server = await this.tryGetServer(state.serverId);
|
|
2919
|
+
if (server)
|
|
2920
|
+
return this.outputsFromState(state, server);
|
|
2867
2921
|
}
|
|
2868
2922
|
if (state?.lbServerId) {
|
|
2869
2923
|
const lb = await this.tryGetServer(state.lbServerId);
|
|
@@ -3008,6 +3062,21 @@ class HetznerDriver {
|
|
|
3008
3062
|
return null;
|
|
3009
3063
|
}
|
|
3010
3064
|
}
|
|
3065
|
+
refreshLbRoutes(lb, sites, appBoxes, proxy, slug) {
|
|
3066
|
+
const ip = lb.public_net.ipv4?.ip;
|
|
3067
|
+
if (!ip) {
|
|
3068
|
+
console.warn(`[ts-cloud] LB server '${lb.name}' has no public IP — skipping the rpx route refresh; its routes may be stale.`);
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
const routable = appBoxes.filter((box) => box.privateIp || box.publicIp);
|
|
3072
|
+
if (routable.length === 0) {
|
|
3073
|
+
console.warn(`[ts-cloud] No routable app boxes found for LB '${lb.name}' — leaving its current rpx routes untouched.`);
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const config = buildRpxLbConfig(sites, routable, { proxy, slug });
|
|
3077
|
+
this.sshExec(ip, buildRpxFragmentRefreshScript({ config, slug }).join(`
|
|
3078
|
+
`));
|
|
3079
|
+
}
|
|
3011
3080
|
async findExistingServer(slug, environment, serverName) {
|
|
3012
3081
|
const servers = await this.client.listServers();
|
|
3013
3082
|
const exact = servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
|
|
@@ -3066,12 +3135,12 @@ class HetznerDriver {
|
|
|
3066
3135
|
const start = Date.now();
|
|
3067
3136
|
while (Date.now() - start < cloudInitTimeoutMs) {
|
|
3068
3137
|
try {
|
|
3069
|
-
const out = this.sshExec(host,
|
|
3070
|
-
if (/status:\s*done/.test(out))
|
|
3071
|
-
return;
|
|
3138
|
+
const out = this.sshExec(host, `if command -v cloud-init >/dev/null 2>&1; then cloud-init status --long 2>/dev/null || true; else echo 'status: done'; fi`);
|
|
3072
3139
|
if (/status:\s*error/.test(out))
|
|
3073
3140
|
throw new Error(`cloud-init reported an error on ${host}:
|
|
3074
3141
|
${out}`);
|
|
3142
|
+
if (/status:\s*(?:done|degraded)/.test(out))
|
|
3143
|
+
return;
|
|
3075
3144
|
} catch (err) {
|
|
3076
3145
|
if (err instanceof Error && /cloud-init reported an error/.test(err.message))
|
|
3077
3146
|
throw err;
|
|
@@ -3164,7 +3233,9 @@ class LocalBoxDriver {
|
|
|
3164
3233
|
async uploadRelease(options) {
|
|
3165
3234
|
return { artifactRef: options.localPath };
|
|
3166
3235
|
}
|
|
3167
|
-
async findComputeTargets(
|
|
3236
|
+
async findComputeTargets(options) {
|
|
3237
|
+
if ((options.role || "app") !== "app")
|
|
3238
|
+
return [];
|
|
3168
3239
|
return [{ id: "localhost", name: "localhost", publicIp: "127.0.0.1", status: "running" }];
|
|
3169
3240
|
}
|
|
3170
3241
|
async runRemoteDeploy(options) {
|
|
@@ -3500,7 +3571,7 @@ class AwsBoxProvisioner {
|
|
|
3500
3571
|
MinCount: 1,
|
|
3501
3572
|
MaxCount: 1,
|
|
3502
3573
|
SecurityGroupIds: [groupId],
|
|
3503
|
-
UserData:
|
|
3574
|
+
UserData: Buffer.from(buildBoxUserData(spec), "utf8").toString("base64"),
|
|
3504
3575
|
TagSpecifications: [{ ResourceType: "instance", Tags: tags }]
|
|
3505
3576
|
});
|
|
3506
3577
|
const instanceId = result.Instances?.[0]?.InstanceId;
|
|
@@ -3717,16 +3788,19 @@ function buildSiteDeployScript(options) {
|
|
|
3717
3788
|
const paths = releasePaths(base, releaseId);
|
|
3718
3789
|
const unitBase = `${slug}-${siteName}`;
|
|
3719
3790
|
const serviceName = `${unitBase}.service`;
|
|
3791
|
+
const tarball = releaseTarballTmpPath(slug, siteName, releaseId);
|
|
3720
3792
|
const sharedPaths = [...new Set([".env", ...options.sharedPaths ?? []])];
|
|
3721
3793
|
const envFile = formatEnvFile(envEntries);
|
|
3722
3794
|
const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
|
|
3723
3795
|
const stageRelease = [
|
|
3724
3796
|
"set -euo pipefail",
|
|
3797
|
+
`trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
|
|
3725
3798
|
...artifactFetch,
|
|
3726
3799
|
...buildEnsureReleaseLayout(paths, sharedPaths),
|
|
3727
3800
|
`rm -rf ${paths.release}`,
|
|
3728
3801
|
`mkdir -p ${paths.release}`,
|
|
3729
|
-
`tar xzf
|
|
3802
|
+
`tar xzf ${tarball} -C ${paths.release}`,
|
|
3803
|
+
`rm -f ${tarball}`,
|
|
3730
3804
|
`cat > ${paths.shared}/.env <<'TS_CLOUD_ENV_EOF'`,
|
|
3731
3805
|
envFile,
|
|
3732
3806
|
"TS_CLOUD_ENV_EOF",
|
|
@@ -3768,7 +3842,7 @@ function buildSiteDeployScript(options) {
|
|
|
3768
3842
|
...buildActivateRelease(paths),
|
|
3769
3843
|
`systemctl enable ${instance} 2>/dev/null || true`,
|
|
3770
3844
|
`for TS_CLOUD_U in \${TS_CLOUD_OLD_UNITS}; do systemctl stop "$TS_CLOUD_U" 2>/dev/null || true; systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
|
|
3771
|
-
`systemctl list-unit-files --plain --no-legend "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v "^${instance}$" | while read -r TS_CLOUD_U; do systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
|
|
3845
|
+
`systemctl list-unit-files --plain --no-legend "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v -e "^${instance}$" -e "^${unitBase}@\\.service$" | while read -r TS_CLOUD_U; do systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
|
|
3772
3846
|
`if [ -f /etc/systemd/system/${serviceName} ]; then systemctl disable ${serviceName} 2>/dev/null || true; rm -f /etc/systemd/system/${serviceName}; systemctl daemon-reload; fi`,
|
|
3773
3847
|
...buildPruneReleases(paths, keepReleases)
|
|
3774
3848
|
];
|
|
@@ -3804,27 +3878,34 @@ function buildStaticSiteDeployScript(options) {
|
|
|
3804
3878
|
const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
|
|
3805
3879
|
const base = options.appDir ?? `/var/www/${siteName}`;
|
|
3806
3880
|
const paths = releasePaths(base, releaseId);
|
|
3881
|
+
const tarball = releaseTarballTmpPath(options.slug, siteName, releaseId);
|
|
3807
3882
|
const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
|
|
3808
3883
|
return [
|
|
3809
3884
|
"set -euo pipefail",
|
|
3885
|
+
`trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
|
|
3810
3886
|
...artifactFetch,
|
|
3811
3887
|
...buildEnsureReleaseLayout(paths, []),
|
|
3812
3888
|
`rm -rf ${paths.release}`,
|
|
3813
3889
|
`mkdir -p ${paths.release}`,
|
|
3814
|
-
`tar xzf
|
|
3890
|
+
`tar xzf ${tarball} -C ${paths.release}`,
|
|
3891
|
+
`rm -f ${tarball}`,
|
|
3815
3892
|
...preStart,
|
|
3816
3893
|
...buildActivateRelease(paths),
|
|
3817
3894
|
...buildPruneReleases(paths, keepReleases)
|
|
3818
3895
|
];
|
|
3819
3896
|
}
|
|
3820
|
-
function
|
|
3897
|
+
function releaseTarballTmpPath(slug, siteName, releaseId) {
|
|
3898
|
+
const parts = [slug, siteName, releaseId].filter(Boolean).join("-");
|
|
3899
|
+
return `/tmp/${parts}-release.tar.gz`;
|
|
3900
|
+
}
|
|
3901
|
+
function buildAwsArtifactFetch(bucket, key, region, destPath) {
|
|
3821
3902
|
return [
|
|
3822
|
-
`aws s3 cp "s3://${bucket}/${key}"
|
|
3903
|
+
`aws s3 cp "s3://${bucket}/${key}" ${destPath} --region ${region}`
|
|
3823
3904
|
];
|
|
3824
3905
|
}
|
|
3825
|
-
function buildLocalArtifactFetch(localPath,
|
|
3906
|
+
function buildLocalArtifactFetch(localPath, destPath) {
|
|
3826
3907
|
return [
|
|
3827
|
-
`cp "${localPath}"
|
|
3908
|
+
`cp "${localPath}" ${destPath}`
|
|
3828
3909
|
];
|
|
3829
3910
|
}
|
|
3830
3911
|
// src/drivers/shared/compute-deploy.ts
|
|
@@ -4088,7 +4169,7 @@ function buildCertbotInstallScript(dns) {
|
|
|
4088
4169
|
"mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
|
|
4089
4170
|
"cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
|
|
4090
4171
|
"#!/bin/sh",
|
|
4091
|
-
"systemctl reload nginx",
|
|
4172
|
+
"systemctl reload ts-cloud-nginx 2>/dev/null || systemctl reload nginx 2>/dev/null || true",
|
|
4092
4173
|
"TS_CLOUD_HOOK_EOF",
|
|
4093
4174
|
"chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh",
|
|
4094
4175
|
"systemctl enable certbot.timer 2>/dev/null || true",
|
|
@@ -4121,7 +4202,7 @@ function buildCertbotIssueScript(options) {
|
|
|
4121
4202
|
args.push(`--${plugin}-propagation-seconds ${dns.propagationSeconds}`);
|
|
4122
4203
|
args.push("certonly");
|
|
4123
4204
|
} else {
|
|
4124
|
-
args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
|
|
4205
|
+
args.push("--nginx", `--nginx-ctl ${NGINX_WRAPPER}`, options.redirect === false ? "--no-redirect" : "--redirect");
|
|
4125
4206
|
}
|
|
4126
4207
|
if (options.email)
|
|
4127
4208
|
args.push(`-m ${options.email}`);
|
|
@@ -4165,16 +4246,16 @@ function buildGitCheckoutScript(options) {
|
|
|
4165
4246
|
];
|
|
4166
4247
|
if (repository.strategy === "tag") {
|
|
4167
4248
|
if (repository.tag) {
|
|
4168
|
-
lines.push(`git clone -q --depth 1 --branch ${repository.tag} ${url} ${releaseDir}`);
|
|
4249
|
+
lines.push(`git clone -q --depth 1 --branch ${shellQuote(repository.tag)} ${shellQuote(url)} ${releaseDir}`);
|
|
4169
4250
|
lines.push(`printf '%s' ${shellQuote(repository.tag)} > ${releaseDir}/.ts-cloud-tag`);
|
|
4170
4251
|
} else {
|
|
4171
4252
|
const pattern = repository.tagPattern || DEFAULT_TAG_PATTERN;
|
|
4172
|
-
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}
|
|
4253
|
+
lines.push(`TS_CLOUD_TAG="$(git ls-remote --tags --refs --sort=-v:refname ${shellQuote(url)} ${shellQuote(`refs/tags/${pattern}`)} | head -n1 | sed 's#.*refs/tags/##')"`, `test -n "$TS_CLOUD_TAG" || { echo "no tags matching" ${shellQuote(pattern)} "found in" ${shellQuote(url)} >&2; exit 1; }`, `git clone -q --depth 1 --branch "$TS_CLOUD_TAG" ${shellQuote(url)} ${releaseDir}`, `printf '%s' "$TS_CLOUD_TAG" > ${releaseDir}/.ts-cloud-tag`);
|
|
4173
4254
|
}
|
|
4174
4255
|
} else if (commit) {
|
|
4175
|
-
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`);
|
|
4256
|
+
lines.push(`git -C ${releaseDir} init -q`, `git -C ${releaseDir} remote add origin ${shellQuote(url)}`, `git -C ${releaseDir} fetch -q --depth 1 origin ${shellQuote(commit)}`, `git -C ${releaseDir} checkout -q FETCH_HEAD`);
|
|
4176
4257
|
} else {
|
|
4177
|
-
lines.push(`git clone -q --depth 1 --branch ${branch} ${url} ${releaseDir}`);
|
|
4258
|
+
lines.push(`git clone -q --depth 1 --branch ${shellQuote(branch)} ${shellQuote(url)} ${releaseDir}`);
|
|
4178
4259
|
}
|
|
4179
4260
|
lines.push(`git -C ${releaseDir} rev-parse HEAD > ${releaseDir}/.ts-cloud-sha`);
|
|
4180
4261
|
return lines;
|
|
@@ -4590,12 +4671,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4590
4671
|
const hint = driver.name === "aws" ? `Stack '${stackName}' has no EC2 instances tagged Project=${slug} Environment=${environment} Role=app, and storage/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 storage/cloud/state/${stackName}.json pins no live server. For a shared box, record its serverId there.`;
|
|
4591
4672
|
return { success: false, error: hint };
|
|
4592
4673
|
}
|
|
4674
|
+
const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, resolveAppDatabase(config)) : buildManagedDbEnv(resolveAppDatabase(config));
|
|
4675
|
+
const envWithServices = Object.keys(dbEnv).length > 0 ? { ...dbEnv, ...site.env || {} } : site.env || {};
|
|
4593
4676
|
if (isPhpSite(site)) {
|
|
4594
4677
|
const compute2 = config.infrastructure?.compute;
|
|
4595
4678
|
const phpVersion = site.phpVersion ?? compute2?.php?.default ?? compute2?.php?.versions?.[0];
|
|
4596
4679
|
const appBase2 = siteInstallBase(slug, siteName);
|
|
4597
|
-
const
|
|
4598
|
-
const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: { ...dbEnv, ...site.env || {} } } : site;
|
|
4680
|
+
const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: envWithServices } : site;
|
|
4599
4681
|
const deployScript = buildLaravelDeployScript({
|
|
4600
4682
|
siteName,
|
|
4601
4683
|
site: siteWithEnv,
|
|
@@ -4662,11 +4744,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4662
4744
|
remoteKey,
|
|
4663
4745
|
targets
|
|
4664
4746
|
});
|
|
4665
|
-
const
|
|
4747
|
+
const tarballPath = releaseTarballTmpPath(slug, siteName, sha);
|
|
4748
|
+
const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", tarballPath) : buildLocalArtifactFetch(uploadResult.artifactRef, tarballPath);
|
|
4666
4749
|
const kind = resolveSiteKind(site);
|
|
4667
4750
|
const appBase = siteInstallBase(slug, siteName);
|
|
4668
4751
|
const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
|
|
4669
4752
|
siteName,
|
|
4753
|
+
slug,
|
|
4670
4754
|
appDir: appBase,
|
|
4671
4755
|
artifactFetch,
|
|
4672
4756
|
releaseId: sha,
|
|
@@ -4678,7 +4762,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4678
4762
|
artifactFetch,
|
|
4679
4763
|
releaseId: sha,
|
|
4680
4764
|
execStart: resolveExecStart(site.start, runtime),
|
|
4681
|
-
envEntries:
|
|
4765
|
+
envEntries: envWithServices,
|
|
4682
4766
|
port: site.port,
|
|
4683
4767
|
preStartCommands: site.preStart,
|
|
4684
4768
|
sharedPaths: site.sharedPaths,
|
|
@@ -4708,7 +4792,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4708
4792
|
...buildDeployHistoryHeader(appBase, {
|
|
4709
4793
|
releaseId: sha,
|
|
4710
4794
|
commit: sha,
|
|
4711
|
-
branch: site.branch ?? "main"
|
|
4795
|
+
branch: site.repository?.branch ?? "main"
|
|
4712
4796
|
}),
|
|
4713
4797
|
...buildSiteOwnerGuard(appBase, slug),
|
|
4714
4798
|
...baseScript,
|
|
@@ -4741,6 +4825,44 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4741
4825
|
perInstance: result.perInstance
|
|
4742
4826
|
};
|
|
4743
4827
|
}
|
|
4828
|
+
async function ensureAttachModeDatabase(driver, options, logger) {
|
|
4829
|
+
const { config, environment } = options;
|
|
4830
|
+
if (!config.cloud?.attachTo)
|
|
4831
|
+
return true;
|
|
4832
|
+
const compute = config.infrastructure?.compute;
|
|
4833
|
+
const database = resolveAppDatabase(config);
|
|
4834
|
+
const commands = buildDatabaseSetupScript(database, compute?.managedServices ?? {});
|
|
4835
|
+
if (commands.length === 0)
|
|
4836
|
+
return true;
|
|
4837
|
+
const slug = config.project.slug;
|
|
4838
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
4839
|
+
const targets = await driver.findComputeTargets({
|
|
4840
|
+
slug,
|
|
4841
|
+
environment,
|
|
4842
|
+
role: "app",
|
|
4843
|
+
stackName
|
|
4844
|
+
});
|
|
4845
|
+
if (targets.length === 0) {
|
|
4846
|
+
return true;
|
|
4847
|
+
}
|
|
4848
|
+
logger.step(`Ensuring database '${database?.name}' + role exist on the shared box...`);
|
|
4849
|
+
const result = await driver.runRemoteDeploy({
|
|
4850
|
+
targets,
|
|
4851
|
+
commands,
|
|
4852
|
+
comment: `ts-cloud ensure database ${slug}/${database?.name}`,
|
|
4853
|
+
tags: {
|
|
4854
|
+
Project: slug,
|
|
4855
|
+
Environment: environment,
|
|
4856
|
+
Role: "app"
|
|
4857
|
+
}
|
|
4858
|
+
});
|
|
4859
|
+
if (!result.success) {
|
|
4860
|
+
logger.error(`Ensuring database '${database?.name}' failed: ${result.error || "unknown error"}`);
|
|
4861
|
+
return false;
|
|
4862
|
+
}
|
|
4863
|
+
logger.success(`Database '${database?.name}' is ready on ${result.instanceCount} target(s)`);
|
|
4864
|
+
return true;
|
|
4865
|
+
}
|
|
4744
4866
|
async function deployAllComputeSites(options) {
|
|
4745
4867
|
const { config, environment, driver, sha, runtime, tarballForSite, logger = noopLogger2 } = options;
|
|
4746
4868
|
const slug = config.project.slug;
|
|
@@ -4791,6 +4913,8 @@ async function deployAllComputeSites(options) {
|
|
|
4791
4913
|
});
|
|
4792
4914
|
if (deployable.length === 0)
|
|
4793
4915
|
return true;
|
|
4916
|
+
if (!await ensureAttachModeDatabase(driver, options, logger))
|
|
4917
|
+
return false;
|
|
4794
4918
|
const tarballFor = (siteName) => dashboardTarballBySite.get(siteName) ?? tarballForSite(siteName);
|
|
4795
4919
|
for (const [siteName, site] of deployable) {
|
|
4796
4920
|
logger.step(`Deploying site: ${siteName}`);
|
|
@@ -4833,29 +4957,68 @@ async function reloadRpxGateway(options) {
|
|
|
4833
4957
|
if (proxy?.engine !== "rpx")
|
|
4834
4958
|
return true;
|
|
4835
4959
|
const sites = routeSource.sites || {};
|
|
4836
|
-
const
|
|
4960
|
+
const slug = config.project.slug;
|
|
4961
|
+
const stackName = resolveProjectStackName(config, environment);
|
|
4962
|
+
const lbTargets = await driver.findComputeTargets({
|
|
4963
|
+
slug,
|
|
4964
|
+
environment,
|
|
4965
|
+
role: "lb",
|
|
4966
|
+
stackName
|
|
4967
|
+
});
|
|
4968
|
+
if (lbTargets.length > 0) {
|
|
4969
|
+
const appTargets = await driver.findComputeTargets({
|
|
4970
|
+
slug,
|
|
4971
|
+
environment,
|
|
4972
|
+
role: "app",
|
|
4973
|
+
stackName
|
|
4974
|
+
});
|
|
4975
|
+
const appBoxes = appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp }));
|
|
4976
|
+
const lbConfig = buildRpxLbConfig(sites, appBoxes, { proxy, slug });
|
|
4977
|
+
if (lbConfig.proxies.length === 0) {
|
|
4978
|
+
logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
|
|
4979
|
+
return true;
|
|
4980
|
+
}
|
|
4981
|
+
logger.step(`Reloading the load balancer's rpx gateway with ${lbConfig.proxies.length} route(s)...`);
|
|
4982
|
+
const result2 = await driver.runRemoteDeploy({
|
|
4983
|
+
targets: lbTargets,
|
|
4984
|
+
commands: buildRpxFragmentRefreshScript({ config: lbConfig, slug }),
|
|
4985
|
+
comment: `ts-cloud rpx gateway reload ${slug}`,
|
|
4986
|
+
tags: {
|
|
4987
|
+
Project: slug,
|
|
4988
|
+
Environment: environment,
|
|
4989
|
+
Role: "lb"
|
|
4990
|
+
}
|
|
4991
|
+
});
|
|
4992
|
+
if (!result2.success) {
|
|
4993
|
+
logger.error(`rpx gateway reload failed: ${result2.error || "unknown error"}`);
|
|
4994
|
+
return false;
|
|
4995
|
+
}
|
|
4996
|
+
logger.success(`rpx gateway reloaded on ${result2.instanceCount} load balancer(s)`);
|
|
4997
|
+
return true;
|
|
4998
|
+
}
|
|
4999
|
+
const rpxConfig = buildRpxConfig(sites, { proxy, slug });
|
|
4837
5000
|
if (rpxConfig.proxies.length === 0) {
|
|
4838
5001
|
logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
|
|
4839
5002
|
return true;
|
|
4840
5003
|
}
|
|
4841
5004
|
const targets = await driver.findComputeTargets({
|
|
4842
|
-
slug
|
|
5005
|
+
slug,
|
|
4843
5006
|
environment,
|
|
4844
5007
|
role: "app",
|
|
4845
|
-
stackName
|
|
5008
|
+
stackName
|
|
4846
5009
|
});
|
|
4847
5010
|
if (targets.length === 0) {
|
|
4848
5011
|
logger.warn("rpx gateway: no compute targets found — skipping gateway reload.");
|
|
4849
5012
|
return true;
|
|
4850
5013
|
}
|
|
4851
5014
|
logger.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
|
|
4852
|
-
const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug
|
|
5015
|
+
const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug });
|
|
4853
5016
|
const result = await driver.runRemoteDeploy({
|
|
4854
5017
|
targets,
|
|
4855
5018
|
commands: script,
|
|
4856
|
-
comment: `ts-cloud rpx gateway reload ${
|
|
5019
|
+
comment: `ts-cloud rpx gateway reload ${slug}`,
|
|
4857
5020
|
tags: {
|
|
4858
|
-
Project:
|
|
5021
|
+
Project: slug,
|
|
4859
5022
|
Environment: environment,
|
|
4860
5023
|
Role: "app"
|
|
4861
5024
|
}
|
|
@@ -4947,4 +5110,4 @@ function buildCloudFrontOriginConfig(options) {
|
|
|
4947
5110
|
CustomErrorResponses: { Quantity: 0 }
|
|
4948
5111
|
};
|
|
4949
5112
|
}
|
|
4950
|
-
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, 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 };
|
|
5113
|
+
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, pgAdminCommand, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, buildRpxFragmentRefreshScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, releaseTarballTmpPath, 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 };
|