@stacksjs/ts-cloud 0.7.17 → 0.7.18
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 +356 -356
- package/dist/{chunk-pr3g0b0t.js → chunk-h4pdjzq2.js} +1 -1
- package/dist/{chunk-qergre5a.js → chunk-vpyd42tp.js} +1504 -1498
- package/dist/deploy/index.js +2 -2
- package/dist/drivers/index.js +1 -1
- package/dist/drivers/shared/rpx-gateway.d.ts +29 -2
- package/dist/index.js +2 -2
- 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
|
@@ -545,1578 +545,1584 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
|
|
|
545
545
|
];
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
// src/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (firewall.enabled === false)
|
|
552
|
-
return [];
|
|
553
|
-
const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
|
|
554
|
-
const lines = [
|
|
555
|
-
"export DEBIAN_FRONTEND=noninteractive",
|
|
556
|
-
"apt-get install -y ufw",
|
|
557
|
-
"ufw default deny incoming",
|
|
558
|
-
"ufw default allow outgoing",
|
|
559
|
-
"ufw allow OpenSSH"
|
|
560
|
-
];
|
|
561
|
-
for (const port of ports)
|
|
562
|
-
lines.push(`ufw allow ${port}/tcp`);
|
|
563
|
-
lines.push("ufw --force enable");
|
|
564
|
-
return lines;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// src/drivers/shared/maintenance.ts
|
|
568
|
-
function buildAutoUpdatesScript(enabled2 = true) {
|
|
569
|
-
if (!enabled2)
|
|
570
|
-
return [];
|
|
571
|
-
return [
|
|
572
|
-
"export DEBIAN_FRONTEND=noninteractive",
|
|
573
|
-
"apt-get install -y unattended-upgrades",
|
|
574
|
-
"cat > /etc/apt/apt.conf.d/20auto-upgrades <<'TS_CLOUD_AUTOUPD_EOF'",
|
|
575
|
-
'APT::Periodic::Update-Package-Lists "1";',
|
|
576
|
-
'APT::Periodic::Unattended-Upgrade "1";',
|
|
577
|
-
'APT::Periodic::Download-Upgradeable-Packages "1";',
|
|
578
|
-
'APT::Periodic::AutocleanInterval "7";',
|
|
579
|
-
"TS_CLOUD_AUTOUPD_EOF",
|
|
580
|
-
"systemctl enable unattended-upgrades 2>/dev/null || true",
|
|
581
|
-
"systemctl restart unattended-upgrades 2>/dev/null || true"
|
|
582
|
-
];
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// src/drivers/shared/monitoring.ts
|
|
586
|
-
var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
|
|
587
|
-
var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
|
|
588
|
-
var DEFAULT_CPU_LOAD_PER_CORE = 2;
|
|
589
|
-
var DEFAULT_MEM_PERCENT = 90;
|
|
590
|
-
var DEFAULT_DISK_PERCENT = 90;
|
|
591
|
-
var SERVICE_PROBES = [
|
|
592
|
-
["nginx", 80],
|
|
593
|
-
["phpFpm", 9074],
|
|
594
|
-
["mysql", 3306],
|
|
595
|
-
["postgres", 5432],
|
|
596
|
-
["redis", 6379],
|
|
597
|
-
["meilisearch", 7700]
|
|
598
|
-
];
|
|
599
|
-
function resolveMonitoring(monitoring = true) {
|
|
600
|
-
const obj = typeof monitoring === "object" ? monitoring : {};
|
|
601
|
-
const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
|
|
602
|
-
return {
|
|
603
|
-
enabled: enabled2,
|
|
604
|
-
cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
|
|
605
|
-
memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
|
|
606
|
-
diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
|
|
607
|
-
};
|
|
548
|
+
// src/deploy/site-target.ts
|
|
549
|
+
function siteInstallBase(slug, siteName) {
|
|
550
|
+
return `/var/www/${slug}-${siteName}`;
|
|
608
551
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
"cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
|
|
618
|
-
"#!/bin/bash",
|
|
619
|
-
"set -uo pipefail",
|
|
620
|
-
"LOAD=$(cut -d' ' -f1 /proc/loadavg)",
|
|
621
|
-
"CPUS=$(nproc)",
|
|
622
|
-
"MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
|
|
623
|
-
"MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
|
|
624
|
-
"SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
|
|
625
|
-
"SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
|
|
626
|
-
`DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
|
|
627
|
-
"UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
|
|
628
|
-
`RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
|
|
629
|
-
`TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
|
|
630
|
-
"probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
|
|
631
|
-
...probeLines,
|
|
632
|
-
"LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
|
|
633
|
-
"SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
|
|
634
|
-
"UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
|
|
635
|
-
"MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
|
|
636
|
-
`cat > ${METRICS_PATH}.tmp <<JSON`,
|
|
637
|
-
'{"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 + "}}",
|
|
638
|
-
"JSON",
|
|
639
|
-
`mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
|
|
640
|
-
'ALERTS=""',
|
|
641
|
-
`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`,
|
|
642
|
-
`if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
|
|
643
|
-
`if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
|
|
644
|
-
`PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
|
|
645
|
-
'if [ -n "$ALERTS" ]; then',
|
|
646
|
-
' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
|
|
647
|
-
` echo alert > ${ALERT_STATE_PATH}`,
|
|
648
|
-
"else",
|
|
649
|
-
' 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',
|
|
650
|
-
` echo ok > ${ALERT_STATE_PATH}`,
|
|
651
|
-
"fi",
|
|
652
|
-
"TS_CLOUD_METRICS_EOF",
|
|
653
|
-
"chmod +x /usr/local/bin/ts-cloud-metrics.sh",
|
|
654
|
-
"cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
|
|
655
|
-
"[Unit]",
|
|
656
|
-
"Description=ts-cloud metrics collector",
|
|
657
|
-
"",
|
|
658
|
-
"[Service]",
|
|
659
|
-
"Type=oneshot",
|
|
660
|
-
"ExecStart=/usr/local/bin/ts-cloud-metrics.sh",
|
|
661
|
-
"TS_CLOUD_METRICS_SVC_EOF",
|
|
662
|
-
"cat > /etc/systemd/system/ts-cloud-metrics.timer <<'TS_CLOUD_METRICS_TMR_EOF'",
|
|
663
|
-
"[Unit]",
|
|
664
|
-
"Description=Run ts-cloud metrics collector every minute",
|
|
665
|
-
"",
|
|
666
|
-
"[Timer]",
|
|
667
|
-
"OnBootSec=60",
|
|
668
|
-
"OnUnitActiveSec=60",
|
|
669
|
-
"",
|
|
670
|
-
"[Install]",
|
|
671
|
-
"WantedBy=timers.target",
|
|
672
|
-
"TS_CLOUD_METRICS_TMR_EOF",
|
|
673
|
-
"systemctl daemon-reload",
|
|
674
|
-
"systemctl enable ts-cloud-metrics.timer",
|
|
675
|
-
"systemctl start ts-cloud-metrics.timer"
|
|
676
|
-
];
|
|
552
|
+
var PHP_SITE_TYPES = new Set([
|
|
553
|
+
"laravel",
|
|
554
|
+
"php",
|
|
555
|
+
"statamic",
|
|
556
|
+
"wordpress"
|
|
557
|
+
]);
|
|
558
|
+
function isPhpSite(site) {
|
|
559
|
+
return site.type != null && PHP_SITE_TYPES.has(site.type);
|
|
677
560
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
|
|
687
|
-
const dir = path.replace(/\/[^/]*$/, "");
|
|
688
|
-
const block = [
|
|
689
|
-
BLOCK_BEGIN,
|
|
690
|
-
...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
|
|
691
|
-
BLOCK_END
|
|
692
|
-
].join(`
|
|
693
|
-
`);
|
|
694
|
-
return [
|
|
695
|
-
`mkdir -p ${dir}`,
|
|
696
|
-
`touch ${path}`,
|
|
697
|
-
`sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
|
|
698
|
-
`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
|
|
699
|
-
block,
|
|
700
|
-
"TS_CLOUD_KEYS_EOF",
|
|
701
|
-
`chmod 600 ${path}`
|
|
702
|
-
];
|
|
561
|
+
function resolveSiteDeployTarget(site) {
|
|
562
|
+
if (site.deploy)
|
|
563
|
+
return site.deploy;
|
|
564
|
+
if (isPhpSite(site))
|
|
565
|
+
return "server";
|
|
566
|
+
if (site.start)
|
|
567
|
+
return "server";
|
|
568
|
+
return "bucket";
|
|
703
569
|
}
|
|
704
|
-
function
|
|
705
|
-
|
|
570
|
+
function resolveSiteKind(site) {
|
|
571
|
+
if (site.redirect)
|
|
572
|
+
return "redirect";
|
|
573
|
+
if (isPhpSite(site))
|
|
574
|
+
return "server-php";
|
|
575
|
+
const target = resolveSiteDeployTarget(site);
|
|
576
|
+
if (target === "bucket")
|
|
577
|
+
return "bucket";
|
|
578
|
+
return site.start ? "server-app" : "server-static";
|
|
706
579
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
function wantsEvent(config, event) {
|
|
710
|
-
return !config.events || config.events.includes(event);
|
|
580
|
+
function hasComputeConfigured(config) {
|
|
581
|
+
return config.infrastructure?.compute != null;
|
|
711
582
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
if (config.discord?.webhookUrl && wantsEvent(config, event)) {
|
|
726
|
-
attempted.push("discord");
|
|
727
|
-
tasks.push(post(config.discord.webhookUrl, { content: message }));
|
|
728
|
-
}
|
|
729
|
-
if (config.telegram?.botToken && config.telegram.chatId && wantsEvent(config, event)) {
|
|
730
|
-
attempted.push("telegram");
|
|
731
|
-
const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
|
|
732
|
-
tasks.push(post(url, { chat_id: config.telegram.chatId, text: message }));
|
|
733
|
-
}
|
|
734
|
-
if (config.webhook?.url && wantsEvent(config, event)) {
|
|
735
|
-
attempted.push("webhook");
|
|
736
|
-
if ((config.webhook.method || "POST") === "GET") {
|
|
737
|
-
const sep = config.webhook.url.includes("?") ? "&" : "?";
|
|
738
|
-
const url = `${config.webhook.url}${sep}event=${encodeURIComponent(event)}&message=${encodeURIComponent(message)}`;
|
|
739
|
-
tasks.push(fetchImpl(url, { method: "GET" }).catch(() => {
|
|
740
|
-
return;
|
|
741
|
-
}));
|
|
742
|
-
} else {
|
|
743
|
-
tasks.push(post(config.webhook.url, { event, message }));
|
|
583
|
+
function validateDeploymentConfig(config) {
|
|
584
|
+
const errors = [];
|
|
585
|
+
const warnings = [];
|
|
586
|
+
const sites = config.sites || {};
|
|
587
|
+
const computeConfigured = hasComputeConfigured(config);
|
|
588
|
+
const coexistence = deploymentCoexistenceError(config);
|
|
589
|
+
if (coexistence)
|
|
590
|
+
errors.push(coexistence);
|
|
591
|
+
const portOwners = new Map;
|
|
592
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
593
|
+
if (!site) {
|
|
594
|
+
continue;
|
|
744
595
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
596
|
+
const target = resolveSiteDeployTarget(site);
|
|
597
|
+
const kind = resolveSiteKind(site);
|
|
598
|
+
if (kind === "redirect") {
|
|
599
|
+
if (!site.domain)
|
|
600
|
+
errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
|
|
601
|
+
const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
|
|
602
|
+
if (!to)
|
|
603
|
+
errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
|
|
604
|
+
if (!computeConfigured) {
|
|
605
|
+
errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
|
|
606
|
+
}
|
|
607
|
+
const serverOnly = [];
|
|
608
|
+
if (site.start)
|
|
609
|
+
serverOnly.push("start");
|
|
610
|
+
if (site.root)
|
|
611
|
+
serverOnly.push("root");
|
|
612
|
+
if (serverOnly.length > 0)
|
|
613
|
+
warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (target === "server" && !site.start && !site.root) {
|
|
617
|
+
errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (kind === "server-php") {
|
|
621
|
+
if (!computeConfigured) {
|
|
622
|
+
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.`);
|
|
623
|
+
}
|
|
624
|
+
if (!site.repository?.url) {
|
|
625
|
+
errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but has no \`repository.url\` to clone. PHP sites deploy via git.`);
|
|
626
|
+
}
|
|
627
|
+
} else if (kind === "server-app") {
|
|
628
|
+
if (!computeConfigured) {
|
|
629
|
+
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).`);
|
|
630
|
+
}
|
|
631
|
+
if (typeof site.port === "number") {
|
|
632
|
+
const existing = portOwners.get(site.port);
|
|
633
|
+
if (existing) {
|
|
634
|
+
errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
|
|
635
|
+
} else {
|
|
636
|
+
portOwners.set(site.port, name);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} else if (kind === "server-static") {
|
|
640
|
+
if (!site.root) {
|
|
641
|
+
errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
|
|
642
|
+
}
|
|
643
|
+
if (!computeConfigured) {
|
|
644
|
+
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).`);
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
if (!site.root) {
|
|
648
|
+
errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
|
|
649
|
+
}
|
|
650
|
+
const serverOnly = [];
|
|
651
|
+
if (site.start)
|
|
652
|
+
serverOnly.push("start");
|
|
653
|
+
if (typeof site.port === "number")
|
|
654
|
+
serverOnly.push("port");
|
|
655
|
+
if (site.preStart && site.preStart.length > 0)
|
|
656
|
+
serverOnly.push("preStart");
|
|
657
|
+
if (serverOnly.length > 0) {
|
|
658
|
+
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.`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return { errors, warnings };
|
|
789
663
|
}
|
|
790
664
|
|
|
791
|
-
// src/drivers/shared/
|
|
792
|
-
var
|
|
793
|
-
var
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const ts = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
804
|
-
return [
|
|
805
|
-
" {",
|
|
806
|
-
` type: '${type}',`,
|
|
807
|
-
` name: '${ts(database.name)}',`,
|
|
808
|
-
" connection: {",
|
|
809
|
-
` hostname: '${ts(host)}',`,
|
|
810
|
-
` port: ${port},`,
|
|
811
|
-
` database: '${ts(database.name)}',`,
|
|
812
|
-
` username: '${ts(database.username || database.name)}',`,
|
|
813
|
-
` password: '${ts(database.password || "")}',`,
|
|
814
|
-
" ssl: false,",
|
|
815
|
-
" },",
|
|
816
|
-
" includeSchema: true,",
|
|
817
|
-
" includeData: true,",
|
|
818
|
-
" },"
|
|
819
|
-
].join(`
|
|
820
|
-
`);
|
|
665
|
+
// src/drivers/shared/rpx-gateway.ts
|
|
666
|
+
var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
|
|
667
|
+
var DEFAULT_ACME_WEBROOT = "/var/www/acme-challenge";
|
|
668
|
+
function normalizeSiteRedirect(input) {
|
|
669
|
+
if (typeof input === "string")
|
|
670
|
+
return { to: input };
|
|
671
|
+
const out = { to: input.to };
|
|
672
|
+
if (input.status != null)
|
|
673
|
+
out.status = input.status;
|
|
674
|
+
if (input.preservePath != null)
|
|
675
|
+
out.preservePath = input.preservePath;
|
|
676
|
+
return out;
|
|
821
677
|
}
|
|
822
|
-
function
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
" optional: false,",
|
|
832
|
-
" },",
|
|
833
|
-
" ],"
|
|
834
|
-
] : [];
|
|
835
|
-
return [
|
|
836
|
-
"import type { BackupConfig } from 'ts-backups'",
|
|
837
|
-
"",
|
|
838
|
-
"const config: BackupConfig = {",
|
|
839
|
-
" verbose: true,",
|
|
840
|
-
` outputPath: '${BACKUP_OUTPUT_DIR}',`,
|
|
841
|
-
" retention: {",
|
|
842
|
-
` count: ${backups.retentionCount ?? 5},`,
|
|
843
|
-
` maxAge: ${backups.retentionDays ?? 30},`,
|
|
844
|
-
" },",
|
|
845
|
-
" databases: [",
|
|
846
|
-
...entry ? [entry] : [],
|
|
847
|
-
" ],",
|
|
848
|
-
...destinations,
|
|
849
|
-
"}",
|
|
850
|
-
"",
|
|
851
|
-
"export default config",
|
|
852
|
-
""
|
|
853
|
-
].join(`
|
|
854
|
-
`);
|
|
678
|
+
function resolveRouteAuth(site) {
|
|
679
|
+
const auth = site.auth;
|
|
680
|
+
if (!auth || auth.enabled === false || !auth.password)
|
|
681
|
+
return;
|
|
682
|
+
return {
|
|
683
|
+
username: auth.username || "admin",
|
|
684
|
+
password: auth.password,
|
|
685
|
+
...auth.realm ? { realm: auth.realm } : {}
|
|
686
|
+
};
|
|
855
687
|
}
|
|
856
|
-
function
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
return
|
|
863
|
-
"export DEBIAN_FRONTEND=noninteractive",
|
|
864
|
-
`mkdir -p /etc/ts-cloud ${BACKUP_OUTPUT_DIR}`,
|
|
865
|
-
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf /root/.bun/bin/bun /usr/local/bin/bun)",
|
|
866
|
-
"bun add -g ts-backups || true",
|
|
867
|
-
`cat > ${BACKUP_CONFIG_PATH} <<'TS_CLOUD_BACKUP_CFG_EOF'`,
|
|
868
|
-
configTs.replace(/\n$/, ""),
|
|
869
|
-
"TS_CLOUD_BACKUP_CFG_EOF",
|
|
870
|
-
`cat > ${BACKUP_RUNNER_PATH} <<'TS_CLOUD_BACKUP_RUN_EOF'`,
|
|
871
|
-
"#!/bin/bash",
|
|
872
|
-
"set -uo pipefail",
|
|
873
|
-
'export PATH="/root/.bun/bin:/usr/local/bin:$PATH"',
|
|
874
|
-
'notify() { [ -x /usr/local/bin/ts-cloud-notify ] && /usr/local/bin/ts-cloud-notify "$1" || true; }',
|
|
875
|
-
"cd /etc/ts-cloud",
|
|
876
|
-
'if ! ts-backups backup --config /etc/ts-cloud/backups.config.ts; then notify "❌ ts-cloud backup failed"; exit 1; fi',
|
|
877
|
-
"TS_CLOUD_BACKUP_RUN_EOF",
|
|
878
|
-
`chmod +x ${BACKUP_RUNNER_PATH}`,
|
|
879
|
-
`cat > ${BACKUP_CRON_PATH} <<'TS_CLOUD_BACKUP_CRON_EOF'`,
|
|
880
|
-
`${schedule} root ${BACKUP_RUNNER_PATH} >> /var/log/ts-cloud-backup.log 2>&1`,
|
|
881
|
-
"TS_CLOUD_BACKUP_CRON_EOF",
|
|
882
|
-
`chmod 644 ${BACKUP_CRON_PATH}`
|
|
883
|
-
];
|
|
688
|
+
function normalizeRoutePath(path) {
|
|
689
|
+
if (!path || path === "/")
|
|
690
|
+
return;
|
|
691
|
+
let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
692
|
+
if (!p.startsWith("/"))
|
|
693
|
+
p = `/${p}`;
|
|
694
|
+
return p === "" || p === "/" ? undefined : p;
|
|
884
695
|
}
|
|
885
|
-
function
|
|
886
|
-
|
|
696
|
+
function deriveRouteId(to, path) {
|
|
697
|
+
const base = path ? `${to}${path}` : to;
|
|
698
|
+
const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
|
|
699
|
+
return cleaned.length > 0 ? cleaned : "rpx";
|
|
887
700
|
}
|
|
888
|
-
function
|
|
889
|
-
if (!
|
|
890
|
-
return
|
|
891
|
-
|
|
892
|
-
const isPg = database.engine === "postgres";
|
|
893
|
-
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)"`;
|
|
894
|
-
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}"`;
|
|
895
|
-
return [
|
|
896
|
-
"set -uo pipefail",
|
|
897
|
-
'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
|
|
898
|
-
locate,
|
|
899
|
-
'[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
|
|
900
|
-
'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
|
|
901
|
-
`case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client}`,
|
|
902
|
-
'echo "restore complete"'
|
|
903
|
-
];
|
|
701
|
+
function resolveServerAppFrom(port, appBoxes) {
|
|
702
|
+
if (!appBoxes || appBoxes.length === 0)
|
|
703
|
+
return `localhost:${port}`;
|
|
704
|
+
return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
|
|
904
705
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
const
|
|
910
|
-
const
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const extras = [];
|
|
926
|
-
if (!phpBox && needsPantry)
|
|
927
|
-
extras.push(...pantryBootstrap);
|
|
928
|
-
extras.push(...buildNotifierScript(config.notifications));
|
|
929
|
-
if (compute.managedServices) {
|
|
930
|
-
extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
|
|
931
|
-
}
|
|
932
|
-
extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
|
|
933
|
-
extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
|
|
934
|
-
extras.push(...buildMonitoringScript(compute.monitoring ?? phpBox));
|
|
935
|
-
extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
|
|
936
|
-
if (compute.backups?.enabled) {
|
|
937
|
-
extras.push(...buildBackupProvisionScript({
|
|
938
|
-
database: config.infrastructure?.appDatabase,
|
|
939
|
-
backups: compute.backups
|
|
940
|
-
}));
|
|
941
|
-
}
|
|
942
|
-
return {
|
|
943
|
-
runtime: compute.runtime || "bun",
|
|
944
|
-
runtimeVersion: compute.runtimeVersion || "latest",
|
|
945
|
-
phpBox,
|
|
946
|
-
phpProvision,
|
|
947
|
-
servicesProvision: extras.length > 0 ? extras : undefined
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// src/drivers/shared/ubuntu-bootstrap.ts
|
|
952
|
-
function buildUbuntuBootstrapScript(options = {}) {
|
|
953
|
-
const {
|
|
954
|
-
runtime = "bun",
|
|
955
|
-
runtimeVersion = "latest",
|
|
956
|
-
systemPackages = [],
|
|
957
|
-
database,
|
|
958
|
-
phpProvision,
|
|
959
|
-
servicesProvision,
|
|
960
|
-
caddyfile,
|
|
961
|
-
rpxProvision,
|
|
962
|
-
baked = false
|
|
963
|
-
} = options;
|
|
964
|
-
const packages = new Set(systemPackages);
|
|
965
|
-
if (database === "sqlite")
|
|
966
|
-
packages.add("sqlite3");
|
|
967
|
-
else if (database === "mysql")
|
|
968
|
-
packages.add("mysql-client");
|
|
969
|
-
else if (database === "postgres")
|
|
970
|
-
packages.add("postgresql-client");
|
|
971
|
-
let script = `#!/bin/bash
|
|
972
|
-
set -euo pipefail
|
|
973
|
-
`;
|
|
974
|
-
if (!baked) {
|
|
975
|
-
script += `
|
|
976
|
-
export DEBIAN_FRONTEND=noninteractive
|
|
977
|
-
apt-get update -y
|
|
978
|
-
apt-get upgrade -y
|
|
979
|
-
apt-get install -y curl tar gzip unzip git ca-certificates
|
|
980
|
-
`;
|
|
981
|
-
if (packages.size > 0) {
|
|
982
|
-
script += `
|
|
983
|
-
apt-get install -y ${[...packages].join(" ")}
|
|
984
|
-
`;
|
|
985
|
-
}
|
|
986
|
-
if (phpProvision && phpProvision.length > 0) {
|
|
987
|
-
script += `
|
|
988
|
-
${phpProvision.join(`
|
|
989
|
-
`)}
|
|
990
|
-
`;
|
|
706
|
+
function buildRpxConfigInternal(sites, options, appBoxes) {
|
|
707
|
+
const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
|
|
708
|
+
const installSlug = options.slug ?? "app";
|
|
709
|
+
const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
|
|
710
|
+
const loadBalancer = options.proxy.loadBalancer;
|
|
711
|
+
const proxies = [];
|
|
712
|
+
const domains = new Set;
|
|
713
|
+
for (const [name, site] of Object.entries(sites)) {
|
|
714
|
+
if (!site || !site.domain)
|
|
715
|
+
continue;
|
|
716
|
+
const kind = resolveSiteKind(site);
|
|
717
|
+
if (kind === "bucket")
|
|
718
|
+
continue;
|
|
719
|
+
const path = normalizeRoutePath(site.path);
|
|
720
|
+
const id = deriveRouteId(site.domain, path);
|
|
721
|
+
const auth = resolveRouteAuth(site);
|
|
722
|
+
if (kind === "redirect") {
|
|
723
|
+
proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
|
|
724
|
+
domains.add(site.domain);
|
|
725
|
+
continue;
|
|
991
726
|
}
|
|
992
|
-
if (
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
727
|
+
if (kind === "server-app") {
|
|
728
|
+
if (typeof site.port !== "number")
|
|
729
|
+
continue;
|
|
730
|
+
const from = resolveServerAppFrom(site.port, appBoxes);
|
|
731
|
+
proxies.push({
|
|
732
|
+
to: site.domain,
|
|
733
|
+
path,
|
|
734
|
+
from,
|
|
735
|
+
id,
|
|
736
|
+
...auth ? { auth } : {},
|
|
737
|
+
...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
|
|
738
|
+
});
|
|
739
|
+
} else {
|
|
740
|
+
proxies.push({
|
|
741
|
+
to: site.domain,
|
|
742
|
+
path,
|
|
743
|
+
static: {
|
|
744
|
+
dir: `${wwwRoot}/${installSlug}-${name}/current`,
|
|
745
|
+
spa: site.spa ?? false,
|
|
746
|
+
pathRewriteStyle: site.pathRewriteStyle ?? "directory"
|
|
747
|
+
},
|
|
748
|
+
cleanUrls: site.pathRewriteStyle !== "flat",
|
|
749
|
+
...auth ? { auth } : {},
|
|
750
|
+
id
|
|
751
|
+
});
|
|
997
752
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
# installer dies with "HOME: unbound variable" partway through (after
|
|
1010
|
-
# the binary is already downloaded, so it looks like a partial success)
|
|
1011
|
-
# and \`set -e\` aborts everything after it: the bun symlink, /var/www +
|
|
1012
|
-
# /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
|
|
1013
|
-
# never run. Confirmed against a real Hetzner box (stacksjs/status#1
|
|
1014
|
-
# Phase 9 e2e deploy) — cloud-init reported success with none of that
|
|
1015
|
-
# actually done.
|
|
1016
|
-
export HOME="\${HOME:-/root}"
|
|
1017
|
-
curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
|
|
1018
|
-
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
|
1019
|
-
echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
|
|
1020
|
-
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> /etc/profile.d/bun.sh
|
|
1021
|
-
`;
|
|
1022
|
-
} else if (runtime === "node") {
|
|
1023
|
-
const nodeMajor = runtimeVersion === "latest" || !runtimeVersion ? "20" : runtimeVersion.split(".")[0];
|
|
1024
|
-
script += `
|
|
1025
|
-
curl -fsSL https://deb.nodesource.com/setup_${nodeMajor}.x | bash -
|
|
1026
|
-
apt-get install -y nodejs
|
|
1027
|
-
ln -sf /usr/bin/node /usr/local/bin/node
|
|
1028
|
-
ln -sf /usr/bin/npm /usr/local/bin/npm
|
|
1029
|
-
`;
|
|
1030
|
-
} else if (runtime === "deno") {
|
|
1031
|
-
script += `
|
|
1032
|
-
curl -fsSL https://deno.land/install.sh | sh
|
|
1033
|
-
ln -sf /root/.deno/bin/deno /usr/local/bin/deno
|
|
1034
|
-
`;
|
|
753
|
+
domains.add(site.domain);
|
|
754
|
+
}
|
|
755
|
+
if (options.proxy.autoWww !== false) {
|
|
756
|
+
for (const domain of [...domains]) {
|
|
757
|
+
if (domain.split(".").length !== 2)
|
|
758
|
+
continue;
|
|
759
|
+
const wwwDomain = `www.${domain}`;
|
|
760
|
+
if (domains.has(wwwDomain))
|
|
761
|
+
continue;
|
|
762
|
+
proxies.push({ to: wwwDomain, redirect: { to: `https://${domain}` }, id: deriveRouteId(wwwDomain) });
|
|
763
|
+
domains.add(wwwDomain);
|
|
1035
764
|
}
|
|
1036
765
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
[Unit]
|
|
1057
|
-
Description=Caddy
|
|
1058
|
-
Documentation=https://caddyserver.com/docs/
|
|
1059
|
-
After=network.target network-online.target
|
|
1060
|
-
Requires=network-online.target
|
|
1061
|
-
|
|
1062
|
-
[Service]
|
|
1063
|
-
Type=notify
|
|
1064
|
-
User=caddy
|
|
1065
|
-
Group=caddy
|
|
1066
|
-
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
|
1067
|
-
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
|
|
1068
|
-
TimeoutStopSec=5s
|
|
1069
|
-
LimitNOFILE=1048576
|
|
1070
|
-
PrivateTmp=true
|
|
1071
|
-
ProtectSystem=full
|
|
1072
|
-
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
1073
|
-
|
|
1074
|
-
[Install]
|
|
1075
|
-
WantedBy=multi-user.target
|
|
1076
|
-
CADDY_UNIT_EOF
|
|
1077
|
-
|
|
1078
|
-
cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
|
|
1079
|
-
${escaped}
|
|
1080
|
-
CADDY_CONFIG_EOF
|
|
1081
|
-
|
|
1082
|
-
systemctl daemon-reload
|
|
1083
|
-
systemctl enable caddy
|
|
1084
|
-
systemctl start caddy
|
|
1085
|
-
`;
|
|
766
|
+
proxies.sort((a, b) => {
|
|
767
|
+
if (a.to !== b.to)
|
|
768
|
+
return a.to.localeCompare(b.to);
|
|
769
|
+
return (b.path?.length ?? 0) - (a.path?.length ?? 0);
|
|
770
|
+
});
|
|
771
|
+
const config = {
|
|
772
|
+
proxies,
|
|
773
|
+
productionCerts: { certsDir },
|
|
774
|
+
https: true,
|
|
775
|
+
hostsManagement: false,
|
|
776
|
+
cleanup: { hosts: false, certs: false }
|
|
777
|
+
};
|
|
778
|
+
if (options.proxy.onDemandTls && domains.size > 0) {
|
|
779
|
+
config.onDemandTls = {
|
|
780
|
+
enabled: true,
|
|
781
|
+
allowedSuffixes: [...domains],
|
|
782
|
+
email: options.proxy.onDemandTlsEmail,
|
|
783
|
+
certsDir
|
|
784
|
+
};
|
|
1086
785
|
}
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
1089
|
-
script += `
|
|
1090
|
-
${body.join(`
|
|
1091
|
-
`)}
|
|
1092
|
-
`;
|
|
786
|
+
if (options.proxy.onDemandTls) {
|
|
787
|
+
config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
|
|
1093
788
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
789
|
+
const cdn = options.proxy.cdn;
|
|
790
|
+
if (cdn?.secret && cdn.frontedHosts.length > 0) {
|
|
791
|
+
config.originGuard = {
|
|
792
|
+
header: cdn.secretHeader ?? "X-Origin-Verify",
|
|
793
|
+
value: cdn.secret,
|
|
794
|
+
hosts: cdn.frontedHosts
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
return config;
|
|
798
|
+
}
|
|
799
|
+
function buildRpxConfig(sites, options) {
|
|
800
|
+
return buildRpxConfigInternal(sites, options);
|
|
1098
801
|
}
|
|
802
|
+
function buildRpxLbConfig(sites, appBoxes, options) {
|
|
803
|
+
return buildRpxConfigInternal(sites, options, appBoxes);
|
|
804
|
+
}
|
|
805
|
+
function renderRpxLauncher(config) {
|
|
806
|
+
const json = JSON.stringify(config, null, 2);
|
|
807
|
+
return `// Generated by ts-cloud — rpx reverse-proxy gateway.
|
|
808
|
+
// Routes are derived from the \`sites\` model on every \`buddy deploy\`.
|
|
809
|
+
import { startProxies } from '@stacksjs/rpx'
|
|
1099
810
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
{ port: 22, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1105
|
-
{ port: 80, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1106
|
-
{ port: 443, protocol: "tcp", cidr: "0.0.0.0/0" }
|
|
1107
|
-
];
|
|
1108
|
-
const extra = new Set;
|
|
1109
|
-
for (const site of Object.values(config.sites || {})) {
|
|
1110
|
-
if (site && typeof site.port === "number" && ![22, 80, 443].includes(site.port))
|
|
1111
|
-
extra.add(site.port);
|
|
1112
|
-
}
|
|
1113
|
-
for (const port of extra)
|
|
1114
|
-
rules.push({ port, protocol: "tcp", cidr: "0.0.0.0/0" });
|
|
1115
|
-
return rules;
|
|
811
|
+
const config = ${json} as const
|
|
812
|
+
|
|
813
|
+
await startProxies(config as any)
|
|
814
|
+
`;
|
|
1116
815
|
}
|
|
1117
|
-
function
|
|
1118
|
-
|
|
1119
|
-
const provision = buildComputeProvisionScripts(config);
|
|
1120
|
-
return buildUbuntuBootstrapScript({
|
|
1121
|
-
runtime: provision.runtime,
|
|
1122
|
-
runtimeVersion: provision.runtimeVersion,
|
|
1123
|
-
systemPackages: compute.systemPackages,
|
|
1124
|
-
database: config.infrastructure?.database,
|
|
1125
|
-
phpProvision: provision.phpProvision,
|
|
1126
|
-
servicesProvision: provision.servicesProvision,
|
|
1127
|
-
baked: compute.bakedImage === true
|
|
1128
|
-
});
|
|
816
|
+
function usesRpxProxy(compute) {
|
|
817
|
+
return compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx";
|
|
1129
818
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
819
|
+
var RPX_DIR = "/etc/rpx";
|
|
820
|
+
var RPX_INSTALL_DIR = "/opt/rpx-gateway";
|
|
821
|
+
var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
|
|
822
|
+
var RPX_SERVICE_NAME = "rpx-gateway.service";
|
|
823
|
+
var RPX_SITES_DIR = "/etc/rpx/sites.d";
|
|
824
|
+
function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
|
|
825
|
+
return `// Generated by ts-cloud — rpx gateway assembler.
|
|
826
|
+
// Merges every app's fragment in ${sitesDir} so independent deploys compose
|
|
827
|
+
// without clobbering each other. Each deploy writes only its own <slug>.json.
|
|
828
|
+
import { startProxies } from '@stacksjs/rpx'
|
|
829
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
830
|
+
|
|
831
|
+
const dir = ${JSON.stringify(sitesDir)}
|
|
832
|
+
const proxies = []
|
|
833
|
+
const seen = new Set()
|
|
834
|
+
const suffixes = new Set()
|
|
835
|
+
const guardHosts = new Set()
|
|
836
|
+
let email
|
|
837
|
+
let certsDir = ${JSON.stringify(defaultCertsDir)}
|
|
838
|
+
let acmeChallengeWebroot
|
|
839
|
+
let guard
|
|
840
|
+
let files = []
|
|
841
|
+
try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
|
|
842
|
+
for (const f of files) {
|
|
843
|
+
let frag
|
|
844
|
+
try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
|
|
845
|
+
for (const p of frag.proxies ?? []) {
|
|
846
|
+
const key = p.id || (p.to + (p.path ?? ''))
|
|
847
|
+
if (seen.has(key)) continue
|
|
848
|
+
seen.add(key)
|
|
849
|
+
proxies.push(p)
|
|
850
|
+
}
|
|
851
|
+
for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
|
|
852
|
+
email ??= frag.onDemandTls?.email
|
|
853
|
+
if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
|
|
854
|
+
acmeChallengeWebroot ??= frag.acmeChallengeWebroot
|
|
855
|
+
if (frag.originGuard) {
|
|
856
|
+
guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
|
|
857
|
+
for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
|
|
858
|
+
}
|
|
1132
859
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
860
|
+
const config = {
|
|
861
|
+
proxies,
|
|
862
|
+
productionCerts: { certsDir },
|
|
863
|
+
https: true,
|
|
864
|
+
hostsManagement: false,
|
|
865
|
+
cleanup: { hosts: false, certs: false },
|
|
866
|
+
...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
|
|
867
|
+
...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
|
|
868
|
+
...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
|
|
1135
869
|
}
|
|
1136
870
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
871
|
+
await startProxies(config)
|
|
872
|
+
`;
|
|
873
|
+
}
|
|
874
|
+
function writeFileHeredoc(path, content, delimiter) {
|
|
875
|
+
return [
|
|
876
|
+
`cat > ${path} <<'${delimiter}'`,
|
|
877
|
+
content,
|
|
878
|
+
delimiter
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
function certDomainsForConfig(config) {
|
|
882
|
+
const seen = new Set;
|
|
883
|
+
for (const r of config.proxies) {
|
|
884
|
+
const host = r.to;
|
|
885
|
+
if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
|
|
886
|
+
continue;
|
|
887
|
+
seen.add(host);
|
|
1145
888
|
}
|
|
889
|
+
return [...seen];
|
|
890
|
+
}
|
|
891
|
+
function buildCertManagementCommands(options) {
|
|
892
|
+
const { config, proxy } = options;
|
|
893
|
+
const webroot = config.acmeChallengeWebroot;
|
|
894
|
+
const domains = certDomainsForConfig(config);
|
|
895
|
+
if (!proxy.onDemandTls || !webroot || domains.length === 0)
|
|
896
|
+
return [];
|
|
897
|
+
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
898
|
+
const version = proxy.version ?? "latest";
|
|
899
|
+
const certsDir = config.productionCerts.certsDir;
|
|
900
|
+
const email = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
|
|
901
|
+
const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
|
|
902
|
+
const csv = domains.join(",");
|
|
903
|
+
const spaced = domains.join(" ");
|
|
904
|
+
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
905
|
+
const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
|
|
906
|
+
const renewServiceName = `rpx-cert-renew-${slug}.service`;
|
|
907
|
+
const renewTimerName = `rpx-cert-renew-${slug}.timer`;
|
|
908
|
+
const renewScript = [
|
|
909
|
+
"#!/bin/sh",
|
|
910
|
+
"# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
|
|
911
|
+
"# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
|
|
912
|
+
"# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
|
|
913
|
+
"set -u",
|
|
914
|
+
`CERTS='${certsDir}'`,
|
|
915
|
+
`WEBROOT='${webroot}'`,
|
|
916
|
+
`EMAIL='${email}'`,
|
|
917
|
+
`TLSX="${tlsxCli}"`,
|
|
918
|
+
`DOMAINS='${csv}'`,
|
|
919
|
+
'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
920
|
+
`for d in ${spaced}; do`,
|
|
921
|
+
' [ -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)"',
|
|
922
|
+
"done",
|
|
923
|
+
'$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
|
|
924
|
+
'rm -f "$CERTS"/*.chain.crt',
|
|
925
|
+
'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
926
|
+
`[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
|
|
927
|
+
].join(`
|
|
928
|
+
`);
|
|
929
|
+
const renewService = [
|
|
930
|
+
"[Unit]",
|
|
931
|
+
`Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
|
|
932
|
+
`After=network-online.target ${RPX_SERVICE_NAME}`,
|
|
933
|
+
"Wants=network-online.target",
|
|
934
|
+
"",
|
|
935
|
+
"[Service]",
|
|
936
|
+
"Type=oneshot",
|
|
937
|
+
`ExecStart=${renewScriptPath}`
|
|
938
|
+
].join(`
|
|
939
|
+
`);
|
|
940
|
+
const renewTimer = [
|
|
941
|
+
"[Unit]",
|
|
942
|
+
`Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
|
|
943
|
+
"",
|
|
944
|
+
"[Timer]",
|
|
945
|
+
"OnCalendar=*-*-* 03:30:00",
|
|
946
|
+
"RandomizedDelaySec=1h",
|
|
947
|
+
"Persistent=true",
|
|
948
|
+
"",
|
|
949
|
+
"[Install]",
|
|
950
|
+
"WantedBy=timers.target"
|
|
951
|
+
].join(`
|
|
952
|
+
`);
|
|
953
|
+
return [
|
|
954
|
+
`mkdir -p ${webroot}`,
|
|
955
|
+
`(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/tlsx@${version}) || true`,
|
|
956
|
+
...writeFileHeredoc(renewScriptPath, renewScript, "TS_CLOUD_RENEW_EOF"),
|
|
957
|
+
`chmod +x ${renewScriptPath}`,
|
|
958
|
+
...writeFileHeredoc(`/etc/systemd/system/${renewServiceName}`, renewService, "TS_CLOUD_RENEW_SVC_EOF"),
|
|
959
|
+
...writeFileHeredoc(`/etc/systemd/system/${renewTimerName}`, renewTimer, "TS_CLOUD_RENEW_TIMER_EOF"),
|
|
960
|
+
"systemctl daemon-reload",
|
|
961
|
+
`systemctl enable --now ${renewTimerName} || true`,
|
|
962
|
+
`${renewScriptPath} || true`
|
|
963
|
+
];
|
|
964
|
+
}
|
|
965
|
+
function buildRpxProvisionScript(options) {
|
|
966
|
+
const { config, proxy } = options;
|
|
967
|
+
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
968
|
+
const version = proxy.version ?? "latest";
|
|
969
|
+
const certsDir = config.productionCerts.certsDir;
|
|
970
|
+
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
971
|
+
const fragment = JSON.stringify({ slug, ...config }, null, 2);
|
|
972
|
+
const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
|
|
973
|
+
const upstreamTimeout = proxy.upstreamTimeout ?? 60;
|
|
974
|
+
const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
|
|
975
|
+
if (typeof proxy.maxUpstreamConns === "number")
|
|
976
|
+
poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
|
|
977
|
+
return [
|
|
978
|
+
"set -euo pipefail",
|
|
979
|
+
`mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
|
|
980
|
+
`rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
|
|
981
|
+
`(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
|
|
982
|
+
`ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
|
|
983
|
+
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
|
|
984
|
+
...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
|
|
985
|
+
...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
|
|
986
|
+
"[Unit]",
|
|
987
|
+
"Description=rpx reverse-proxy gateway (managed by ts-cloud)",
|
|
988
|
+
"After=network.target network-online.target",
|
|
989
|
+
"Wants=network-online.target",
|
|
990
|
+
"",
|
|
991
|
+
"[Service]",
|
|
992
|
+
"Type=simple",
|
|
993
|
+
`ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
|
|
994
|
+
`WorkingDirectory=${RPX_INSTALL_DIR}`,
|
|
995
|
+
`Environment=BUN_INSTALL=/root/.bun`,
|
|
996
|
+
...poolEnv,
|
|
997
|
+
"Restart=always",
|
|
998
|
+
"RestartSec=5",
|
|
999
|
+
"LimitNOFILE=1048576",
|
|
1000
|
+
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
|
1001
|
+
"",
|
|
1002
|
+
"[Install]",
|
|
1003
|
+
"WantedBy=multi-user.target"
|
|
1004
|
+
].join(`
|
|
1005
|
+
`), "TS_CLOUD_RPX_UNIT_EOF"),
|
|
1006
|
+
"systemctl daemon-reload",
|
|
1007
|
+
"systemctl disable --now bun-gateway.service 2>/dev/null || true",
|
|
1008
|
+
"systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
|
|
1009
|
+
`systemctl enable ${RPX_SERVICE_NAME}`,
|
|
1010
|
+
`systemctl restart ${RPX_SERVICE_NAME}`,
|
|
1011
|
+
...buildCertManagementCommands(options)
|
|
1012
|
+
];
|
|
1146
1013
|
}
|
|
1147
1014
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
})
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1015
|
+
// src/drivers/shared/ufw.ts
|
|
1016
|
+
var UFW_BASE_PORTS = [80, 443];
|
|
1017
|
+
function buildUfwScript(firewall = {}) {
|
|
1018
|
+
if (firewall.enabled === false)
|
|
1019
|
+
return [];
|
|
1020
|
+
const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
|
|
1021
|
+
const lines = [
|
|
1022
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
1023
|
+
"apt-get install -y ufw",
|
|
1024
|
+
"ufw default deny incoming",
|
|
1025
|
+
"ufw default allow outgoing",
|
|
1026
|
+
"ufw allow OpenSSH"
|
|
1027
|
+
];
|
|
1028
|
+
for (const port of ports)
|
|
1029
|
+
lines.push(`ufw allow ${port}/tcp`);
|
|
1030
|
+
lines.push("ufw --force enable");
|
|
1031
|
+
return lines;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/drivers/shared/maintenance.ts
|
|
1035
|
+
function buildAutoUpdatesScript(enabled2 = true) {
|
|
1036
|
+
if (!enabled2)
|
|
1037
|
+
return [];
|
|
1038
|
+
return [
|
|
1039
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
1040
|
+
"apt-get install -y unattended-upgrades",
|
|
1041
|
+
"cat > /etc/apt/apt.conf.d/20auto-upgrades <<'TS_CLOUD_AUTOUPD_EOF'",
|
|
1042
|
+
'APT::Periodic::Update-Package-Lists "1";',
|
|
1043
|
+
'APT::Periodic::Unattended-Upgrade "1";',
|
|
1044
|
+
'APT::Periodic::Download-Upgradeable-Packages "1";',
|
|
1045
|
+
'APT::Periodic::AutocleanInterval "7";',
|
|
1046
|
+
"TS_CLOUD_AUTOUPD_EOF",
|
|
1047
|
+
"systemctl enable unattended-upgrades 2>/dev/null || true",
|
|
1048
|
+
"systemctl restart unattended-upgrades 2>/dev/null || true"
|
|
1049
|
+
];
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/drivers/shared/monitoring.ts
|
|
1053
|
+
var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
|
|
1054
|
+
var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
|
|
1055
|
+
var DEFAULT_CPU_LOAD_PER_CORE = 2;
|
|
1056
|
+
var DEFAULT_MEM_PERCENT = 90;
|
|
1057
|
+
var DEFAULT_DISK_PERCENT = 90;
|
|
1058
|
+
var SERVICE_PROBES = [
|
|
1059
|
+
["nginx", 80],
|
|
1060
|
+
["phpFpm", 9074],
|
|
1061
|
+
["mysql", 3306],
|
|
1062
|
+
["postgres", 5432],
|
|
1063
|
+
["redis", 6379],
|
|
1064
|
+
["meilisearch", 7700]
|
|
1065
|
+
];
|
|
1066
|
+
function resolveMonitoring(monitoring = true) {
|
|
1067
|
+
const obj = typeof monitoring === "object" ? monitoring : {};
|
|
1068
|
+
const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
|
|
1069
|
+
return {
|
|
1070
|
+
enabled: enabled2,
|
|
1071
|
+
cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
|
|
1072
|
+
memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
|
|
1073
|
+
diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function buildMonitoringScript(monitoring = true) {
|
|
1077
|
+
const { enabled: enabled2, cpuLoadPerCore, memPercent, diskPercent } = resolveMonitoring(monitoring);
|
|
1078
|
+
if (!enabled2)
|
|
1079
|
+
return [];
|
|
1080
|
+
const probeLines = SERVICE_PROBES.map(([name, port]) => `SVC_${name.toUpperCase()}=$(probe ${port})`);
|
|
1081
|
+
const servicesJson = SERVICE_PROBES.map(([name]) => `"${name}":"$SVC_${name.toUpperCase()}"`).join(",");
|
|
1082
|
+
return [
|
|
1083
|
+
"mkdir -p /var/lib/ts-cloud",
|
|
1084
|
+
"cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
|
|
1085
|
+
"#!/bin/bash",
|
|
1086
|
+
"set -uo pipefail",
|
|
1087
|
+
"LOAD=$(cut -d' ' -f1 /proc/loadavg)",
|
|
1088
|
+
"CPUS=$(nproc)",
|
|
1089
|
+
"MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
|
|
1090
|
+
"MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
|
|
1091
|
+
"SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
|
|
1092
|
+
"SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
|
|
1093
|
+
`DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
|
|
1094
|
+
"UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
|
|
1095
|
+
`RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
|
|
1096
|
+
`TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
|
|
1097
|
+
"probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
|
|
1098
|
+
...probeLines,
|
|
1099
|
+
"LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
|
|
1100
|
+
"SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
|
|
1101
|
+
"UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
|
|
1102
|
+
"MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
|
|
1103
|
+
`cat > ${METRICS_PATH}.tmp <<JSON`,
|
|
1104
|
+
'{"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 + "}}",
|
|
1105
|
+
"JSON",
|
|
1106
|
+
`mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
|
|
1107
|
+
'ALERTS=""',
|
|
1108
|
+
`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`,
|
|
1109
|
+
`if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
|
|
1110
|
+
`if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
|
|
1111
|
+
`PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
|
|
1112
|
+
'if [ -n "$ALERTS" ]; then',
|
|
1113
|
+
' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
|
|
1114
|
+
` echo alert > ${ALERT_STATE_PATH}`,
|
|
1115
|
+
"else",
|
|
1116
|
+
' 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',
|
|
1117
|
+
` echo ok > ${ALERT_STATE_PATH}`,
|
|
1118
|
+
"fi",
|
|
1119
|
+
"TS_CLOUD_METRICS_EOF",
|
|
1120
|
+
"chmod +x /usr/local/bin/ts-cloud-metrics.sh",
|
|
1121
|
+
"cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
|
|
1122
|
+
"[Unit]",
|
|
1123
|
+
"Description=ts-cloud metrics collector",
|
|
1124
|
+
"",
|
|
1125
|
+
"[Service]",
|
|
1126
|
+
"Type=oneshot",
|
|
1127
|
+
"ExecStart=/usr/local/bin/ts-cloud-metrics.sh",
|
|
1128
|
+
"TS_CLOUD_METRICS_SVC_EOF",
|
|
1129
|
+
"cat > /etc/systemd/system/ts-cloud-metrics.timer <<'TS_CLOUD_METRICS_TMR_EOF'",
|
|
1130
|
+
"[Unit]",
|
|
1131
|
+
"Description=Run ts-cloud metrics collector every minute",
|
|
1132
|
+
"",
|
|
1133
|
+
"[Timer]",
|
|
1134
|
+
"OnBootSec=60",
|
|
1135
|
+
"OnUnitActiveSec=60",
|
|
1136
|
+
"",
|
|
1137
|
+
"[Install]",
|
|
1138
|
+
"WantedBy=timers.target",
|
|
1139
|
+
"TS_CLOUD_METRICS_TMR_EOF",
|
|
1140
|
+
"systemctl daemon-reload",
|
|
1141
|
+
"systemctl enable ts-cloud-metrics.timer",
|
|
1142
|
+
"systemctl start ts-cloud-metrics.timer"
|
|
1143
|
+
];
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/drivers/shared/ssh-keys.ts
|
|
1147
|
+
var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
|
|
1148
|
+
var BLOCK_END = "# <<< ts-cloud managed keys <<<";
|
|
1149
|
+
var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
1150
|
+
function buildAuthorizedKeysScript(keys = [], options = {}) {
|
|
1151
|
+
if (keys.length === 0)
|
|
1152
|
+
return [];
|
|
1153
|
+
const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
|
|
1154
|
+
const dir = path.replace(/\/[^/]*$/, "");
|
|
1155
|
+
const block = [
|
|
1156
|
+
BLOCK_BEGIN,
|
|
1157
|
+
...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
|
|
1158
|
+
BLOCK_END
|
|
1159
|
+
].join(`
|
|
1160
|
+
`);
|
|
1161
|
+
return [
|
|
1162
|
+
`mkdir -p ${dir}`,
|
|
1163
|
+
`touch ${path}`,
|
|
1164
|
+
`sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
|
|
1165
|
+
`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
|
|
1166
|
+
block,
|
|
1167
|
+
"TS_CLOUD_KEYS_EOF",
|
|
1168
|
+
`chmod 600 ${path}`
|
|
1169
|
+
];
|
|
1170
|
+
}
|
|
1171
|
+
function escapeSed(value) {
|
|
1172
|
+
return value.replace(/[.*[\]\\/^$]/g, "\\$&");
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/drivers/shared/notifications.ts
|
|
1176
|
+
function wantsEvent(config, event) {
|
|
1177
|
+
return !config.events || config.events.includes(event);
|
|
1178
|
+
}
|
|
1179
|
+
async function sendNotifications(config, event, message, options = {}) {
|
|
1180
|
+
if (!config)
|
|
1181
|
+
return [];
|
|
1182
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1183
|
+
const attempted = [];
|
|
1184
|
+
const tasks = [];
|
|
1185
|
+
const post = (url, body) => fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {
|
|
1186
|
+
return;
|
|
1187
|
+
});
|
|
1188
|
+
if (config.slack?.webhookUrl && wantsEvent(config, event)) {
|
|
1189
|
+
attempted.push("slack");
|
|
1190
|
+
tasks.push(post(config.slack.webhookUrl, { text: message }));
|
|
1236
1191
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1192
|
+
if (config.discord?.webhookUrl && wantsEvent(config, event)) {
|
|
1193
|
+
attempted.push("discord");
|
|
1194
|
+
tasks.push(post(config.discord.webhookUrl, { content: message }));
|
|
1195
|
+
}
|
|
1196
|
+
if (config.telegram?.botToken && config.telegram.chatId && wantsEvent(config, event)) {
|
|
1197
|
+
attempted.push("telegram");
|
|
1198
|
+
const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
|
|
1199
|
+
tasks.push(post(url, { chat_id: config.telegram.chatId, text: message }));
|
|
1200
|
+
}
|
|
1201
|
+
if (config.webhook?.url && wantsEvent(config, event)) {
|
|
1202
|
+
attempted.push("webhook");
|
|
1203
|
+
if ((config.webhook.method || "POST") === "GET") {
|
|
1204
|
+
const sep = config.webhook.url.includes("?") ? "&" : "?";
|
|
1205
|
+
const url = `${config.webhook.url}${sep}event=${encodeURIComponent(event)}&message=${encodeURIComponent(message)}`;
|
|
1206
|
+
tasks.push(fetchImpl(url, { method: "GET" }).catch(() => {
|
|
1248
1207
|
return;
|
|
1249
|
-
}))
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
|
|
1253
|
-
const groupId = found.SecurityGroups?.[0]?.GroupId;
|
|
1254
|
-
if (groupId) {
|
|
1255
|
-
for (let i = 0;i < 10; i++) {
|
|
1256
|
-
try {
|
|
1257
|
-
await ec2.deleteSecurityGroup(groupId);
|
|
1258
|
-
destroyed.push(`security group ${sgName}`);
|
|
1259
|
-
break;
|
|
1260
|
-
} catch {
|
|
1261
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1208
|
+
}));
|
|
1209
|
+
} else {
|
|
1210
|
+
tasks.push(post(config.webhook.url, { event, message }));
|
|
1264
1211
|
}
|
|
1265
|
-
return { destroyed };
|
|
1266
1212
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
const cfn = new CloudFormationClient(region);
|
|
1271
|
-
try {
|
|
1272
|
-
const outputs = await cfn.getStackOutputs(stackName);
|
|
1273
|
-
return {
|
|
1274
|
-
deployBucketName: outputs.deployBucketName,
|
|
1275
|
-
appInstanceId: outputs.appInstanceId,
|
|
1276
|
-
appPublicIp: outputs.appPublicIp,
|
|
1277
|
-
sshUser: "ec2-user"
|
|
1278
|
-
};
|
|
1279
|
-
} catch (err) {
|
|
1280
|
-
if (!/does not exist|ValidationError/i.test(err instanceof Error ? err.message : ""))
|
|
1281
|
-
throw err;
|
|
1282
|
-
const targets = await this.findComputeTargets({
|
|
1283
|
-
slug: options.config.project.slug,
|
|
1284
|
-
environment: options.environment,
|
|
1285
|
-
role: "app"
|
|
1286
|
-
});
|
|
1287
|
-
const first = targets[0];
|
|
1288
|
-
return {
|
|
1289
|
-
appInstanceId: first?.id,
|
|
1290
|
-
appPublicIp: first?.publicIp,
|
|
1291
|
-
sshUser: "ubuntu",
|
|
1292
|
-
deployStoragePath: "/var/ts-cloud/staging"
|
|
1293
|
-
};
|
|
1294
|
-
}
|
|
1213
|
+
if (config.email?.to && wantsEvent(config, event)) {
|
|
1214
|
+
attempted.push("email");
|
|
1215
|
+
tasks.push(sendEmailNotification(config.email, event, message));
|
|
1295
1216
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
await s3.putObject({
|
|
1308
|
-
bucket,
|
|
1309
|
-
key: options.remoteKey,
|
|
1310
|
-
body: readFileSync(options.localPath),
|
|
1311
|
-
contentType: "application/gzip"
|
|
1217
|
+
await Promise.all(tasks);
|
|
1218
|
+
return attempted;
|
|
1219
|
+
}
|
|
1220
|
+
async function sendEmailNotification(email, event, message) {
|
|
1221
|
+
try {
|
|
1222
|
+
const mod = await import("./chunk-pqfzdg68.js");
|
|
1223
|
+
await mod.email.send({
|
|
1224
|
+
to: email.to,
|
|
1225
|
+
from: email.from,
|
|
1226
|
+
subject: `[ts-cloud] ${event}`,
|
|
1227
|
+
text: message
|
|
1312
1228
|
});
|
|
1313
|
-
|
|
1229
|
+
} catch {}
|
|
1230
|
+
}
|
|
1231
|
+
function resolveNotifications(project, site) {
|
|
1232
|
+
return site ?? project;
|
|
1233
|
+
}
|
|
1234
|
+
function buildNotifierScript(config) {
|
|
1235
|
+
if (!config)
|
|
1236
|
+
return [];
|
|
1237
|
+
const curls = [];
|
|
1238
|
+
if (config.slack?.webhookUrl)
|
|
1239
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"text\\":\\"$MSG\\"}" ${config.slack.webhookUrl} || true`);
|
|
1240
|
+
if (config.discord?.webhookUrl)
|
|
1241
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"content\\":\\"$MSG\\"}" ${config.discord.webhookUrl} || true`);
|
|
1242
|
+
if (config.telegram?.botToken && config.telegram.chatId)
|
|
1243
|
+
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`);
|
|
1244
|
+
if (config.webhook?.url)
|
|
1245
|
+
curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"message\\":\\"$MSG\\"}" ${config.webhook.url} || true`);
|
|
1246
|
+
if (curls.length === 0)
|
|
1247
|
+
return [];
|
|
1248
|
+
return [
|
|
1249
|
+
"cat > /usr/local/bin/ts-cloud-notify <<'TS_CLOUD_NOTIFY_EOF'",
|
|
1250
|
+
"#!/bin/bash",
|
|
1251
|
+
'MSG="$1"',
|
|
1252
|
+
...curls,
|
|
1253
|
+
"TS_CLOUD_NOTIFY_EOF",
|
|
1254
|
+
"chmod +x /usr/local/bin/ts-cloud-notify"
|
|
1255
|
+
];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/drivers/shared/backups.ts
|
|
1259
|
+
var BACKUP_OUTPUT_DIR = "/var/backups/ts-cloud";
|
|
1260
|
+
var BACKUP_CONFIG_PATH = "/etc/ts-cloud/backups.config.ts";
|
|
1261
|
+
var BACKUP_CRON_PATH = "/etc/cron.d/ts-cloud-backups";
|
|
1262
|
+
var BACKUP_RUNNER_PATH = "/usr/local/bin/ts-cloud-backup.sh";
|
|
1263
|
+
function backupEntryFor(database) {
|
|
1264
|
+
if (!database.name)
|
|
1265
|
+
return null;
|
|
1266
|
+
const host = database.host || "127.0.0.1";
|
|
1267
|
+
const isPostgres = database.engine === "postgres";
|
|
1268
|
+
const type = isPostgres ? "postgresql" : "mysql";
|
|
1269
|
+
const port = database.port ?? (isPostgres ? 5432 : 3306);
|
|
1270
|
+
const ts = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1271
|
+
return [
|
|
1272
|
+
" {",
|
|
1273
|
+
` type: '${type}',`,
|
|
1274
|
+
` name: '${ts(database.name)}',`,
|
|
1275
|
+
" connection: {",
|
|
1276
|
+
` hostname: '${ts(host)}',`,
|
|
1277
|
+
` port: ${port},`,
|
|
1278
|
+
` database: '${ts(database.name)}',`,
|
|
1279
|
+
` username: '${ts(database.username || database.name)}',`,
|
|
1280
|
+
` password: '${ts(database.password || "")}',`,
|
|
1281
|
+
" ssl: false,",
|
|
1282
|
+
" },",
|
|
1283
|
+
" includeSchema: true,",
|
|
1284
|
+
" includeData: true,",
|
|
1285
|
+
" },"
|
|
1286
|
+
].join(`
|
|
1287
|
+
`);
|
|
1288
|
+
}
|
|
1289
|
+
function buildBackupsConfigTs(database, backups) {
|
|
1290
|
+
const entry = database ? backupEntryFor(database) : null;
|
|
1291
|
+
const destinations = backups.bucket ? [
|
|
1292
|
+
" destinations: [",
|
|
1293
|
+
" {",
|
|
1294
|
+
" type: 's3',",
|
|
1295
|
+
` bucket: '${backups.bucket}',`,
|
|
1296
|
+
" prefix: 'db-backups',",
|
|
1297
|
+
...backups.endpoint ? [` endpoint: '${backups.endpoint}',`] : [],
|
|
1298
|
+
" optional: false,",
|
|
1299
|
+
" },",
|
|
1300
|
+
" ],"
|
|
1301
|
+
] : [];
|
|
1302
|
+
return [
|
|
1303
|
+
"import type { BackupConfig } from 'ts-backups'",
|
|
1304
|
+
"",
|
|
1305
|
+
"const config: BackupConfig = {",
|
|
1306
|
+
" verbose: true,",
|
|
1307
|
+
` outputPath: '${BACKUP_OUTPUT_DIR}',`,
|
|
1308
|
+
" retention: {",
|
|
1309
|
+
` count: ${backups.retentionCount ?? 5},`,
|
|
1310
|
+
` maxAge: ${backups.retentionDays ?? 30},`,
|
|
1311
|
+
" },",
|
|
1312
|
+
" databases: [",
|
|
1313
|
+
...entry ? [entry] : [],
|
|
1314
|
+
" ],",
|
|
1315
|
+
...destinations,
|
|
1316
|
+
"}",
|
|
1317
|
+
"",
|
|
1318
|
+
"export default config",
|
|
1319
|
+
""
|
|
1320
|
+
].join(`
|
|
1321
|
+
`);
|
|
1322
|
+
}
|
|
1323
|
+
function buildBackupProvisionScript(options) {
|
|
1324
|
+
const { database, backups } = options;
|
|
1325
|
+
if (!backups.enabled)
|
|
1326
|
+
return [];
|
|
1327
|
+
const schedule = backups.schedule || "0 2 * * *";
|
|
1328
|
+
const configTs = buildBackupsConfigTs(database, backups);
|
|
1329
|
+
return [
|
|
1330
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
1331
|
+
`mkdir -p /etc/ts-cloud ${BACKUP_OUTPUT_DIR}`,
|
|
1332
|
+
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf /root/.bun/bin/bun /usr/local/bin/bun)",
|
|
1333
|
+
"bun add -g ts-backups || true",
|
|
1334
|
+
`cat > ${BACKUP_CONFIG_PATH} <<'TS_CLOUD_BACKUP_CFG_EOF'`,
|
|
1335
|
+
configTs.replace(/\n$/, ""),
|
|
1336
|
+
"TS_CLOUD_BACKUP_CFG_EOF",
|
|
1337
|
+
`cat > ${BACKUP_RUNNER_PATH} <<'TS_CLOUD_BACKUP_RUN_EOF'`,
|
|
1338
|
+
"#!/bin/bash",
|
|
1339
|
+
"set -uo pipefail",
|
|
1340
|
+
'export PATH="/root/.bun/bin:/usr/local/bin:$PATH"',
|
|
1341
|
+
'notify() { [ -x /usr/local/bin/ts-cloud-notify ] && /usr/local/bin/ts-cloud-notify "$1" || true; }',
|
|
1342
|
+
"cd /etc/ts-cloud",
|
|
1343
|
+
'if ! ts-backups backup --config /etc/ts-cloud/backups.config.ts; then notify "❌ ts-cloud backup failed"; exit 1; fi',
|
|
1344
|
+
"TS_CLOUD_BACKUP_RUN_EOF",
|
|
1345
|
+
`chmod +x ${BACKUP_RUNNER_PATH}`,
|
|
1346
|
+
`cat > ${BACKUP_CRON_PATH} <<'TS_CLOUD_BACKUP_CRON_EOF'`,
|
|
1347
|
+
`${schedule} root ${BACKUP_RUNNER_PATH} >> /var/log/ts-cloud-backup.log 2>&1`,
|
|
1348
|
+
"TS_CLOUD_BACKUP_CRON_EOF",
|
|
1349
|
+
`chmod 644 ${BACKUP_CRON_PATH}`
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
function engineSocket(engine) {
|
|
1353
|
+
return engine === "mariadb" ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
|
|
1354
|
+
}
|
|
1355
|
+
function buildBackupRestoreScript(database, options = {}) {
|
|
1356
|
+
if (!database?.name)
|
|
1357
|
+
return [];
|
|
1358
|
+
const name = database.name;
|
|
1359
|
+
const isPg = database.engine === "postgres";
|
|
1360
|
+
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)"`;
|
|
1361
|
+
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}"`;
|
|
1362
|
+
return [
|
|
1363
|
+
"set -uo pipefail",
|
|
1364
|
+
'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
|
|
1365
|
+
locate,
|
|
1366
|
+
'[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
|
|
1367
|
+
'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
|
|
1368
|
+
`case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client}`,
|
|
1369
|
+
'echo "restore complete"'
|
|
1370
|
+
];
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/drivers/shared/compute-provision.ts
|
|
1374
|
+
function buildComputeProvisionScripts(config) {
|
|
1375
|
+
const compute = config.infrastructure?.compute ?? {};
|
|
1376
|
+
const phpBox = compute.runtime === "php" || !!compute.php;
|
|
1377
|
+
const needsPantry = phpBox || !!compute.managedServices;
|
|
1378
|
+
const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
|
|
1379
|
+
const useNginx = !usesRpxProxy(compute);
|
|
1380
|
+
const phpProvision = phpBox ? [
|
|
1381
|
+
...pantryBootstrap,
|
|
1382
|
+
...buildPhpProvisionScript({
|
|
1383
|
+
versions: compute.php?.versions,
|
|
1384
|
+
default: compute.php?.default,
|
|
1385
|
+
extensions: compute.php?.extensions,
|
|
1386
|
+
installNginx: useNginx,
|
|
1387
|
+
optimizeForProduction: compute.php?.optimizeForProduction,
|
|
1388
|
+
ini: compute.php?.ini
|
|
1389
|
+
}),
|
|
1390
|
+
...useNginx ? buildNginxServiceScript() : []
|
|
1391
|
+
] : undefined;
|
|
1392
|
+
const extras = [];
|
|
1393
|
+
if (!phpBox && needsPantry)
|
|
1394
|
+
extras.push(...pantryBootstrap);
|
|
1395
|
+
extras.push(...buildNotifierScript(config.notifications));
|
|
1396
|
+
if (compute.managedServices) {
|
|
1397
|
+
extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
|
|
1314
1398
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
const result = await ec2.describeInstances({ Filters: filters });
|
|
1325
|
-
const targets = this.reservationsToTargets(result.Reservations);
|
|
1326
|
-
if (targets.length > 0 || (options.role || "app") !== "app")
|
|
1327
|
-
return targets;
|
|
1328
|
-
const stackName = options.stackName ?? `${options.slug}-${options.environment}`;
|
|
1329
|
-
let pinnedId = readPinnedInstanceId(stackName);
|
|
1330
|
-
if (!pinnedId) {
|
|
1331
|
-
try {
|
|
1332
|
-
pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
|
|
1333
|
-
} catch {
|
|
1334
|
-
pinnedId = null;
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
if (!pinnedId)
|
|
1338
|
-
return [];
|
|
1339
|
-
try {
|
|
1340
|
-
const pinned = await ec2.describeInstances({
|
|
1341
|
-
InstanceIds: [pinnedId],
|
|
1342
|
-
Filters: [{ Name: "instance-state-name", Values: ["running", "pending"] }]
|
|
1343
|
-
});
|
|
1344
|
-
return this.reservationsToTargets(pinned.Reservations);
|
|
1345
|
-
} catch {
|
|
1346
|
-
return [];
|
|
1347
|
-
}
|
|
1399
|
+
extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
|
|
1400
|
+
extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
|
|
1401
|
+
extras.push(...buildMonitoringScript(compute.monitoring ?? phpBox));
|
|
1402
|
+
extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
|
|
1403
|
+
if (compute.backups?.enabled) {
|
|
1404
|
+
extras.push(...buildBackupProvisionScript({
|
|
1405
|
+
database: config.infrastructure?.appDatabase,
|
|
1406
|
+
backups: compute.backups
|
|
1407
|
+
}));
|
|
1348
1408
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1409
|
+
return {
|
|
1410
|
+
runtime: compute.runtime || "bun",
|
|
1411
|
+
runtimeVersion: compute.runtimeVersion || "latest",
|
|
1412
|
+
phpBox,
|
|
1413
|
+
phpProvision,
|
|
1414
|
+
servicesProvision: extras.length > 0 ? extras : undefined
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/drivers/shared/ubuntu-bootstrap.ts
|
|
1419
|
+
function buildUbuntuBootstrapScript(options = {}) {
|
|
1420
|
+
const {
|
|
1421
|
+
runtime = "bun",
|
|
1422
|
+
runtimeVersion = "latest",
|
|
1423
|
+
systemPackages = [],
|
|
1424
|
+
database,
|
|
1425
|
+
phpProvision,
|
|
1426
|
+
servicesProvision,
|
|
1427
|
+
caddyfile,
|
|
1428
|
+
rpxProvision,
|
|
1429
|
+
baked = false
|
|
1430
|
+
} = options;
|
|
1431
|
+
const packages = new Set(systemPackages);
|
|
1432
|
+
if (database === "sqlite")
|
|
1433
|
+
packages.add("sqlite3");
|
|
1434
|
+
else if (database === "mysql")
|
|
1435
|
+
packages.add("mysql-client");
|
|
1436
|
+
else if (database === "postgres")
|
|
1437
|
+
packages.add("postgresql-client");
|
|
1438
|
+
let script = `#!/bin/bash
|
|
1439
|
+
set -euo pipefail
|
|
1440
|
+
`;
|
|
1441
|
+
if (!baked) {
|
|
1442
|
+
script += `
|
|
1443
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
1444
|
+
apt-get update -y
|
|
1445
|
+
apt-get upgrade -y
|
|
1446
|
+
apt-get install -y curl tar gzip unzip git ca-certificates
|
|
1447
|
+
`;
|
|
1448
|
+
if (packages.size > 0) {
|
|
1449
|
+
script += `
|
|
1450
|
+
apt-get install -y ${[...packages].join(" ")}
|
|
1451
|
+
`;
|
|
1364
1452
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
async runRemoteDeploy(options) {
|
|
1372
|
-
const region = this.region;
|
|
1373
|
-
const ssm = new SSMClient(region);
|
|
1374
|
-
if (options.targets.length > 0) {
|
|
1375
|
-
const sendResult = await ssm.sendCommand({
|
|
1376
|
-
InstanceIds: options.targets.map((target) => target.id),
|
|
1377
|
-
DocumentName: "AWS-RunShellScript",
|
|
1378
|
-
Parameters: { commands: this.bashWrap(options.commands) },
|
|
1379
|
-
TimeoutSeconds: options.timeoutSeconds || 600,
|
|
1380
|
-
Comment: options.comment
|
|
1381
|
-
});
|
|
1382
|
-
if (!sendResult.CommandId) {
|
|
1383
|
-
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1384
|
-
}
|
|
1385
|
-
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1453
|
+
if (phpProvision && phpProvision.length > 0) {
|
|
1454
|
+
script += `
|
|
1455
|
+
${phpProvision.join(`
|
|
1456
|
+
`)}
|
|
1457
|
+
`;
|
|
1386
1458
|
}
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1459
|
+
if (servicesProvision && servicesProvision.length > 0) {
|
|
1460
|
+
script += `
|
|
1461
|
+
${servicesProvision.join(`
|
|
1462
|
+
`)}
|
|
1463
|
+
`;
|
|
1389
1464
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1465
|
+
if (runtime === "bun") {
|
|
1466
|
+
script += `
|
|
1467
|
+
# cloud-init runs this with \`set -u\` and NO $HOME set. bun's install.sh
|
|
1468
|
+
# dereferences $HOME even when BUN_INSTALL is set, so without this the install
|
|
1469
|
+
# aborts with "HOME: unbound variable" and /usr/local/bin/bun never appears —
|
|
1470
|
+
# breaking a from-scratch provision (adopting an existing box masked it).
|
|
1471
|
+
export HOME="\${HOME:-/root}"
|
|
1472
|
+
export BUN_INSTALL="/root/.bun"
|
|
1473
|
+
# bun.sh's installer references $HOME internally and runs under this
|
|
1474
|
+
# script's \`set -u\` (inherited by the piped bash) — cloud-init's runcmd
|
|
1475
|
+
# environment doesn't export HOME by default, so without this the
|
|
1476
|
+
# installer dies with "HOME: unbound variable" partway through (after
|
|
1477
|
+
# the binary is already downloaded, so it looks like a partial success)
|
|
1478
|
+
# and \`set -e\` aborts everything after it: the bun symlink, /var/www +
|
|
1479
|
+
# /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
|
|
1480
|
+
# never run. Confirmed against a real Hetzner box (stacksjs/status#1
|
|
1481
|
+
# Phase 9 e2e deploy) — cloud-init reported success with none of that
|
|
1482
|
+
# actually done.
|
|
1483
|
+
export HOME="\${HOME:-/root}"
|
|
1484
|
+
curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
|
|
1485
|
+
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
|
1486
|
+
echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
|
|
1487
|
+
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> /etc/profile.d/bun.sh
|
|
1488
|
+
`;
|
|
1489
|
+
} else if (runtime === "node") {
|
|
1490
|
+
const nodeMajor = runtimeVersion === "latest" || !runtimeVersion ? "20" : runtimeVersion.split(".")[0];
|
|
1491
|
+
script += `
|
|
1492
|
+
curl -fsSL https://deb.nodesource.com/setup_${nodeMajor}.x | bash -
|
|
1493
|
+
apt-get install -y nodejs
|
|
1494
|
+
ln -sf /usr/bin/node /usr/local/bin/node
|
|
1495
|
+
ln -sf /usr/bin/npm /usr/local/bin/npm
|
|
1496
|
+
`;
|
|
1497
|
+
} else if (runtime === "deno") {
|
|
1498
|
+
script += `
|
|
1499
|
+
curl -fsSL https://deno.land/install.sh | sh
|
|
1500
|
+
ln -sf /root/.deno/bin/deno /usr/local/bin/deno
|
|
1501
|
+
`;
|
|
1423
1502
|
}
|
|
1424
|
-
const perInstance = lastInvocations.map((item) => ({
|
|
1425
|
-
instanceId: item.InstanceId,
|
|
1426
|
-
status: item.Status || "Unknown",
|
|
1427
|
-
output: item.StandardOutputContent,
|
|
1428
|
-
error: item.StandardErrorContent
|
|
1429
|
-
}));
|
|
1430
|
-
const success = perInstance.length > 0 && perInstance.every((item) => item.status === "Success");
|
|
1431
|
-
return {
|
|
1432
|
-
success,
|
|
1433
|
-
instanceCount: perInstance.length,
|
|
1434
|
-
perInstance,
|
|
1435
|
-
error: success ? undefined : "One or more SSM command invocations failed"
|
|
1436
|
-
};
|
|
1437
1503
|
}
|
|
1438
|
-
|
|
1504
|
+
script += `
|
|
1505
|
+
mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
|
|
1506
|
+
`;
|
|
1507
|
+
if (caddyfile) {
|
|
1508
|
+
const escaped = caddyfile.replace(/\$/g, "\\$");
|
|
1509
|
+
script += `
|
|
1510
|
+
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
|
1511
|
+
curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
|
|
1512
|
+
chmod +x /usr/local/bin/caddy
|
|
1439
1513
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
import { execSync } from "node:child_process";
|
|
1514
|
+
getent group caddy >/dev/null || groupadd --system caddy
|
|
1515
|
+
getent passwd caddy >/dev/null || useradd --system --gid caddy \\
|
|
1516
|
+
--create-home --home-dir /var/lib/caddy \\
|
|
1517
|
+
--shell /usr/sbin/nologin --comment "Caddy web server" caddy
|
|
1445
1518
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1519
|
+
mkdir -p /etc/caddy /var/lib/caddy /var/log/caddy
|
|
1520
|
+
chown -R caddy:caddy /var/lib/caddy /var/log/caddy
|
|
1448
1521
|
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const message = data.error?.message || response.statusText || "Hetzner API error";
|
|
1481
|
-
const code = data.error?.code ? ` [${data.error.code}]` : "";
|
|
1482
|
-
throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
|
|
1483
|
-
}
|
|
1484
|
-
return data;
|
|
1485
|
-
}
|
|
1486
|
-
async listServers() {
|
|
1487
|
-
const data = await this.request("GET", "/servers");
|
|
1488
|
-
return data.servers;
|
|
1489
|
-
}
|
|
1490
|
-
async getServer(id) {
|
|
1491
|
-
const data = await this.request("GET", `/servers/${id}`);
|
|
1492
|
-
return data.server;
|
|
1493
|
-
}
|
|
1494
|
-
async createServer(options) {
|
|
1495
|
-
const data = await this.request("POST", "/servers", {
|
|
1496
|
-
name: options.name,
|
|
1497
|
-
server_type: options.serverType,
|
|
1498
|
-
image: options.image,
|
|
1499
|
-
location: options.location,
|
|
1500
|
-
datacenter: options.datacenter,
|
|
1501
|
-
ssh_keys: options.sshKeys,
|
|
1502
|
-
user_data: options.userData,
|
|
1503
|
-
labels: options.labels,
|
|
1504
|
-
firewalls: options.firewalls,
|
|
1505
|
-
networks: options.networks,
|
|
1506
|
-
start_after_create: true
|
|
1507
|
-
});
|
|
1508
|
-
return { server: data.server, action: data.action };
|
|
1509
|
-
}
|
|
1510
|
-
async listNetworks() {
|
|
1511
|
-
const data = await this.request("GET", "/networks");
|
|
1512
|
-
return data.networks;
|
|
1513
|
-
}
|
|
1514
|
-
async createNetwork(options) {
|
|
1515
|
-
const ipRange = options.ipRange ?? "10.0.0.0/16";
|
|
1516
|
-
const data = await this.request("POST", "/networks", {
|
|
1517
|
-
name: options.name,
|
|
1518
|
-
ip_range: ipRange,
|
|
1519
|
-
subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
|
|
1520
|
-
labels: options.labels
|
|
1521
|
-
});
|
|
1522
|
-
return data.network;
|
|
1523
|
-
}
|
|
1524
|
-
async deleteNetwork(id) {
|
|
1525
|
-
await this.request("DELETE", `/networks/${id}`);
|
|
1526
|
-
}
|
|
1527
|
-
async listLoadBalancers() {
|
|
1528
|
-
const data = await this.request("GET", "/load_balancers");
|
|
1529
|
-
return data.load_balancers;
|
|
1530
|
-
}
|
|
1531
|
-
async createLoadBalancer(options) {
|
|
1532
|
-
const data = await this.request("POST", "/load_balancers", {
|
|
1533
|
-
name: options.name,
|
|
1534
|
-
load_balancer_type: options.type ?? "lb11",
|
|
1535
|
-
location: options.location,
|
|
1536
|
-
network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
|
|
1537
|
-
network: options.network,
|
|
1538
|
-
labels: options.labels,
|
|
1539
|
-
targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
|
|
1540
|
-
services: options.services.map((s) => ({
|
|
1541
|
-
protocol: s.protocol ?? "tcp",
|
|
1542
|
-
listen_port: s.listenPort,
|
|
1543
|
-
destination_port: s.destinationPort,
|
|
1544
|
-
health_check: {
|
|
1545
|
-
protocol: "http",
|
|
1546
|
-
port: 80,
|
|
1547
|
-
interval: 15,
|
|
1548
|
-
timeout: 10,
|
|
1549
|
-
retries: 3,
|
|
1550
|
-
http: { path: "/", status_codes: ["2??", "3??"] }
|
|
1551
|
-
}
|
|
1552
|
-
}))
|
|
1553
|
-
});
|
|
1554
|
-
return data.load_balancer;
|
|
1555
|
-
}
|
|
1556
|
-
async deleteLoadBalancer(id) {
|
|
1557
|
-
await this.request("DELETE", `/load_balancers/${id}`);
|
|
1558
|
-
}
|
|
1559
|
-
async deleteServer(id) {
|
|
1560
|
-
const data = await this.request("DELETE", `/servers/${id}`);
|
|
1561
|
-
return data.action;
|
|
1562
|
-
}
|
|
1563
|
-
async listFirewalls() {
|
|
1564
|
-
const data = await this.request("GET", "/firewalls");
|
|
1565
|
-
return data.firewalls;
|
|
1566
|
-
}
|
|
1567
|
-
async createFirewall(options) {
|
|
1568
|
-
const data = await this.request("POST", "/firewalls", {
|
|
1569
|
-
name: options.name,
|
|
1570
|
-
rules: options.rules,
|
|
1571
|
-
labels: options.labels,
|
|
1572
|
-
apply_to: options.applyTo
|
|
1573
|
-
});
|
|
1574
|
-
return { firewall: data.firewall, actions: data.actions };
|
|
1575
|
-
}
|
|
1576
|
-
async setFirewallRules(firewallId, rules) {
|
|
1577
|
-
const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
|
|
1578
|
-
rules
|
|
1579
|
-
});
|
|
1580
|
-
return data.actions ?? [];
|
|
1581
|
-
}
|
|
1582
|
-
async deleteFirewall(firewallId) {
|
|
1583
|
-
await this.request("DELETE", `/firewalls/${firewallId}`);
|
|
1584
|
-
}
|
|
1585
|
-
async applyFirewallToResources(firewallId, applyTo) {
|
|
1586
|
-
const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
1587
|
-
apply_to: applyTo
|
|
1588
|
-
});
|
|
1589
|
-
return data.actions;
|
|
1590
|
-
}
|
|
1591
|
-
async listSshKeys() {
|
|
1592
|
-
const data = await this.request("GET", "/ssh_keys");
|
|
1593
|
-
return data.ssh_keys;
|
|
1594
|
-
}
|
|
1595
|
-
async createSshKey(options) {
|
|
1596
|
-
const data = await this.request("POST", "/ssh_keys", {
|
|
1597
|
-
name: options.name,
|
|
1598
|
-
public_key: options.publicKey,
|
|
1599
|
-
labels: options.labels
|
|
1600
|
-
});
|
|
1601
|
-
return data.ssh_key;
|
|
1602
|
-
}
|
|
1603
|
-
async waitForAction(actionId, options) {
|
|
1604
|
-
const pollInterval = options?.pollIntervalMs ?? 2000;
|
|
1605
|
-
const maxWait = options?.maxWaitMs ?? 300000;
|
|
1606
|
-
const start = Date.now();
|
|
1607
|
-
while (Date.now() - start < maxWait) {
|
|
1608
|
-
const data = await this.request("GET", `/actions/${actionId}`);
|
|
1609
|
-
if (data.action.status === "success")
|
|
1610
|
-
return data.action;
|
|
1611
|
-
if (data.action.status === "error") {
|
|
1612
|
-
throw new Error(data.action.error?.message || "Hetzner action failed");
|
|
1613
|
-
}
|
|
1614
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1615
|
-
}
|
|
1616
|
-
throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
|
|
1617
|
-
}
|
|
1618
|
-
async waitForServerRunning(serverId, options) {
|
|
1619
|
-
const pollInterval = options?.pollIntervalMs ?? 3000;
|
|
1620
|
-
const maxWait = options?.maxWaitMs ?? 600000;
|
|
1621
|
-
const start = Date.now();
|
|
1622
|
-
while (Date.now() - start < maxWait) {
|
|
1623
|
-
const server = await this.getServer(serverId);
|
|
1624
|
-
if (server.status === "running")
|
|
1625
|
-
return server;
|
|
1626
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1627
|
-
}
|
|
1628
|
-
throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
|
|
1522
|
+
cat > /etc/systemd/system/caddy.service <<'CADDY_UNIT_EOF'
|
|
1523
|
+
[Unit]
|
|
1524
|
+
Description=Caddy
|
|
1525
|
+
Documentation=https://caddyserver.com/docs/
|
|
1526
|
+
After=network.target network-online.target
|
|
1527
|
+
Requires=network-online.target
|
|
1528
|
+
|
|
1529
|
+
[Service]
|
|
1530
|
+
Type=notify
|
|
1531
|
+
User=caddy
|
|
1532
|
+
Group=caddy
|
|
1533
|
+
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
|
1534
|
+
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
|
|
1535
|
+
TimeoutStopSec=5s
|
|
1536
|
+
LimitNOFILE=1048576
|
|
1537
|
+
PrivateTmp=true
|
|
1538
|
+
ProtectSystem=full
|
|
1539
|
+
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
1540
|
+
|
|
1541
|
+
[Install]
|
|
1542
|
+
WantedBy=multi-user.target
|
|
1543
|
+
CADDY_UNIT_EOF
|
|
1544
|
+
|
|
1545
|
+
cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
|
|
1546
|
+
${escaped}
|
|
1547
|
+
CADDY_CONFIG_EOF
|
|
1548
|
+
|
|
1549
|
+
systemctl daemon-reload
|
|
1550
|
+
systemctl enable caddy
|
|
1551
|
+
systemctl start caddy
|
|
1552
|
+
`;
|
|
1629
1553
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1554
|
+
if (rpxProvision && rpxProvision.length > 0) {
|
|
1555
|
+
const body = rpxProvision[0] === "set -euo pipefail" ? rpxProvision.slice(1) : rpxProvision;
|
|
1556
|
+
script += `
|
|
1557
|
+
${body.join(`
|
|
1558
|
+
`)}
|
|
1559
|
+
`;
|
|
1635
1560
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
function normalizeSshPublicKey(publicKey) {
|
|
1639
|
-
const [type, body] = publicKey.trim().split(/\s+/);
|
|
1640
|
-
return body ? `${type} ${body}` : type;
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// src/drivers/hetzner/cloud-init.ts
|
|
1644
|
-
function wrapCloudInitUserData(bootstrapScript) {
|
|
1645
|
-
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
1646
|
-
const indented = bootstrapScript.split(`
|
|
1647
|
-
`).map((line) => ` ${line}`).join(`
|
|
1648
|
-
`);
|
|
1649
|
-
return `#cloud-config
|
|
1650
|
-
write_files:
|
|
1651
|
-
- path: ${scriptPath}
|
|
1652
|
-
permissions: '0755'
|
|
1653
|
-
owner: root:root
|
|
1654
|
-
content: |
|
|
1655
|
-
${indented}
|
|
1656
|
-
runcmd:
|
|
1657
|
-
- [ bash, ${scriptPath} ]
|
|
1561
|
+
script += `
|
|
1562
|
+
echo "ts-cloud bootstrap complete — instance is ready for site deploys"
|
|
1658
1563
|
`;
|
|
1564
|
+
return script;
|
|
1659
1565
|
}
|
|
1660
1566
|
|
|
1661
|
-
// src/
|
|
1662
|
-
|
|
1663
|
-
|
|
1567
|
+
// src/drivers/aws/provision.ts
|
|
1568
|
+
var UBUNTU_AMI_SSM_PARAM = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id";
|
|
1569
|
+
function awsComputeIngressRules(config) {
|
|
1570
|
+
const rules = [
|
|
1571
|
+
{ port: 22, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1572
|
+
{ port: 80, protocol: "tcp", cidr: "0.0.0.0/0" },
|
|
1573
|
+
{ port: 443, protocol: "tcp", cidr: "0.0.0.0/0" }
|
|
1574
|
+
];
|
|
1575
|
+
const extra = new Set;
|
|
1576
|
+
for (const site of Object.values(config.sites || {})) {
|
|
1577
|
+
if (site && typeof site.port === "number" && ![22, 80, 443].includes(site.port))
|
|
1578
|
+
extra.add(site.port);
|
|
1579
|
+
}
|
|
1580
|
+
for (const port of extra)
|
|
1581
|
+
rules.push({ port, protocol: "tcp", cidr: "0.0.0.0/0" });
|
|
1582
|
+
return rules;
|
|
1664
1583
|
}
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1584
|
+
function buildAwsUserData(config) {
|
|
1585
|
+
const compute = config.infrastructure?.compute ?? {};
|
|
1586
|
+
const provision = buildComputeProvisionScripts(config);
|
|
1587
|
+
return buildUbuntuBootstrapScript({
|
|
1588
|
+
runtime: provision.runtime,
|
|
1589
|
+
runtimeVersion: provision.runtimeVersion,
|
|
1590
|
+
systemPackages: compute.systemPackages,
|
|
1591
|
+
database: config.infrastructure?.database,
|
|
1592
|
+
phpProvision: provision.phpProvision,
|
|
1593
|
+
servicesProvision: provision.servicesProvision,
|
|
1594
|
+
baked: compute.bakedImage === true
|
|
1595
|
+
});
|
|
1673
1596
|
}
|
|
1674
|
-
function
|
|
1675
|
-
|
|
1676
|
-
return site.deploy;
|
|
1677
|
-
if (isPhpSite(site))
|
|
1678
|
-
return "server";
|
|
1679
|
-
if (site.start)
|
|
1680
|
-
return "server";
|
|
1681
|
-
return "bucket";
|
|
1597
|
+
function encodeUserData(userData) {
|
|
1598
|
+
return Buffer.from(userData, "utf8").toString("base64");
|
|
1682
1599
|
}
|
|
1683
|
-
function
|
|
1684
|
-
|
|
1685
|
-
return "redirect";
|
|
1686
|
-
if (isPhpSite(site))
|
|
1687
|
-
return "server-php";
|
|
1688
|
-
const target = resolveSiteDeployTarget(site);
|
|
1689
|
-
if (target === "bucket")
|
|
1690
|
-
return "bucket";
|
|
1691
|
-
return site.start ? "server-app" : "server-static";
|
|
1600
|
+
function resolveAwsImageId(config) {
|
|
1601
|
+
return config.infrastructure?.compute?.image ?? null;
|
|
1692
1602
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1603
|
+
|
|
1604
|
+
// src/drivers/aws/driver.ts
|
|
1605
|
+
function readPinnedInstanceId(stackName) {
|
|
1606
|
+
try {
|
|
1607
|
+
const raw = readFileSync(join(process.cwd(), ".ts-cloud/state", `${stackName}.json`), "utf8");
|
|
1608
|
+
const state = JSON.parse(raw);
|
|
1609
|
+
return typeof state.instanceId === "string" && state.instanceId.length > 0 ? state.instanceId : null;
|
|
1610
|
+
} catch {
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1695
1613
|
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1614
|
+
|
|
1615
|
+
class AwsDriver {
|
|
1616
|
+
name = "aws";
|
|
1617
|
+
usesCloudFormation = true;
|
|
1618
|
+
region;
|
|
1619
|
+
constructor(options = {}) {
|
|
1620
|
+
this.region = options.region || "us-east-1";
|
|
1621
|
+
}
|
|
1622
|
+
resolveRegion(config) {
|
|
1623
|
+
return config.project.region || this.region;
|
|
1624
|
+
}
|
|
1625
|
+
async provisionComputeInfrastructure(options) {
|
|
1626
|
+
const { config, environment } = options;
|
|
1627
|
+
const region = this.resolveRegion(config);
|
|
1628
|
+
const slug = config.project.slug;
|
|
1629
|
+
const compute = config.infrastructure?.compute;
|
|
1630
|
+
if (!compute)
|
|
1631
|
+
throw new Error("infrastructure.compute is required to provision AWS compute");
|
|
1632
|
+
const ec2 = new EC2Client(region);
|
|
1633
|
+
const existing = await this.findComputeTargets({ slug, environment, role: "app" });
|
|
1634
|
+
if (existing.length > 0) {
|
|
1635
|
+
const first = existing[0];
|
|
1636
|
+
return { appInstanceId: first.id, appPublicIp: first.publicIp, sshUser: "ubuntu", deployStoragePath: "/var/ts-cloud/staging" };
|
|
1708
1637
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
|
|
1717
|
-
if (!computeConfigured) {
|
|
1718
|
-
errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
|
|
1719
|
-
}
|
|
1720
|
-
const serverOnly = [];
|
|
1721
|
-
if (site.start)
|
|
1722
|
-
serverOnly.push("start");
|
|
1723
|
-
if (site.root)
|
|
1724
|
-
serverOnly.push("root");
|
|
1725
|
-
if (serverOnly.length > 0)
|
|
1726
|
-
warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
|
|
1727
|
-
continue;
|
|
1638
|
+
let imageId = resolveAwsImageId(config);
|
|
1639
|
+
if (!imageId) {
|
|
1640
|
+
const ssm = new SSMClient(region);
|
|
1641
|
+
const param = await ssm.getParameter({ Name: UBUNTU_AMI_SSM_PARAM });
|
|
1642
|
+
imageId = param.Parameter?.Value ?? null;
|
|
1643
|
+
if (!imageId)
|
|
1644
|
+
throw new Error("Could not resolve the Ubuntu 24.04 AMI from SSM");
|
|
1728
1645
|
}
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1646
|
+
const vpcs = await ec2.describeVpcs();
|
|
1647
|
+
const vpc = (vpcs.Vpcs || []).find((v) => v.IsDefault) || (vpcs.Vpcs || [])[0];
|
|
1648
|
+
if (!vpc?.VpcId)
|
|
1649
|
+
throw new Error("No VPC found to launch the instance into");
|
|
1650
|
+
const subnets = (await ec2.describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpc.VpcId] }] })).Subnets || [];
|
|
1651
|
+
const subnet = subnets.find((s) => s.MapPublicIpOnLaunch) || subnets[0];
|
|
1652
|
+
const subnetId = subnet?.SubnetId;
|
|
1653
|
+
if (!subnet?.MapPublicIpOnLaunch)
|
|
1654
|
+
console.warn("ts-cloud: no public subnet found; the instance may not get a public IP (deploys/SSL need one).");
|
|
1655
|
+
const sgName = `${slug}-${environment}-app-sg`;
|
|
1656
|
+
const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }, { Name: "vpc-id", Values: [vpc.VpcId] }] });
|
|
1657
|
+
let groupId = found.SecurityGroups?.[0]?.GroupId;
|
|
1658
|
+
if (!groupId) {
|
|
1659
|
+
const created = await ec2.createSecurityGroup({ GroupName: sgName, Description: `ts-cloud ${slug}/${environment} app`, VpcId: vpc.VpcId });
|
|
1660
|
+
groupId = created.GroupId;
|
|
1661
|
+
}
|
|
1662
|
+
const rules = awsComputeIngressRules(config);
|
|
1663
|
+
await ec2.authorizeSecurityGroupIngress({
|
|
1664
|
+
GroupId: groupId,
|
|
1665
|
+
IpPermissions: rules.map((r) => ({ IpProtocol: r.protocol, FromPort: r.port, ToPort: r.port, IpRanges: [{ CidrIp: r.cidr }] }))
|
|
1666
|
+
}).catch((e) => {
|
|
1667
|
+
if (!/InvalidPermission\.Duplicate/.test(e instanceof Error ? e.message : ""))
|
|
1668
|
+
throw e;
|
|
1669
|
+
});
|
|
1670
|
+
const userData = encodeUserData(buildAwsUserData(config));
|
|
1671
|
+
const instanceType = compute.server?.instanceType || "t3.small";
|
|
1672
|
+
const run = await ec2.runInstances({
|
|
1673
|
+
ImageId: imageId,
|
|
1674
|
+
InstanceType: instanceType,
|
|
1675
|
+
MinCount: 1,
|
|
1676
|
+
MaxCount: 1,
|
|
1677
|
+
SecurityGroupIds: groupId ? [groupId] : undefined,
|
|
1678
|
+
SubnetId: subnetId,
|
|
1679
|
+
UserData: userData,
|
|
1680
|
+
IamInstanceProfile: compute.server?.iamInstanceProfile ? { Name: compute.server.iamInstanceProfile } : undefined,
|
|
1681
|
+
TagSpecifications: [{
|
|
1682
|
+
ResourceType: "instance",
|
|
1683
|
+
Tags: [
|
|
1684
|
+
{ Key: "Name", Value: `${slug}-${environment}-app` },
|
|
1685
|
+
{ Key: "Project", Value: slug },
|
|
1686
|
+
{ Key: "Environment", Value: environment },
|
|
1687
|
+
{ Key: "Role", Value: "app" }
|
|
1688
|
+
]
|
|
1689
|
+
}]
|
|
1690
|
+
});
|
|
1691
|
+
const instanceId = run.Instances?.[0]?.InstanceId;
|
|
1692
|
+
if (!instanceId)
|
|
1693
|
+
throw new Error("RunInstances did not return an instance id");
|
|
1694
|
+
const running = await ec2.waitForInstanceState(instanceId, "running");
|
|
1695
|
+
if (!running)
|
|
1696
|
+
throw new Error(`Instance ${instanceId} did not reach 'running' before timeout`);
|
|
1697
|
+
return {
|
|
1698
|
+
appInstanceId: instanceId,
|
|
1699
|
+
appPublicIp: running.PublicIpAddress,
|
|
1700
|
+
sshUser: "ubuntu",
|
|
1701
|
+
deployStoragePath: "/var/ts-cloud/staging"
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
async destroyCompute(options) {
|
|
1705
|
+
const { config, environment } = options;
|
|
1706
|
+
const region = this.resolveRegion(config);
|
|
1707
|
+
const ec2 = new EC2Client(region);
|
|
1708
|
+
const destroyed = [];
|
|
1709
|
+
const targets = await this.findComputeTargets({ slug: config.project.slug, environment, role: "app" });
|
|
1710
|
+
const ids = targets.map((t) => t.id);
|
|
1711
|
+
if (ids.length > 0) {
|
|
1712
|
+
await ec2.terminateInstances(ids);
|
|
1713
|
+
destroyed.push(...ids.map((id) => `instance ${id}`));
|
|
1714
|
+
await Promise.all(ids.map((id) => ec2.waitForInstanceState(id, "terminated").catch(() => {
|
|
1715
|
+
return;
|
|
1716
|
+
})));
|
|
1732
1717
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
if (typeof site.port === "number") {
|
|
1745
|
-
const existing = portOwners.get(site.port);
|
|
1746
|
-
if (existing) {
|
|
1747
|
-
errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
|
|
1748
|
-
} else {
|
|
1749
|
-
portOwners.set(site.port, name);
|
|
1718
|
+
const sgName = `${config.project.slug}-${environment}-app-sg`;
|
|
1719
|
+
const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
|
|
1720
|
+
const groupId = found.SecurityGroups?.[0]?.GroupId;
|
|
1721
|
+
if (groupId) {
|
|
1722
|
+
for (let i = 0;i < 10; i++) {
|
|
1723
|
+
try {
|
|
1724
|
+
await ec2.deleteSecurityGroup(groupId);
|
|
1725
|
+
destroyed.push(`security group ${sgName}`);
|
|
1726
|
+
break;
|
|
1727
|
+
} catch {
|
|
1728
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1750
1729
|
}
|
|
1751
1730
|
}
|
|
1752
|
-
} else if (kind === "server-static") {
|
|
1753
|
-
if (!site.root) {
|
|
1754
|
-
errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
|
|
1755
|
-
}
|
|
1756
|
-
if (!computeConfigured) {
|
|
1757
|
-
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).`);
|
|
1758
|
-
}
|
|
1759
|
-
} else {
|
|
1760
|
-
if (!site.root) {
|
|
1761
|
-
errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
|
|
1762
|
-
}
|
|
1763
|
-
const serverOnly = [];
|
|
1764
|
-
if (site.start)
|
|
1765
|
-
serverOnly.push("start");
|
|
1766
|
-
if (typeof site.port === "number")
|
|
1767
|
-
serverOnly.push("port");
|
|
1768
|
-
if (site.preStart && site.preStart.length > 0)
|
|
1769
|
-
serverOnly.push("preStart");
|
|
1770
|
-
if (serverOnly.length > 0) {
|
|
1771
|
-
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.`);
|
|
1772
|
-
}
|
|
1773
1731
|
}
|
|
1732
|
+
return { destroyed };
|
|
1774
1733
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
if (!path || path === "/")
|
|
1803
|
-
return;
|
|
1804
|
-
let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
1805
|
-
if (!p.startsWith("/"))
|
|
1806
|
-
p = `/${p}`;
|
|
1807
|
-
return p === "" || p === "/" ? undefined : p;
|
|
1808
|
-
}
|
|
1809
|
-
function deriveRouteId(to, path) {
|
|
1810
|
-
const base = path ? `${to}${path}` : to;
|
|
1811
|
-
const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
|
|
1812
|
-
return cleaned.length > 0 ? cleaned : "rpx";
|
|
1813
|
-
}
|
|
1814
|
-
function resolveServerAppFrom(port, appBoxes) {
|
|
1815
|
-
if (!appBoxes || appBoxes.length === 0)
|
|
1816
|
-
return `localhost:${port}`;
|
|
1817
|
-
return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
|
|
1818
|
-
}
|
|
1819
|
-
function buildRpxConfigInternal(sites, options, appBoxes) {
|
|
1820
|
-
const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
|
|
1821
|
-
const installSlug = options.slug ?? "app";
|
|
1822
|
-
const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
|
|
1823
|
-
const loadBalancer = options.proxy.loadBalancer;
|
|
1824
|
-
const proxies = [];
|
|
1825
|
-
const domains = new Set;
|
|
1826
|
-
for (const [name, site] of Object.entries(sites)) {
|
|
1827
|
-
if (!site || !site.domain)
|
|
1828
|
-
continue;
|
|
1829
|
-
const kind = resolveSiteKind(site);
|
|
1830
|
-
if (kind === "bucket")
|
|
1831
|
-
continue;
|
|
1832
|
-
const path = normalizeRoutePath(site.path);
|
|
1833
|
-
const id = deriveRouteId(site.domain, path);
|
|
1834
|
-
const auth = resolveRouteAuth(site);
|
|
1835
|
-
if (kind === "redirect") {
|
|
1836
|
-
proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
|
|
1837
|
-
domains.add(site.domain);
|
|
1838
|
-
continue;
|
|
1734
|
+
async getComputeOutputs(options) {
|
|
1735
|
+
const region = this.resolveRegion(options.config);
|
|
1736
|
+
const stackName = resolveProjectStackName(options.config, options.environment);
|
|
1737
|
+
const cfn = new CloudFormationClient(region);
|
|
1738
|
+
try {
|
|
1739
|
+
const outputs = await cfn.getStackOutputs(stackName);
|
|
1740
|
+
return {
|
|
1741
|
+
deployBucketName: outputs.deployBucketName,
|
|
1742
|
+
appInstanceId: outputs.appInstanceId,
|
|
1743
|
+
appPublicIp: outputs.appPublicIp,
|
|
1744
|
+
sshUser: "ec2-user"
|
|
1745
|
+
};
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
if (!/does not exist|ValidationError/i.test(err instanceof Error ? err.message : ""))
|
|
1748
|
+
throw err;
|
|
1749
|
+
const targets = await this.findComputeTargets({
|
|
1750
|
+
slug: options.config.project.slug,
|
|
1751
|
+
environment: options.environment,
|
|
1752
|
+
role: "app"
|
|
1753
|
+
});
|
|
1754
|
+
const first = targets[0];
|
|
1755
|
+
return {
|
|
1756
|
+
appInstanceId: first?.id,
|
|
1757
|
+
appPublicIp: first?.publicIp,
|
|
1758
|
+
sshUser: "ubuntu",
|
|
1759
|
+
deployStoragePath: "/var/ts-cloud/staging"
|
|
1760
|
+
};
|
|
1839
1761
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1762
|
+
}
|
|
1763
|
+
async uploadRelease(options) {
|
|
1764
|
+
const region = this.resolveRegion(options.config);
|
|
1765
|
+
const outputs = await this.getComputeOutputs({
|
|
1766
|
+
config: options.config,
|
|
1767
|
+
environment: options.environment
|
|
1768
|
+
});
|
|
1769
|
+
const bucket = outputs.deployBucketName;
|
|
1770
|
+
if (!bucket) {
|
|
1771
|
+
throw new Error("No deployBucketName in stack outputs. Re-deploy infrastructure to add the staging bucket.");
|
|
1772
|
+
}
|
|
1773
|
+
const s3 = new S3Client(region);
|
|
1774
|
+
await s3.putObject({
|
|
1775
|
+
bucket,
|
|
1776
|
+
key: options.remoteKey,
|
|
1777
|
+
body: readFileSync(options.localPath),
|
|
1778
|
+
contentType: "application/gzip"
|
|
1779
|
+
});
|
|
1780
|
+
return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
|
|
1781
|
+
}
|
|
1782
|
+
async findComputeTargets(options) {
|
|
1783
|
+
const region = this.region;
|
|
1784
|
+
const ec2 = new EC2Client(region);
|
|
1785
|
+
const filters = [
|
|
1786
|
+
{ Name: "tag:Project", Values: [options.slug] },
|
|
1787
|
+
{ Name: "tag:Environment", Values: [options.environment] },
|
|
1788
|
+
{ Name: "tag:Role", Values: [options.role || "app"] },
|
|
1789
|
+
{ Name: "instance-state-name", Values: ["running", "pending"] }
|
|
1790
|
+
];
|
|
1791
|
+
const result = await ec2.describeInstances({ Filters: filters });
|
|
1792
|
+
const targets = this.reservationsToTargets(result.Reservations);
|
|
1793
|
+
if (targets.length > 0 || (options.role || "app") !== "app")
|
|
1794
|
+
return targets;
|
|
1795
|
+
const stackName = options.stackName ?? `${options.slug}-${options.environment}`;
|
|
1796
|
+
let pinnedId = readPinnedInstanceId(stackName);
|
|
1797
|
+
if (!pinnedId) {
|
|
1798
|
+
try {
|
|
1799
|
+
pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
|
|
1800
|
+
} catch {
|
|
1801
|
+
pinnedId = null;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (!pinnedId)
|
|
1805
|
+
return [];
|
|
1806
|
+
try {
|
|
1807
|
+
const pinned = await ec2.describeInstances({
|
|
1808
|
+
InstanceIds: [pinnedId],
|
|
1809
|
+
Filters: [{ Name: "instance-state-name", Values: ["running", "pending"] }]
|
|
1851
1810
|
});
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1811
|
+
return this.reservationsToTargets(pinned.Reservations);
|
|
1812
|
+
} catch {
|
|
1813
|
+
return [];
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
reservationsToTargets(reservations) {
|
|
1817
|
+
const targets = [];
|
|
1818
|
+
for (const reservation of reservations || []) {
|
|
1819
|
+
for (const instance of reservation.Instances || []) {
|
|
1820
|
+
if (!instance.InstanceId)
|
|
1821
|
+
continue;
|
|
1822
|
+
const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
|
|
1823
|
+
targets.push({
|
|
1824
|
+
id: instance.InstanceId,
|
|
1825
|
+
name: nameTag,
|
|
1826
|
+
publicIp: instance.PublicIpAddress,
|
|
1827
|
+
privateIp: instance.PrivateIpAddress,
|
|
1828
|
+
status: instance.State?.Name
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return targets;
|
|
1833
|
+
}
|
|
1834
|
+
bashWrap(commands) {
|
|
1835
|
+
return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
|
|
1836
|
+
`), "TS_CLOUD_BASH_EOF"];
|
|
1837
|
+
}
|
|
1838
|
+
async runRemoteDeploy(options) {
|
|
1839
|
+
const region = this.region;
|
|
1840
|
+
const ssm = new SSMClient(region);
|
|
1841
|
+
if (options.targets.length > 0) {
|
|
1842
|
+
const sendResult = await ssm.sendCommand({
|
|
1843
|
+
InstanceIds: options.targets.map((target) => target.id),
|
|
1844
|
+
DocumentName: "AWS-RunShellScript",
|
|
1845
|
+
Parameters: { commands: this.bashWrap(options.commands) },
|
|
1846
|
+
TimeoutSeconds: options.timeoutSeconds || 600,
|
|
1847
|
+
Comment: options.comment
|
|
1861
1848
|
});
|
|
1849
|
+
if (!sendResult.CommandId) {
|
|
1850
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1851
|
+
}
|
|
1852
|
+
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1862
1853
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
if (options.proxy.autoWww !== false) {
|
|
1866
|
-
for (const domain of [...domains]) {
|
|
1867
|
-
if (domain.split(".").length !== 2)
|
|
1868
|
-
continue;
|
|
1869
|
-
const wwwDomain = `www.${domain}`;
|
|
1870
|
-
if (domains.has(wwwDomain))
|
|
1871
|
-
continue;
|
|
1872
|
-
proxies.push({ to: wwwDomain, redirect: { to: `https://${domain}` }, id: deriveRouteId(wwwDomain) });
|
|
1873
|
-
domains.add(wwwDomain);
|
|
1854
|
+
if (!options.tags || Object.keys(options.tags).length === 0) {
|
|
1855
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
|
|
1874
1856
|
}
|
|
1857
|
+
const result = await ssm.sendCommandByTags({
|
|
1858
|
+
tags: options.tags,
|
|
1859
|
+
commands: this.bashWrap(options.commands),
|
|
1860
|
+
timeoutSeconds: options.timeoutSeconds || 600,
|
|
1861
|
+
comment: options.comment
|
|
1862
|
+
});
|
|
1863
|
+
return {
|
|
1864
|
+
success: result.success,
|
|
1865
|
+
instanceCount: result.instanceCount,
|
|
1866
|
+
perInstance: result.perInstance.map((item) => ({
|
|
1867
|
+
instanceId: item.instanceId,
|
|
1868
|
+
status: item.status,
|
|
1869
|
+
output: item.output,
|
|
1870
|
+
error: item.error
|
|
1871
|
+
})),
|
|
1872
|
+
error: result.error
|
|
1873
|
+
};
|
|
1875
1874
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1875
|
+
async pollSsmCommand(ssm, commandId, expectedCount) {
|
|
1876
|
+
const pollInterval = 3000;
|
|
1877
|
+
const maxWait = 600000;
|
|
1878
|
+
const startTime = Date.now();
|
|
1879
|
+
const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
|
|
1880
|
+
let lastInvocations = [];
|
|
1881
|
+
while (Date.now() - startTime < maxWait) {
|
|
1882
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1883
|
+
try {
|
|
1884
|
+
const invocations = await ssm.listCommandInvocations({ CommandId: commandId, Details: true });
|
|
1885
|
+
lastInvocations = invocations;
|
|
1886
|
+
if (lastInvocations.length >= expectedCount && lastInvocations.every((i) => terminalStatuses.has(i.Status || ""))) {
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
} catch {}
|
|
1890
|
+
}
|
|
1891
|
+
const perInstance = lastInvocations.map((item) => ({
|
|
1892
|
+
instanceId: item.InstanceId,
|
|
1893
|
+
status: item.Status || "Unknown",
|
|
1894
|
+
output: item.StandardOutputContent,
|
|
1895
|
+
error: item.StandardErrorContent
|
|
1896
|
+
}));
|
|
1897
|
+
const success = perInstance.length > 0 && perInstance.every((item) => item.status === "Success");
|
|
1898
|
+
return {
|
|
1899
|
+
success,
|
|
1900
|
+
instanceCount: perInstance.length,
|
|
1901
|
+
perInstance,
|
|
1902
|
+
error: success ? undefined : "One or more SSM command invocations failed"
|
|
1894
1903
|
};
|
|
1895
1904
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/drivers/hetzner/driver.ts
|
|
1908
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1909
|
+
import { homedir } from "node:os";
|
|
1910
|
+
import { join as join3 } from "node:path";
|
|
1911
|
+
import { execSync } from "node:child_process";
|
|
1912
|
+
|
|
1913
|
+
// src/drivers/hetzner/client.ts
|
|
1914
|
+
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
1915
|
+
|
|
1916
|
+
class HetznerClient {
|
|
1917
|
+
name = "hetzner";
|
|
1918
|
+
apiToken;
|
|
1919
|
+
baseUrl;
|
|
1920
|
+
fetchImpl;
|
|
1921
|
+
constructor(options) {
|
|
1922
|
+
this.apiToken = options.apiToken;
|
|
1923
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
|
|
1924
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
1925
|
+
}
|
|
1926
|
+
async request(method, path, body) {
|
|
1927
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
1928
|
+
method,
|
|
1929
|
+
headers: {
|
|
1930
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
1931
|
+
"Content-Type": "application/json"
|
|
1932
|
+
},
|
|
1933
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
1934
|
+
});
|
|
1935
|
+
const text = await response.text();
|
|
1936
|
+
let data;
|
|
1937
|
+
try {
|
|
1938
|
+
data = text ? JSON.parse(text) : {};
|
|
1939
|
+
} catch {
|
|
1940
|
+
if (!response.ok) {
|
|
1941
|
+
const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
|
|
1942
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
|
|
1943
|
+
}
|
|
1944
|
+
throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
|
|
1945
|
+
}
|
|
1946
|
+
if (!response.ok) {
|
|
1947
|
+
const message = data.error?.message || response.statusText || "Hetzner API error";
|
|
1948
|
+
const code = data.error?.code ? ` [${data.error.code}]` : "";
|
|
1949
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
|
|
1950
|
+
}
|
|
1951
|
+
return data;
|
|
1952
|
+
}
|
|
1953
|
+
async listServers() {
|
|
1954
|
+
const data = await this.request("GET", "/servers");
|
|
1955
|
+
return data.servers;
|
|
1956
|
+
}
|
|
1957
|
+
async getServer(id) {
|
|
1958
|
+
const data = await this.request("GET", `/servers/${id}`);
|
|
1959
|
+
return data.server;
|
|
1960
|
+
}
|
|
1961
|
+
async createServer(options) {
|
|
1962
|
+
const data = await this.request("POST", "/servers", {
|
|
1963
|
+
name: options.name,
|
|
1964
|
+
server_type: options.serverType,
|
|
1965
|
+
image: options.image,
|
|
1966
|
+
location: options.location,
|
|
1967
|
+
datacenter: options.datacenter,
|
|
1968
|
+
ssh_keys: options.sshKeys,
|
|
1969
|
+
user_data: options.userData,
|
|
1970
|
+
labels: options.labels,
|
|
1971
|
+
firewalls: options.firewalls,
|
|
1972
|
+
networks: options.networks,
|
|
1973
|
+
start_after_create: true
|
|
1974
|
+
});
|
|
1975
|
+
return { server: data.server, action: data.action };
|
|
1976
|
+
}
|
|
1977
|
+
async listNetworks() {
|
|
1978
|
+
const data = await this.request("GET", "/networks");
|
|
1979
|
+
return data.networks;
|
|
1980
|
+
}
|
|
1981
|
+
async createNetwork(options) {
|
|
1982
|
+
const ipRange = options.ipRange ?? "10.0.0.0/16";
|
|
1983
|
+
const data = await this.request("POST", "/networks", {
|
|
1984
|
+
name: options.name,
|
|
1985
|
+
ip_range: ipRange,
|
|
1986
|
+
subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
|
|
1987
|
+
labels: options.labels
|
|
1988
|
+
});
|
|
1989
|
+
return data.network;
|
|
1990
|
+
}
|
|
1991
|
+
async deleteNetwork(id) {
|
|
1992
|
+
await this.request("DELETE", `/networks/${id}`);
|
|
1993
|
+
}
|
|
1994
|
+
async listLoadBalancers() {
|
|
1995
|
+
const data = await this.request("GET", "/load_balancers");
|
|
1996
|
+
return data.load_balancers;
|
|
1997
|
+
}
|
|
1998
|
+
async createLoadBalancer(options) {
|
|
1999
|
+
const data = await this.request("POST", "/load_balancers", {
|
|
2000
|
+
name: options.name,
|
|
2001
|
+
load_balancer_type: options.type ?? "lb11",
|
|
2002
|
+
location: options.location,
|
|
2003
|
+
network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
|
|
2004
|
+
network: options.network,
|
|
2005
|
+
labels: options.labels,
|
|
2006
|
+
targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
|
|
2007
|
+
services: options.services.map((s) => ({
|
|
2008
|
+
protocol: s.protocol ?? "tcp",
|
|
2009
|
+
listen_port: s.listenPort,
|
|
2010
|
+
destination_port: s.destinationPort,
|
|
2011
|
+
health_check: {
|
|
2012
|
+
protocol: "http",
|
|
2013
|
+
port: 80,
|
|
2014
|
+
interval: 15,
|
|
2015
|
+
timeout: 10,
|
|
2016
|
+
retries: 3,
|
|
2017
|
+
http: { path: "/", status_codes: ["2??", "3??"] }
|
|
2018
|
+
}
|
|
2019
|
+
}))
|
|
2020
|
+
});
|
|
2021
|
+
return data.load_balancer;
|
|
2022
|
+
}
|
|
2023
|
+
async deleteLoadBalancer(id) {
|
|
2024
|
+
await this.request("DELETE", `/load_balancers/${id}`);
|
|
2025
|
+
}
|
|
2026
|
+
async deleteServer(id) {
|
|
2027
|
+
const data = await this.request("DELETE", `/servers/${id}`);
|
|
2028
|
+
return data.action;
|
|
2029
|
+
}
|
|
2030
|
+
async listFirewalls() {
|
|
2031
|
+
const data = await this.request("GET", "/firewalls");
|
|
2032
|
+
return data.firewalls;
|
|
2033
|
+
}
|
|
2034
|
+
async createFirewall(options) {
|
|
2035
|
+
const data = await this.request("POST", "/firewalls", {
|
|
2036
|
+
name: options.name,
|
|
2037
|
+
rules: options.rules,
|
|
2038
|
+
labels: options.labels,
|
|
2039
|
+
apply_to: options.applyTo
|
|
2040
|
+
});
|
|
2041
|
+
return { firewall: data.firewall, actions: data.actions };
|
|
2042
|
+
}
|
|
2043
|
+
async setFirewallRules(firewallId, rules) {
|
|
2044
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
|
|
2045
|
+
rules
|
|
2046
|
+
});
|
|
2047
|
+
return data.actions ?? [];
|
|
2048
|
+
}
|
|
2049
|
+
async deleteFirewall(firewallId) {
|
|
2050
|
+
await this.request("DELETE", `/firewalls/${firewallId}`);
|
|
2051
|
+
}
|
|
2052
|
+
async applyFirewallToResources(firewallId, applyTo) {
|
|
2053
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
2054
|
+
apply_to: applyTo
|
|
2055
|
+
});
|
|
2056
|
+
return data.actions;
|
|
2057
|
+
}
|
|
2058
|
+
async listSshKeys() {
|
|
2059
|
+
const data = await this.request("GET", "/ssh_keys");
|
|
2060
|
+
return data.ssh_keys;
|
|
2061
|
+
}
|
|
2062
|
+
async createSshKey(options) {
|
|
2063
|
+
const data = await this.request("POST", "/ssh_keys", {
|
|
2064
|
+
name: options.name,
|
|
2065
|
+
public_key: options.publicKey,
|
|
2066
|
+
labels: options.labels
|
|
2067
|
+
});
|
|
2068
|
+
return data.ssh_key;
|
|
2069
|
+
}
|
|
2070
|
+
async waitForAction(actionId, options) {
|
|
2071
|
+
const pollInterval = options?.pollIntervalMs ?? 2000;
|
|
2072
|
+
const maxWait = options?.maxWaitMs ?? 300000;
|
|
2073
|
+
const start = Date.now();
|
|
2074
|
+
while (Date.now() - start < maxWait) {
|
|
2075
|
+
const data = await this.request("GET", `/actions/${actionId}`);
|
|
2076
|
+
if (data.action.status === "success")
|
|
2077
|
+
return data.action;
|
|
2078
|
+
if (data.action.status === "error") {
|
|
2079
|
+
throw new Error(data.action.error?.message || "Hetzner action failed");
|
|
2080
|
+
}
|
|
2081
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
2082
|
+
}
|
|
2083
|
+
throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
|
|
1898
2084
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2085
|
+
async waitForServerRunning(serverId, options) {
|
|
2086
|
+
const pollInterval = options?.pollIntervalMs ?? 3000;
|
|
2087
|
+
const maxWait = options?.maxWaitMs ?? 600000;
|
|
2088
|
+
const start = Date.now();
|
|
2089
|
+
while (Date.now() - start < maxWait) {
|
|
2090
|
+
const server = await this.getServer(serverId);
|
|
2091
|
+
if (server.status === "running")
|
|
2092
|
+
return server;
|
|
2093
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
2094
|
+
}
|
|
2095
|
+
throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
|
|
1906
2096
|
}
|
|
1907
|
-
return config;
|
|
1908
|
-
}
|
|
1909
|
-
function buildRpxConfig(sites, options) {
|
|
1910
|
-
return buildRpxConfigInternal(sites, options);
|
|
1911
|
-
}
|
|
1912
|
-
function buildRpxLbConfig(sites, appBoxes, options) {
|
|
1913
|
-
return buildRpxConfigInternal(sites, options, appBoxes);
|
|
1914
|
-
}
|
|
1915
|
-
function renderRpxLauncher(config) {
|
|
1916
|
-
const json = JSON.stringify(config, null, 2);
|
|
1917
|
-
return `// Generated by ts-cloud — rpx reverse-proxy gateway.
|
|
1918
|
-
// Routes are derived from the \`sites\` model on every \`buddy deploy\`.
|
|
1919
|
-
import { startProxies } from '@stacksjs/rpx'
|
|
1920
|
-
|
|
1921
|
-
const config = ${json} as const
|
|
1922
|
-
|
|
1923
|
-
await startProxies(config as any)
|
|
1924
|
-
`;
|
|
1925
2097
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
var RPX_SITES_DIR = "/etc/rpx/sites.d";
|
|
1931
|
-
function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
|
|
1932
|
-
return `// Generated by ts-cloud — rpx gateway assembler.
|
|
1933
|
-
// Merges every app's fragment in ${sitesDir} so independent deploys compose
|
|
1934
|
-
// without clobbering each other. Each deploy writes only its own <slug>.json.
|
|
1935
|
-
import { startProxies } from '@stacksjs/rpx'
|
|
1936
|
-
import { readdirSync, readFileSync } from 'node:fs'
|
|
1937
|
-
|
|
1938
|
-
const dir = ${JSON.stringify(sitesDir)}
|
|
1939
|
-
const proxies = []
|
|
1940
|
-
const seen = new Set()
|
|
1941
|
-
const suffixes = new Set()
|
|
1942
|
-
const guardHosts = new Set()
|
|
1943
|
-
let email
|
|
1944
|
-
let certsDir = ${JSON.stringify(defaultCertsDir)}
|
|
1945
|
-
let acmeChallengeWebroot
|
|
1946
|
-
let guard
|
|
1947
|
-
let files = []
|
|
1948
|
-
try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
|
|
1949
|
-
for (const f of files) {
|
|
1950
|
-
let frag
|
|
1951
|
-
try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
|
|
1952
|
-
for (const p of frag.proxies ?? []) {
|
|
1953
|
-
const key = p.id || (p.to + (p.path ?? ''))
|
|
1954
|
-
if (seen.has(key)) continue
|
|
1955
|
-
seen.add(key)
|
|
1956
|
-
proxies.push(p)
|
|
1957
|
-
}
|
|
1958
|
-
for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
|
|
1959
|
-
email ??= frag.onDemandTls?.email
|
|
1960
|
-
if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
|
|
1961
|
-
acmeChallengeWebroot ??= frag.acmeChallengeWebroot
|
|
1962
|
-
if (frag.originGuard) {
|
|
1963
|
-
guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
|
|
1964
|
-
for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
|
|
2098
|
+
function resolveHetznerApiToken(configToken) {
|
|
2099
|
+
const token = configToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
|
|
2100
|
+
if (!token) {
|
|
2101
|
+
throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
|
|
1965
2102
|
}
|
|
2103
|
+
return token;
|
|
1966
2104
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
https: true,
|
|
1971
|
-
hostsManagement: false,
|
|
1972
|
-
cleanup: { hosts: false, certs: false },
|
|
1973
|
-
...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
|
|
1974
|
-
...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
|
|
1975
|
-
...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
|
|
2105
|
+
function normalizeSshPublicKey(publicKey) {
|
|
2106
|
+
const [type, body] = publicKey.trim().split(/\s+/);
|
|
2107
|
+
return body ? `${type} ${body}` : type;
|
|
1976
2108
|
}
|
|
1977
2109
|
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
`cat > ${path} <<'${delimiter}'`,
|
|
1984
|
-
content,
|
|
1985
|
-
delimiter
|
|
1986
|
-
];
|
|
1987
|
-
}
|
|
1988
|
-
function certDomainsForConfig(config) {
|
|
1989
|
-
const seen = new Set;
|
|
1990
|
-
for (const r of config.proxies) {
|
|
1991
|
-
const host = r.to;
|
|
1992
|
-
if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
|
|
1993
|
-
continue;
|
|
1994
|
-
seen.add(host);
|
|
1995
|
-
}
|
|
1996
|
-
return [...seen];
|
|
1997
|
-
}
|
|
1998
|
-
function buildCertManagementCommands(options) {
|
|
1999
|
-
const { config, proxy } = options;
|
|
2000
|
-
const webroot = config.acmeChallengeWebroot;
|
|
2001
|
-
const domains = certDomainsForConfig(config);
|
|
2002
|
-
if (!proxy.onDemandTls || !webroot || domains.length === 0)
|
|
2003
|
-
return [];
|
|
2004
|
-
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
2005
|
-
const version = proxy.version ?? "latest";
|
|
2006
|
-
const certsDir = config.productionCerts.certsDir;
|
|
2007
|
-
const email = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
|
|
2008
|
-
const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
|
|
2009
|
-
const csv = domains.join(",");
|
|
2010
|
-
const spaced = domains.join(" ");
|
|
2011
|
-
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
2012
|
-
const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
|
|
2013
|
-
const renewServiceName = `rpx-cert-renew-${slug}.service`;
|
|
2014
|
-
const renewTimerName = `rpx-cert-renew-${slug}.timer`;
|
|
2015
|
-
const renewScript = [
|
|
2016
|
-
"#!/bin/sh",
|
|
2017
|
-
"# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
|
|
2018
|
-
"# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
|
|
2019
|
-
"# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
|
|
2020
|
-
"set -u",
|
|
2021
|
-
`CERTS='${certsDir}'`,
|
|
2022
|
-
`WEBROOT='${webroot}'`,
|
|
2023
|
-
`EMAIL='${email}'`,
|
|
2024
|
-
`TLSX="${tlsxCli}"`,
|
|
2025
|
-
`DOMAINS='${csv}'`,
|
|
2026
|
-
'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
2027
|
-
`for d in ${spaced}; do`,
|
|
2028
|
-
' [ -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)"',
|
|
2029
|
-
"done",
|
|
2030
|
-
'$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
|
|
2031
|
-
'rm -f "$CERTS"/*.chain.crt',
|
|
2032
|
-
'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
|
|
2033
|
-
`[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
|
|
2034
|
-
].join(`
|
|
2035
|
-
`);
|
|
2036
|
-
const renewService = [
|
|
2037
|
-
"[Unit]",
|
|
2038
|
-
`Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
|
|
2039
|
-
`After=network-online.target ${RPX_SERVICE_NAME}`,
|
|
2040
|
-
"Wants=network-online.target",
|
|
2041
|
-
"",
|
|
2042
|
-
"[Service]",
|
|
2043
|
-
"Type=oneshot",
|
|
2044
|
-
`ExecStart=${renewScriptPath}`
|
|
2045
|
-
].join(`
|
|
2046
|
-
`);
|
|
2047
|
-
const renewTimer = [
|
|
2048
|
-
"[Unit]",
|
|
2049
|
-
`Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
|
|
2050
|
-
"",
|
|
2051
|
-
"[Timer]",
|
|
2052
|
-
"OnCalendar=*-*-* 03:30:00",
|
|
2053
|
-
"RandomizedDelaySec=1h",
|
|
2054
|
-
"Persistent=true",
|
|
2055
|
-
"",
|
|
2056
|
-
"[Install]",
|
|
2057
|
-
"WantedBy=timers.target"
|
|
2058
|
-
].join(`
|
|
2110
|
+
// src/drivers/hetzner/cloud-init.ts
|
|
2111
|
+
function wrapCloudInitUserData(bootstrapScript) {
|
|
2112
|
+
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
2113
|
+
const indented = bootstrapScript.split(`
|
|
2114
|
+
`).map((line) => ` ${line}`).join(`
|
|
2059
2115
|
`);
|
|
2060
|
-
return
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
];
|
|
2071
|
-
}
|
|
2072
|
-
function buildRpxProvisionScript(options) {
|
|
2073
|
-
const { config, proxy } = options;
|
|
2074
|
-
const bunBin = options.bunBin ?? "/usr/local/bin/bun";
|
|
2075
|
-
const version = proxy.version ?? "latest";
|
|
2076
|
-
const certsDir = config.productionCerts.certsDir;
|
|
2077
|
-
const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
|
|
2078
|
-
const fragment = JSON.stringify({ slug, ...config }, null, 2);
|
|
2079
|
-
const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
|
|
2080
|
-
const upstreamTimeout = proxy.upstreamTimeout ?? 60;
|
|
2081
|
-
const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
|
|
2082
|
-
if (typeof proxy.maxUpstreamConns === "number")
|
|
2083
|
-
poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
|
|
2084
|
-
return [
|
|
2085
|
-
"set -euo pipefail",
|
|
2086
|
-
`mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
|
|
2087
|
-
`rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
|
|
2088
|
-
`(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
|
|
2089
|
-
`ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
|
|
2090
|
-
...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
|
|
2091
|
-
...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
|
|
2092
|
-
...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
|
|
2093
|
-
"[Unit]",
|
|
2094
|
-
"Description=rpx reverse-proxy gateway (managed by ts-cloud)",
|
|
2095
|
-
"After=network.target network-online.target",
|
|
2096
|
-
"Wants=network-online.target",
|
|
2097
|
-
"",
|
|
2098
|
-
"[Service]",
|
|
2099
|
-
"Type=simple",
|
|
2100
|
-
`ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
|
|
2101
|
-
`WorkingDirectory=${RPX_INSTALL_DIR}`,
|
|
2102
|
-
`Environment=BUN_INSTALL=/root/.bun`,
|
|
2103
|
-
...poolEnv,
|
|
2104
|
-
"Restart=always",
|
|
2105
|
-
"RestartSec=5",
|
|
2106
|
-
"LimitNOFILE=1048576",
|
|
2107
|
-
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
|
2108
|
-
"",
|
|
2109
|
-
"[Install]",
|
|
2110
|
-
"WantedBy=multi-user.target"
|
|
2111
|
-
].join(`
|
|
2112
|
-
`), "TS_CLOUD_RPX_UNIT_EOF"),
|
|
2113
|
-
"systemctl daemon-reload",
|
|
2114
|
-
"systemctl disable --now bun-gateway.service 2>/dev/null || true",
|
|
2115
|
-
"systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
|
|
2116
|
-
`systemctl enable ${RPX_SERVICE_NAME}`,
|
|
2117
|
-
`systemctl restart ${RPX_SERVICE_NAME}`,
|
|
2118
|
-
...buildCertManagementCommands(options)
|
|
2119
|
-
];
|
|
2116
|
+
return `#cloud-config
|
|
2117
|
+
write_files:
|
|
2118
|
+
- path: ${scriptPath}
|
|
2119
|
+
permissions: '0755'
|
|
2120
|
+
owner: root:root
|
|
2121
|
+
content: |
|
|
2122
|
+
${indented}
|
|
2123
|
+
runcmd:
|
|
2124
|
+
- [ bash, ${scriptPath} ]
|
|
2125
|
+
`;
|
|
2120
2126
|
}
|
|
2121
2127
|
|
|
2122
2128
|
// src/drivers/shared/fleet.ts
|
|
@@ -2435,7 +2441,7 @@ class HetznerDriver {
|
|
|
2435
2441
|
versions: compute.php?.versions,
|
|
2436
2442
|
default: compute.php?.default,
|
|
2437
2443
|
extensions: compute.php?.extensions,
|
|
2438
|
-
installNginx: compute
|
|
2444
|
+
installNginx: !usesRpxProxy(compute),
|
|
2439
2445
|
optimizeForProduction: compute.php?.optimizeForProduction,
|
|
2440
2446
|
ini: compute.php?.ini
|
|
2441
2447
|
});
|
|
@@ -4418,7 +4424,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4418
4424
|
appBase: appBase2,
|
|
4419
4425
|
defaultPhpVersion: phpVersion
|
|
4420
4426
|
});
|
|
4421
|
-
const useNginx = compute2
|
|
4427
|
+
const useNginx = !usesRpxProxy(compute2);
|
|
4422
4428
|
const sslProvider = resolveSslProvider(site);
|
|
4423
4429
|
const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
|
|
4424
4430
|
const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
|
|
@@ -4500,7 +4506,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4500
4506
|
healthCheckPath: site.healthCheck?.path
|
|
4501
4507
|
});
|
|
4502
4508
|
const compute = config.infrastructure?.compute;
|
|
4503
|
-
const wantsNginxStatic = kind === "server-static" && compute
|
|
4509
|
+
const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
|
|
4504
4510
|
const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
|
|
4505
4511
|
siteName,
|
|
4506
4512
|
domain: site.domain,
|
|
@@ -4761,4 +4767,4 @@ function buildCloudFrontOriginConfig(options) {
|
|
|
4761
4767
|
CustomErrorResponses: { Quantity: 0 }
|
|
4762
4768
|
};
|
|
4763
4769
|
}
|
|
4764
|
-
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR,
|
|
4770
|
+
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, HetznerClient, 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 };
|