@stacksjs/ts-cloud 0.2.20 → 0.2.22
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 +430 -407
- package/dist/deploy/index.d.ts +1 -0
- package/dist/deploy/site-target.d.ts +41 -0
- package/dist/drivers/hetzner/client.d.ts +8 -1
- package/dist/drivers/hetzner/cloud-init.d.ts +7 -0
- package/dist/drivers/hetzner/driver.d.ts +56 -0
- package/dist/drivers/index.d.ts +2 -2
- package/dist/drivers/shared/caddyfile.d.ts +41 -1
- package/dist/drivers/shared/compute-deploy.d.ts +3 -1
- package/dist/drivers/shared/deploy-script.d.ts +27 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +445 -62
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -80931,6 +80931,77 @@ class AcmeClient {
|
|
|
80931
80931
|
return this.accountKey;
|
|
80932
80932
|
}
|
|
80933
80933
|
}
|
|
80934
|
+
// src/deploy/site-target.ts
|
|
80935
|
+
function resolveSiteDeployTarget(site) {
|
|
80936
|
+
if (site.deploy)
|
|
80937
|
+
return site.deploy;
|
|
80938
|
+
if (site.start)
|
|
80939
|
+
return "server";
|
|
80940
|
+
return "bucket";
|
|
80941
|
+
}
|
|
80942
|
+
function resolveSiteKind(site) {
|
|
80943
|
+
const target = resolveSiteDeployTarget(site);
|
|
80944
|
+
if (target === "bucket")
|
|
80945
|
+
return "bucket";
|
|
80946
|
+
return site.start ? "server-app" : "server-static";
|
|
80947
|
+
}
|
|
80948
|
+
function hasComputeConfigured(config6) {
|
|
80949
|
+
return config6.infrastructure?.compute != null;
|
|
80950
|
+
}
|
|
80951
|
+
function validateDeploymentConfig(config6) {
|
|
80952
|
+
const errors = [];
|
|
80953
|
+
const warnings = [];
|
|
80954
|
+
const sites = config6.sites || {};
|
|
80955
|
+
const computeConfigured = hasComputeConfigured(config6);
|
|
80956
|
+
const portOwners = new Map;
|
|
80957
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
80958
|
+
if (!site) {
|
|
80959
|
+
continue;
|
|
80960
|
+
}
|
|
80961
|
+
const target = resolveSiteDeployTarget(site);
|
|
80962
|
+
const kind = resolveSiteKind(site);
|
|
80963
|
+
if (target === "server" && !site.start && !site.root) {
|
|
80964
|
+
errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
|
|
80965
|
+
continue;
|
|
80966
|
+
}
|
|
80967
|
+
if (kind === "server-app") {
|
|
80968
|
+
if (!computeConfigured) {
|
|
80969
|
+
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).`);
|
|
80970
|
+
}
|
|
80971
|
+
if (typeof site.port === "number") {
|
|
80972
|
+
const existing = portOwners.get(site.port);
|
|
80973
|
+
if (existing) {
|
|
80974
|
+
errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
|
|
80975
|
+
} else {
|
|
80976
|
+
portOwners.set(site.port, name);
|
|
80977
|
+
}
|
|
80978
|
+
}
|
|
80979
|
+
} else if (kind === "server-static") {
|
|
80980
|
+
if (!site.root) {
|
|
80981
|
+
errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
|
|
80982
|
+
}
|
|
80983
|
+
if (!computeConfigured) {
|
|
80984
|
+
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).`);
|
|
80985
|
+
}
|
|
80986
|
+
} else {
|
|
80987
|
+
if (!site.root) {
|
|
80988
|
+
errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
|
|
80989
|
+
}
|
|
80990
|
+
const serverOnly = [];
|
|
80991
|
+
if (site.start)
|
|
80992
|
+
serverOnly.push("start");
|
|
80993
|
+
if (typeof site.port === "number")
|
|
80994
|
+
serverOnly.push("port");
|
|
80995
|
+
if (site.preStart && site.preStart.length > 0)
|
|
80996
|
+
serverOnly.push("preStart");
|
|
80997
|
+
if (serverOnly.length > 0) {
|
|
80998
|
+
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.`);
|
|
80999
|
+
}
|
|
81000
|
+
}
|
|
81001
|
+
}
|
|
81002
|
+
return { errors, warnings };
|
|
81003
|
+
}
|
|
81004
|
+
|
|
80934
81005
|
// src/deploy/index.ts
|
|
80935
81006
|
init_static_site();
|
|
80936
81007
|
init_static_site_external_dns();
|
|
@@ -81159,46 +81230,202 @@ import { join as join13 } from "node:path";
|
|
|
81159
81230
|
import { execSync } from "node:child_process";
|
|
81160
81231
|
|
|
81161
81232
|
// src/drivers/shared/caddyfile.ts
|
|
81162
|
-
function
|
|
81163
|
-
|
|
81164
|
-
|
|
81165
|
-
|
|
81233
|
+
function isOnDemandDomain(domain) {
|
|
81234
|
+
return domain === "*" || domain.includes("*");
|
|
81235
|
+
}
|
|
81236
|
+
function isStaticApp(app) {
|
|
81237
|
+
return typeof app.root === "string" && app.root.length > 0;
|
|
81238
|
+
}
|
|
81239
|
+
function isProxyApp(app) {
|
|
81240
|
+
return typeof app.port === "number";
|
|
81241
|
+
}
|
|
81242
|
+
function staticSiteServerRoot(name) {
|
|
81243
|
+
return `/var/www/${name}`;
|
|
81244
|
+
}
|
|
81245
|
+
function normalizeOnDemandTls(onDemandTls) {
|
|
81246
|
+
if (!onDemandTls)
|
|
81247
|
+
return;
|
|
81248
|
+
if (onDemandTls === true)
|
|
81249
|
+
return {};
|
|
81250
|
+
return onDemandTls;
|
|
81251
|
+
}
|
|
81252
|
+
function wrapInHandle(body, path, indent) {
|
|
81253
|
+
const inner = body.split(`
|
|
81254
|
+
`).map((line) => ` ${line}`).join(`
|
|
81255
|
+
`);
|
|
81256
|
+
return `${indent}handle ${path} {
|
|
81257
|
+
${inner}
|
|
81258
|
+
${indent}}`;
|
|
81259
|
+
}
|
|
81260
|
+
function renderUpstreamBlock(app, indent) {
|
|
81261
|
+
const host = app.upstreamHost || "localhost";
|
|
81262
|
+
const upstream = `${host}:${app.port}`;
|
|
81263
|
+
const directives = app.reverseProxyDirectives ?? [];
|
|
81264
|
+
const proxyLine = directives.length === 0 ? `${indent}reverse_proxy ${upstream}` : [
|
|
81265
|
+
`${indent}reverse_proxy ${upstream} {`,
|
|
81266
|
+
...directives.map((d) => `${indent} ${d}`),
|
|
81267
|
+
`${indent}}`
|
|
81268
|
+
].join(`
|
|
81269
|
+
`);
|
|
81270
|
+
const isCatchAll = !app.path || app.path === "/";
|
|
81271
|
+
if (isCatchAll)
|
|
81272
|
+
return proxyLine;
|
|
81273
|
+
return wrapInHandle(proxyLine, app.path, indent);
|
|
81274
|
+
}
|
|
81275
|
+
function renderStaticBlock(app, indent) {
|
|
81276
|
+
const root = app.root;
|
|
81277
|
+
const lines = [`${indent}root * ${root}`];
|
|
81278
|
+
if (app.cache?.enabled) {
|
|
81279
|
+
const maxAge = app.cache.maxAge ?? 3600;
|
|
81280
|
+
lines.push(`${indent}header Cache-Control "public, max-age=${maxAge}"`);
|
|
81281
|
+
}
|
|
81282
|
+
if (app.spa) {
|
|
81283
|
+
lines.push(`${indent}try_files {path} /index.html`);
|
|
81284
|
+
} else if (app.pathRewriteStyle === "flat") {
|
|
81285
|
+
lines.push(`${indent}try_files {path} {path}.html {path}/index.html`);
|
|
81286
|
+
} else {
|
|
81287
|
+
lines.push(`${indent}try_files {path} {path}/index.html {path}.html`);
|
|
81288
|
+
}
|
|
81289
|
+
lines.push(`${indent}file_server`);
|
|
81290
|
+
const body = lines.join(`
|
|
81291
|
+
`);
|
|
81292
|
+
const isCatchAll = !app.path || app.path === "/";
|
|
81293
|
+
if (isCatchAll)
|
|
81294
|
+
return body;
|
|
81295
|
+
return wrapInHandle(body, app.path, indent);
|
|
81296
|
+
}
|
|
81297
|
+
function renderAppBlock(app, indent) {
|
|
81298
|
+
return isStaticApp(app) ? renderStaticBlock(app, indent) : renderUpstreamBlock(app, indent);
|
|
81299
|
+
}
|
|
81300
|
+
function groupAppsByDomains(apps) {
|
|
81301
|
+
const groups = new Map;
|
|
81302
|
+
for (const app of apps) {
|
|
81303
|
+
const domains = [...app.domains].filter(Boolean);
|
|
81304
|
+
if (domains.length === 0)
|
|
81305
|
+
continue;
|
|
81306
|
+
const key = [...domains].sort().join(" ");
|
|
81307
|
+
const group = groups.get(key) ?? { domains, apps: [] };
|
|
81308
|
+
group.apps.push(app);
|
|
81309
|
+
groups.set(key, group);
|
|
81310
|
+
}
|
|
81311
|
+
return [...groups.values()];
|
|
81312
|
+
}
|
|
81313
|
+
function sortAppsByPath(apps) {
|
|
81314
|
+
return [...apps].sort((a, b) => {
|
|
81315
|
+
const aCatchAll = !a.path || a.path === "/";
|
|
81316
|
+
const bCatchAll = !b.path || b.path === "/";
|
|
81317
|
+
if (aCatchAll && !bCatchAll)
|
|
81318
|
+
return 1;
|
|
81319
|
+
if (!aCatchAll && bCatchAll)
|
|
81320
|
+
return -1;
|
|
81321
|
+
return (b.path?.length ?? 0) - (a.path?.length ?? 0);
|
|
81322
|
+
});
|
|
81323
|
+
}
|
|
81324
|
+
function buildCaddyfileFromProxy(proxy) {
|
|
81325
|
+
if (proxy.raw && proxy.raw.trim())
|
|
81326
|
+
return proxy.raw.trim();
|
|
81327
|
+
const apps = (proxy.apps ?? []).filter((app) => app.domains.length > 0 && (isProxyApp(app) || isStaticApp(app)));
|
|
81328
|
+
if (apps.length === 0)
|
|
81166
81329
|
return;
|
|
81167
|
-
const
|
|
81168
|
-
|
|
81169
|
-
|
|
81170
|
-
|
|
81171
|
-
|
|
81330
|
+
const onDemand = normalizeOnDemandTls(proxy.onDemandTls);
|
|
81331
|
+
const hasOnDemandDomain = apps.some((app) => app.domains.some(isOnDemandDomain));
|
|
81332
|
+
const globalLines = [];
|
|
81333
|
+
if (proxy.email)
|
|
81334
|
+
globalLines.push(`email ${proxy.email}`);
|
|
81335
|
+
if (proxy.staging)
|
|
81336
|
+
globalLines.push("acme_ca https://acme-staging-v02.api.letsencrypt.org/directory");
|
|
81337
|
+
if (onDemand) {
|
|
81338
|
+
if (onDemand.ask || onDemand.interval || onDemand.burst != null) {
|
|
81339
|
+
const inner = [];
|
|
81340
|
+
if (onDemand.ask)
|
|
81341
|
+
inner.push(` ask ${onDemand.ask}`);
|
|
81342
|
+
if (onDemand.interval || onDemand.burst != null) {
|
|
81343
|
+
const rl = [];
|
|
81344
|
+
if (onDemand.interval)
|
|
81345
|
+
rl.push(` interval ${onDemand.interval}`);
|
|
81346
|
+
if (onDemand.burst != null)
|
|
81347
|
+
rl.push(` burst ${onDemand.burst}`);
|
|
81348
|
+
inner.push(` rate_limit {
|
|
81349
|
+
${rl.join(`
|
|
81350
|
+
`)}
|
|
81351
|
+
}`);
|
|
81352
|
+
}
|
|
81353
|
+
globalLines.push(`on_demand_tls {
|
|
81354
|
+
${inner.join(`
|
|
81355
|
+
`)}
|
|
81356
|
+
}`);
|
|
81357
|
+
} else {
|
|
81358
|
+
globalLines.push(`on_demand_tls {
|
|
81359
|
+
}`);
|
|
81360
|
+
}
|
|
81172
81361
|
}
|
|
81362
|
+
for (const directive of proxy.globalDirectives ?? [])
|
|
81363
|
+
globalLines.push(directive);
|
|
81173
81364
|
const blocks = [];
|
|
81174
|
-
|
|
81175
|
-
|
|
81176
|
-
|
|
81177
|
-
const bIsCatchAll = !b.path || b.path === "/";
|
|
81178
|
-
if (aIsCatchAll && !bIsCatchAll)
|
|
81179
|
-
return 1;
|
|
81180
|
-
if (!aIsCatchAll && bIsCatchAll)
|
|
81181
|
-
return -1;
|
|
81182
|
-
return (b.path?.length ?? 0) - (a.path?.length ?? 0);
|
|
81183
|
-
});
|
|
81184
|
-
const handles = sorted.map((s) => {
|
|
81185
|
-
const isCatchAll = !s.path || s.path === "/";
|
|
81186
|
-
const inner = `reverse_proxy localhost:${s.port}`;
|
|
81187
|
-
return isCatchAll ? ` handle {
|
|
81188
|
-
${inner}
|
|
81189
|
-
}` : ` handle ${s.path} {
|
|
81190
|
-
${inner}
|
|
81191
|
-
}`;
|
|
81192
|
-
});
|
|
81193
|
-
blocks.push(`${domain} {
|
|
81194
|
-
${handles.join(`
|
|
81365
|
+
if (globalLines.length > 0)
|
|
81366
|
+
blocks.push(`{
|
|
81367
|
+
${globalLines.map((line) => ` ${line}`).join(`
|
|
81195
81368
|
`)}
|
|
81196
81369
|
}`);
|
|
81370
|
+
for (const group of groupAppsByDomains(apps)) {
|
|
81371
|
+
const sorted = sortAppsByPath(group.apps);
|
|
81372
|
+
const body = sorted.map((app) => renderAppBlock(app, " ")).join(`
|
|
81373
|
+
`);
|
|
81374
|
+
const needsOnDemand = onDemand && group.domains.some(isOnDemandDomain);
|
|
81375
|
+
const tlsBlock = needsOnDemand ? `
|
|
81376
|
+
tls {
|
|
81377
|
+
on_demand
|
|
81378
|
+
}` : "";
|
|
81379
|
+
blocks.push(`${group.domains.join(", ")} {
|
|
81380
|
+
${body}${tlsBlock}
|
|
81381
|
+
}`);
|
|
81382
|
+
}
|
|
81383
|
+
if (hasOnDemandDomain && !onDemand) {
|
|
81384
|
+
blocks.unshift(`# WARNING: wildcard/catch-all domain present but on_demand_tls is not enabled.
|
|
81385
|
+
` + "# Caddy cannot provision TLS for these hosts. Set compute.proxy.onDemandTls.");
|
|
81197
81386
|
}
|
|
81198
81387
|
return blocks.join(`
|
|
81199
81388
|
|
|
81200
81389
|
`);
|
|
81201
81390
|
}
|
|
81391
|
+
function proxyConfigFromSites(sites) {
|
|
81392
|
+
const apps = [];
|
|
81393
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
81394
|
+
if (typeof site.domain !== "string" || !site.domain)
|
|
81395
|
+
continue;
|
|
81396
|
+
if (typeof site.port === "number" && site.deploy !== "bucket") {
|
|
81397
|
+
apps.push({
|
|
81398
|
+
name,
|
|
81399
|
+
domains: [site.domain],
|
|
81400
|
+
port: site.port,
|
|
81401
|
+
path: site.path
|
|
81402
|
+
});
|
|
81403
|
+
} else if (resolveSiteKind(site) === "server-static") {
|
|
81404
|
+
apps.push({
|
|
81405
|
+
name,
|
|
81406
|
+
domains: [site.domain],
|
|
81407
|
+
root: staticSiteServerRoot(name),
|
|
81408
|
+
path: site.path,
|
|
81409
|
+
spa: site.spa,
|
|
81410
|
+
pathRewriteStyle: site.pathRewriteStyle,
|
|
81411
|
+
cache: site.cache
|
|
81412
|
+
});
|
|
81413
|
+
}
|
|
81414
|
+
}
|
|
81415
|
+
return { apps };
|
|
81416
|
+
}
|
|
81417
|
+
function resolveCaddyfile(sites, proxy) {
|
|
81418
|
+
if (proxy) {
|
|
81419
|
+
if (proxy.raw && proxy.raw.trim())
|
|
81420
|
+
return proxy.raw.trim();
|
|
81421
|
+
const resolved = proxy.apps && proxy.apps.length > 0 ? proxy : { ...proxy, apps: proxyConfigFromSites(sites).apps };
|
|
81422
|
+
return buildCaddyfileFromProxy(resolved);
|
|
81423
|
+
}
|
|
81424
|
+
return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
|
|
81425
|
+
}
|
|
81426
|
+
function buildCaddyfile(sites) {
|
|
81427
|
+
return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
|
|
81428
|
+
}
|
|
81202
81429
|
|
|
81203
81430
|
// src/drivers/hetzner/client.ts
|
|
81204
81431
|
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
@@ -81223,10 +81450,20 @@ class HetznerClient {
|
|
|
81223
81450
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
81224
81451
|
});
|
|
81225
81452
|
const text = await response.text();
|
|
81226
|
-
|
|
81453
|
+
let data;
|
|
81454
|
+
try {
|
|
81455
|
+
data = text ? JSON.parse(text) : {};
|
|
81456
|
+
} catch {
|
|
81457
|
+
if (!response.ok) {
|
|
81458
|
+
const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
|
|
81459
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
|
|
81460
|
+
}
|
|
81461
|
+
throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
|
|
81462
|
+
}
|
|
81227
81463
|
if (!response.ok) {
|
|
81228
81464
|
const message = data.error?.message || response.statusText || "Hetzner API error";
|
|
81229
|
-
|
|
81465
|
+
const code = data.error?.code ? ` [${data.error.code}]` : "";
|
|
81466
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
|
|
81230
81467
|
}
|
|
81231
81468
|
return data;
|
|
81232
81469
|
}
|
|
@@ -81270,6 +81507,12 @@ class HetznerClient {
|
|
|
81270
81507
|
});
|
|
81271
81508
|
return { firewall: data.firewall, actions: data.actions };
|
|
81272
81509
|
}
|
|
81510
|
+
async setFirewallRules(firewallId, rules) {
|
|
81511
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
|
|
81512
|
+
rules
|
|
81513
|
+
});
|
|
81514
|
+
return data.actions ?? [];
|
|
81515
|
+
}
|
|
81273
81516
|
async applyFirewallToResources(firewallId, applyTo) {
|
|
81274
81517
|
const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
81275
81518
|
apply_to: applyTo
|
|
@@ -81435,12 +81678,19 @@ echo "ts-cloud bootstrap complete — instance is ready for site deploys"
|
|
|
81435
81678
|
return script;
|
|
81436
81679
|
}
|
|
81437
81680
|
function wrapCloudInitUserData(bootstrapScript) {
|
|
81681
|
+
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
81682
|
+
const indented = bootstrapScript.split(`
|
|
81683
|
+
`).map((line) => ` ${line}`).join(`
|
|
81684
|
+
`);
|
|
81438
81685
|
return `#cloud-config
|
|
81686
|
+
write_files:
|
|
81687
|
+
- path: ${scriptPath}
|
|
81688
|
+
permissions: '0755'
|
|
81689
|
+
owner: root:root
|
|
81690
|
+
content: |
|
|
81691
|
+
${indented}
|
|
81439
81692
|
runcmd:
|
|
81440
|
-
-
|
|
81441
|
-
${bootstrapScript.split(`
|
|
81442
|
-
`).join(`
|
|
81443
|
-
`)}
|
|
81693
|
+
- [ bash, ${scriptPath} ]
|
|
81444
81694
|
`;
|
|
81445
81695
|
}
|
|
81446
81696
|
|
|
@@ -81464,10 +81714,10 @@ function buildHetznerFirewallRules(config6) {
|
|
|
81464
81714
|
// src/drivers/hetzner/instance-sizes.ts
|
|
81465
81715
|
var HETZNER_INSTANCE_TYPES = {
|
|
81466
81716
|
micro: "cpx11",
|
|
81467
|
-
small: "
|
|
81468
|
-
medium: "
|
|
81469
|
-
large: "
|
|
81470
|
-
xlarge: "
|
|
81717
|
+
small: "cx23",
|
|
81718
|
+
medium: "cx33",
|
|
81719
|
+
large: "cx43",
|
|
81720
|
+
xlarge: "cx53",
|
|
81471
81721
|
"2xlarge": "ccx33"
|
|
81472
81722
|
};
|
|
81473
81723
|
function resolveHetznerServerType(size) {
|
|
@@ -81516,6 +81766,7 @@ async function writeDriverState(stackName, state) {
|
|
|
81516
81766
|
}
|
|
81517
81767
|
|
|
81518
81768
|
// src/drivers/hetzner/driver.ts
|
|
81769
|
+
var SSH_MAX_BUFFER = 1024 * 1024 * 256;
|
|
81519
81770
|
function expandHome(path) {
|
|
81520
81771
|
return path.startsWith("~/") ? join13(homedir7(), path.slice(2)) : path;
|
|
81521
81772
|
}
|
|
@@ -81528,6 +81779,8 @@ class HetznerDriver {
|
|
|
81528
81779
|
sshPublicKeyPath;
|
|
81529
81780
|
sshUser;
|
|
81530
81781
|
location;
|
|
81782
|
+
waitForBoot;
|
|
81783
|
+
bootWait;
|
|
81531
81784
|
constructor(options = {}) {
|
|
81532
81785
|
this.client = options.client ?? new HetznerClient({
|
|
81533
81786
|
apiToken: resolveHetznerApiToken(options.apiToken)
|
|
@@ -81536,6 +81789,13 @@ class HetznerDriver {
|
|
|
81536
81789
|
this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
|
|
81537
81790
|
this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
|
|
81538
81791
|
this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
|
|
81792
|
+
this.waitForBoot = options.waitForBoot ?? true;
|
|
81793
|
+
this.bootWait = {
|
|
81794
|
+
sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
|
|
81795
|
+
sshTimeoutMs: options.bootWait?.sshTimeoutMs ?? 300000,
|
|
81796
|
+
cloudInitIntervalMs: options.bootWait?.cloudInitIntervalMs ?? 5000,
|
|
81797
|
+
cloudInitTimeoutMs: options.bootWait?.cloudInitTimeoutMs ?? 600000
|
|
81798
|
+
};
|
|
81539
81799
|
}
|
|
81540
81800
|
async provisionComputeInfrastructure(options) {
|
|
81541
81801
|
const { config: config6, environment } = options;
|
|
@@ -81545,16 +81805,32 @@ class HetznerDriver {
|
|
|
81545
81805
|
throw new Error("infrastructure.compute is required to provision Hetzner compute");
|
|
81546
81806
|
}
|
|
81547
81807
|
const stackName = resolveProjectStackName(config6, environment);
|
|
81808
|
+
const serverName = `${slug}-${environment}-app`;
|
|
81548
81809
|
const existing = await readDriverState(stackName);
|
|
81549
81810
|
if (existing?.serverId) {
|
|
81550
|
-
const server2 = await this.
|
|
81551
|
-
if (server2.status !== "off") {
|
|
81811
|
+
const server2 = await this.tryGetServer(existing.serverId);
|
|
81812
|
+
if (server2 && server2.status !== "off") {
|
|
81552
81813
|
return this.outputsFromState(existing, server2);
|
|
81553
81814
|
}
|
|
81554
81815
|
}
|
|
81816
|
+
const labels = tsCloudLabels(slug, environment, "app");
|
|
81817
|
+
const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
|
|
81818
|
+
if (alreadyRunning && alreadyRunning.status !== "off") {
|
|
81819
|
+
const rehydrated = {
|
|
81820
|
+
provider: "hetzner",
|
|
81821
|
+
stackName,
|
|
81822
|
+
serverId: alreadyRunning.id,
|
|
81823
|
+
serverName: alreadyRunning.name,
|
|
81824
|
+
publicIp: alreadyRunning.public_net.ipv4?.ip,
|
|
81825
|
+
deployStoragePath: "/var/ts-cloud/staging",
|
|
81826
|
+
sshUser: this.sshUser
|
|
81827
|
+
};
|
|
81828
|
+
await writeDriverState(stackName, rehydrated);
|
|
81829
|
+
return this.outputsFromState(rehydrated, alreadyRunning);
|
|
81830
|
+
}
|
|
81555
81831
|
const sites = config6.sites || {};
|
|
81556
|
-
const
|
|
81557
|
-
const
|
|
81832
|
+
const caddyfile = resolveCaddyfile(sites, compute.proxy);
|
|
81833
|
+
const sitePorts = caddyfile ? [] : this.collectUpstreamPorts(sites, compute.proxy);
|
|
81558
81834
|
const bootstrap = generateUbuntuAppCloudInit({
|
|
81559
81835
|
runtime: compute.runtime || "bun",
|
|
81560
81836
|
runtimeVersion: compute.runtimeVersion || "latest",
|
|
@@ -81563,19 +81839,13 @@ class HetznerDriver {
|
|
|
81563
81839
|
caddyfile
|
|
81564
81840
|
});
|
|
81565
81841
|
const userData = wrapCloudInitUserData(bootstrap);
|
|
81566
|
-
const serverName = `${slug}-${environment}-app`;
|
|
81567
81842
|
const serverType = resolveHetznerServerType(compute.size);
|
|
81568
81843
|
const image = compute.image || config6.hetzner?.image || "ubuntu-24.04";
|
|
81569
|
-
const labels = tsCloudLabels(slug, environment, "app");
|
|
81570
81844
|
const firewallName = `${slug}-${environment}-app-fw`;
|
|
81571
|
-
const { firewall } = await this.
|
|
81572
|
-
|
|
81573
|
-
|
|
81574
|
-
|
|
81575
|
-
allowSsh: compute.allowSsh !== false,
|
|
81576
|
-
sitePorts
|
|
81577
|
-
})
|
|
81578
|
-
});
|
|
81845
|
+
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
81846
|
+
allowSsh: compute.allowSsh !== false,
|
|
81847
|
+
sitePorts
|
|
81848
|
+
}));
|
|
81579
81849
|
const sshKeyId = await this.ensureSshKey(slug, environment, labels);
|
|
81580
81850
|
const { server, action } = await this.client.createServer({
|
|
81581
81851
|
name: serverName,
|
|
@@ -81600,6 +81870,11 @@ class HetznerDriver {
|
|
|
81600
81870
|
sshUser: this.sshUser
|
|
81601
81871
|
};
|
|
81602
81872
|
await writeDriverState(stackName, state);
|
|
81873
|
+
const ip = running.public_net.ipv4?.ip;
|
|
81874
|
+
if (ip && this.waitForBoot) {
|
|
81875
|
+
await this.waitForSshReady(ip);
|
|
81876
|
+
await this.waitForCloudInit(ip);
|
|
81877
|
+
}
|
|
81603
81878
|
return this.outputsFromState(state, running);
|
|
81604
81879
|
}
|
|
81605
81880
|
async getComputeOutputs(options) {
|
|
@@ -81706,6 +81981,79 @@ class HetznerDriver {
|
|
|
81706
81981
|
});
|
|
81707
81982
|
return created.id;
|
|
81708
81983
|
}
|
|
81984
|
+
async tryGetServer(id) {
|
|
81985
|
+
try {
|
|
81986
|
+
return await this.client.getServer(id);
|
|
81987
|
+
} catch {
|
|
81988
|
+
return null;
|
|
81989
|
+
}
|
|
81990
|
+
}
|
|
81991
|
+
async findExistingServer(slug, environment, serverName) {
|
|
81992
|
+
const servers = await this.client.listServers();
|
|
81993
|
+
return servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
|
|
81994
|
+
}
|
|
81995
|
+
async ensureFirewall(name, labels, rules) {
|
|
81996
|
+
const existing = await this.client.listFirewalls();
|
|
81997
|
+
const match = existing.find((fw) => fw.name === name);
|
|
81998
|
+
if (match) {
|
|
81999
|
+
await this.client.setFirewallRules(match.id, rules);
|
|
82000
|
+
return { firewall: match };
|
|
82001
|
+
}
|
|
82002
|
+
const { firewall } = await this.client.createFirewall({ name, labels, rules });
|
|
82003
|
+
return { firewall };
|
|
82004
|
+
}
|
|
82005
|
+
collectUpstreamPorts(sites, proxy) {
|
|
82006
|
+
const ports = new Set;
|
|
82007
|
+
for (const app of proxy?.apps ?? []) {
|
|
82008
|
+
if (typeof app.port === "number")
|
|
82009
|
+
ports.add(app.port);
|
|
82010
|
+
}
|
|
82011
|
+
for (const site of Object.values(sites)) {
|
|
82012
|
+
if (typeof site.port === "number")
|
|
82013
|
+
ports.add(site.port);
|
|
82014
|
+
}
|
|
82015
|
+
return [...ports].filter((port) => ![80, 443].includes(port));
|
|
82016
|
+
}
|
|
82017
|
+
async sleep(ms) {
|
|
82018
|
+
await new Promise((resolve13) => setTimeout(resolve13, ms));
|
|
82019
|
+
}
|
|
82020
|
+
async waitForSshReady(host) {
|
|
82021
|
+
const { sshIntervalMs, sshTimeoutMs } = this.bootWait;
|
|
82022
|
+
const start = Date.now();
|
|
82023
|
+
let lastErr;
|
|
82024
|
+
while (Date.now() - start < sshTimeoutMs) {
|
|
82025
|
+
try {
|
|
82026
|
+
execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} true`, {
|
|
82027
|
+
stdio: "pipe",
|
|
82028
|
+
maxBuffer: SSH_MAX_BUFFER
|
|
82029
|
+
});
|
|
82030
|
+
return;
|
|
82031
|
+
} catch (err) {
|
|
82032
|
+
lastErr = err;
|
|
82033
|
+
await this.sleep(sshIntervalMs);
|
|
82034
|
+
}
|
|
82035
|
+
}
|
|
82036
|
+
throw new Error(`Timed out waiting for SSH on ${host} after ${sshTimeoutMs}ms: ${lastErr?.message ?? "unknown error"}`);
|
|
82037
|
+
}
|
|
82038
|
+
async waitForCloudInit(host) {
|
|
82039
|
+
const { cloudInitIntervalMs, cloudInitTimeoutMs } = this.bootWait;
|
|
82040
|
+
const start = Date.now();
|
|
82041
|
+
while (Date.now() - start < cloudInitTimeoutMs) {
|
|
82042
|
+
try {
|
|
82043
|
+
const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
|
|
82044
|
+
if (/status:\s*done/.test(out))
|
|
82045
|
+
return;
|
|
82046
|
+
if (/status:\s*error/.test(out))
|
|
82047
|
+
throw new Error(`cloud-init reported an error on ${host}:
|
|
82048
|
+
${out}`);
|
|
82049
|
+
} catch (err) {
|
|
82050
|
+
if (err instanceof Error && /cloud-init reported an error/.test(err.message))
|
|
82051
|
+
throw err;
|
|
82052
|
+
}
|
|
82053
|
+
await this.sleep(cloudInitIntervalMs);
|
|
82054
|
+
}
|
|
82055
|
+
throw new Error(`Timed out waiting for cloud-init to finish on ${host} after ${cloudInitTimeoutMs}ms`);
|
|
82056
|
+
}
|
|
81709
82057
|
outputsFromState(state, server) {
|
|
81710
82058
|
return {
|
|
81711
82059
|
deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
|
|
@@ -81714,7 +82062,7 @@ class HetznerDriver {
|
|
|
81714
82062
|
sshUser: state.sshUser || this.sshUser
|
|
81715
82063
|
};
|
|
81716
82064
|
}
|
|
81717
|
-
sshBaseArgs(host) {
|
|
82065
|
+
sshBaseArgs(host, extra = []) {
|
|
81718
82066
|
return [
|
|
81719
82067
|
"-i",
|
|
81720
82068
|
this.sshPrivateKeyPath,
|
|
@@ -81722,6 +82070,7 @@ class HetznerDriver {
|
|
|
81722
82070
|
"StrictHostKeyChecking=accept-new",
|
|
81723
82071
|
"-o",
|
|
81724
82072
|
"BatchMode=yes",
|
|
82073
|
+
...extra,
|
|
81725
82074
|
`${this.sshUser}@${host}`
|
|
81726
82075
|
];
|
|
81727
82076
|
}
|
|
@@ -81736,13 +82085,14 @@ class HetznerDriver {
|
|
|
81736
82085
|
"BatchMode=yes",
|
|
81737
82086
|
localPath,
|
|
81738
82087
|
`${this.sshUser}@${host}:${remotePath}`
|
|
81739
|
-
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe" });
|
|
82088
|
+
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
|
|
81740
82089
|
}
|
|
81741
82090
|
sshExec(host, script) {
|
|
81742
82091
|
const escaped = script.replace(/'/g, `'\\''`);
|
|
81743
82092
|
return execSync(`ssh ${this.sshBaseArgs(host).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} '${escaped}'`, {
|
|
81744
82093
|
encoding: "utf8",
|
|
81745
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
82094
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
82095
|
+
maxBuffer: SSH_MAX_BUFFER
|
|
81746
82096
|
});
|
|
81747
82097
|
}
|
|
81748
82098
|
}
|
|
@@ -81792,12 +82142,14 @@ function buildSiteDeployScript(options) {
|
|
|
81792
82142
|
artifactFetch,
|
|
81793
82143
|
execStart,
|
|
81794
82144
|
envEntries,
|
|
81795
|
-
port
|
|
82145
|
+
port,
|
|
82146
|
+
preStartCommands = []
|
|
81796
82147
|
} = options;
|
|
81797
82148
|
const appDir = options.appDir ?? `/var/www/${siteName}`;
|
|
81798
82149
|
const serviceName = `${slug}-${siteName}.service`;
|
|
81799
82150
|
const envFile = Object.entries(envEntries).map(([k, v]) => `${k}=${JSON.stringify(String(v))}`).join(`
|
|
81800
82151
|
`);
|
|
82152
|
+
const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
|
|
81801
82153
|
return [
|
|
81802
82154
|
"set -euo pipefail",
|
|
81803
82155
|
...artifactFetch,
|
|
@@ -81808,6 +82160,7 @@ function buildSiteDeployScript(options) {
|
|
|
81808
82160
|
envFile,
|
|
81809
82161
|
"TS_CLOUD_ENV_EOF",
|
|
81810
82162
|
`chmod 600 ${appDir}/.env`,
|
|
82163
|
+
...preStart,
|
|
81811
82164
|
`cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
|
|
81812
82165
|
"[Unit]",
|
|
81813
82166
|
`Description=${siteName} (managed by ts-cloud)`,
|
|
@@ -81831,6 +82184,19 @@ function buildSiteDeployScript(options) {
|
|
|
81831
82184
|
`systemctl is-active ${serviceName}`
|
|
81832
82185
|
];
|
|
81833
82186
|
}
|
|
82187
|
+
function buildStaticSiteDeployScript(options) {
|
|
82188
|
+
const { siteName, artifactFetch, preStartCommands = [] } = options;
|
|
82189
|
+
const appDir = options.appDir ?? `/var/www/${siteName}`;
|
|
82190
|
+
const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
|
|
82191
|
+
return [
|
|
82192
|
+
"set -euo pipefail",
|
|
82193
|
+
...artifactFetch,
|
|
82194
|
+
`mkdir -p ${appDir}`,
|
|
82195
|
+
`find ${appDir} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`,
|
|
82196
|
+
`tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
|
|
82197
|
+
...preStart
|
|
82198
|
+
];
|
|
82199
|
+
}
|
|
81834
82200
|
function buildAwsArtifactFetch(bucket, key, region, siteName) {
|
|
81835
82201
|
return [
|
|
81836
82202
|
`aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
|
|
@@ -81880,13 +82246,18 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
|
|
|
81880
82246
|
targets
|
|
81881
82247
|
});
|
|
81882
82248
|
const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config6.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
|
|
81883
|
-
const remoteScript =
|
|
82249
|
+
const remoteScript = resolveSiteKind(site) === "server-static" ? buildStaticSiteDeployScript({
|
|
82250
|
+
siteName,
|
|
82251
|
+
artifactFetch,
|
|
82252
|
+
preStartCommands: site.preStart
|
|
82253
|
+
}) : buildSiteDeployScript({
|
|
81884
82254
|
siteName,
|
|
81885
82255
|
slug,
|
|
81886
82256
|
artifactFetch,
|
|
81887
82257
|
execStart: resolveExecStart(site.start, runtime),
|
|
81888
82258
|
envEntries: site.env || {},
|
|
81889
|
-
port: site.port
|
|
82259
|
+
port: site.port,
|
|
82260
|
+
preStartCommands: site.preStart
|
|
81890
82261
|
});
|
|
81891
82262
|
logger4.step(`Deploying to ${targets.length} target(s)...`);
|
|
81892
82263
|
const result = await driver.runRemoteDeploy({
|
|
@@ -81918,8 +82289,11 @@ async function deployAllComputeSites(options) {
|
|
|
81918
82289
|
const slug = config6.project.slug;
|
|
81919
82290
|
const sites = config6.sites || {};
|
|
81920
82291
|
const deployable = Object.entries(sites).filter(([name, site]) => {
|
|
81921
|
-
if (!site
|
|
81922
|
-
|
|
82292
|
+
if (!site)
|
|
82293
|
+
return false;
|
|
82294
|
+
const kind = resolveSiteKind(site);
|
|
82295
|
+
if (kind === "bucket") {
|
|
82296
|
+
logger4.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
|
|
81923
82297
|
return false;
|
|
81924
82298
|
}
|
|
81925
82299
|
return true;
|
|
@@ -81971,6 +82345,7 @@ export {
|
|
|
81971
82345
|
validateTemplateSize,
|
|
81972
82346
|
validateTemplate,
|
|
81973
82347
|
validateResourceLimits,
|
|
82348
|
+
validateDeploymentConfig,
|
|
81974
82349
|
validateCredentials,
|
|
81975
82350
|
validateConfiguration,
|
|
81976
82351
|
validateCommand,
|
|
@@ -81986,6 +82361,7 @@ export {
|
|
|
81986
82361
|
suggestFlags,
|
|
81987
82362
|
suggestCommand,
|
|
81988
82363
|
storageAdvancedManager,
|
|
82364
|
+
staticSiteServerRoot,
|
|
81989
82365
|
staticSiteManager,
|
|
81990
82366
|
stackDependencyManager,
|
|
81991
82367
|
signRequestAsync,
|
|
@@ -82006,6 +82382,8 @@ export {
|
|
|
82006
82382
|
resolveStorageBucketName,
|
|
82007
82383
|
resolveSiteStackName,
|
|
82008
82384
|
resolveSiteResourceName,
|
|
82385
|
+
resolveSiteKind,
|
|
82386
|
+
resolveSiteDeployTarget,
|
|
82009
82387
|
resolveSiteBucketName,
|
|
82010
82388
|
resolveRegion,
|
|
82011
82389
|
resolveProjectStackName,
|
|
@@ -82015,11 +82393,13 @@ export {
|
|
|
82015
82393
|
resolveDeployBucketName,
|
|
82016
82394
|
resolveCredentials,
|
|
82017
82395
|
resolveCloudProvider,
|
|
82396
|
+
resolveCaddyfile,
|
|
82018
82397
|
requiresReplacement,
|
|
82019
82398
|
replicaManager,
|
|
82020
82399
|
regionPairManager,
|
|
82021
82400
|
quickHash,
|
|
82022
82401
|
queueManagementManager,
|
|
82402
|
+
proxyConfigFromSites,
|
|
82023
82403
|
providerEndpoint,
|
|
82024
82404
|
progressiveDeploymentManager,
|
|
82025
82405
|
processInChunks,
|
|
@@ -82052,6 +82432,7 @@ export {
|
|
|
82052
82432
|
lambdaConcurrencyManager,
|
|
82053
82433
|
isWebCryptoAvailable,
|
|
82054
82434
|
isValidRegion,
|
|
82435
|
+
isOnDemandDomain,
|
|
82055
82436
|
isNodeCryptoAvailable,
|
|
82056
82437
|
isLocalDevelopment,
|
|
82057
82438
|
isLikelyTypo,
|
|
@@ -82200,9 +82581,11 @@ export {
|
|
|
82200
82581
|
certificateManager,
|
|
82201
82582
|
categorizeChanges,
|
|
82202
82583
|
canaryManager,
|
|
82584
|
+
buildStaticSiteDeployScript,
|
|
82203
82585
|
buildSiteDeployScript,
|
|
82204
82586
|
buildOptimizationManager,
|
|
82205
82587
|
buildCloudFormationTemplate,
|
|
82588
|
+
buildCaddyfileFromProxy,
|
|
82206
82589
|
buildCaddyfile,
|
|
82207
82590
|
bounceComplaintHandler,
|
|
82208
82591
|
blueGreenManager,
|