@stacksjs/ts-cloud 0.7.16 → 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 +363 -363
- package/dist/{chunk-d1370frz.js → chunk-h4pdjzq2.js} +1 -1
- package/dist/{chunk-hb06vk29.js → chunk-vpyd42tp.js} +772 -746
- package/dist/deploy/index.js +2 -2
- package/dist/drivers/hetzner/driver.d.ts +8 -0
- 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,6 +545,473 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
|
|
|
545
545
|
];
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
// src/deploy/site-target.ts
|
|
549
|
+
function siteInstallBase(slug, siteName) {
|
|
550
|
+
return `/var/www/${slug}-${siteName}`;
|
|
551
|
+
}
|
|
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);
|
|
560
|
+
}
|
|
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";
|
|
569
|
+
}
|
|
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";
|
|
579
|
+
}
|
|
580
|
+
function hasComputeConfigured(config) {
|
|
581
|
+
return config.infrastructure?.compute != null;
|
|
582
|
+
}
|
|
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;
|
|
595
|
+
}
|
|
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 };
|
|
663
|
+
}
|
|
664
|
+
|
|
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;
|
|
677
|
+
}
|
|
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
|
+
};
|
|
687
|
+
}
|
|
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;
|
|
695
|
+
}
|
|
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";
|
|
700
|
+
}
|
|
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}`);
|
|
705
|
+
}
|
|
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;
|
|
726
|
+
}
|
|
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
|
+
});
|
|
752
|
+
}
|
|
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);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
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
|
+
};
|
|
785
|
+
}
|
|
786
|
+
if (options.proxy.onDemandTls) {
|
|
787
|
+
config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
|
|
788
|
+
}
|
|
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);
|
|
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'
|
|
810
|
+
|
|
811
|
+
const config = ${json} as const
|
|
812
|
+
|
|
813
|
+
await startProxies(config as any)
|
|
814
|
+
`;
|
|
815
|
+
}
|
|
816
|
+
function usesRpxProxy(compute) {
|
|
817
|
+
return compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx";
|
|
818
|
+
}
|
|
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
|
+
}
|
|
859
|
+
}
|
|
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] } } : {}),
|
|
869
|
+
}
|
|
870
|
+
|
|
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);
|
|
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
|
+
];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
548
1015
|
// src/drivers/shared/ufw.ts
|
|
549
1016
|
var UFW_BASE_PORTS = [80, 443];
|
|
550
1017
|
function buildUfwScript(firewall = {}) {
|
|
@@ -909,7 +1376,7 @@ function buildComputeProvisionScripts(config) {
|
|
|
909
1376
|
const phpBox = compute.runtime === "php" || !!compute.php;
|
|
910
1377
|
const needsPantry = phpBox || !!compute.managedServices;
|
|
911
1378
|
const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
|
|
912
|
-
const useNginx = compute
|
|
1379
|
+
const useNginx = !usesRpxProxy(compute);
|
|
913
1380
|
const phpProvision = phpBox ? [
|
|
914
1381
|
...pantryBootstrap,
|
|
915
1382
|
...buildPhpProvisionScript({
|
|
@@ -1355,768 +1822,307 @@ class AwsDriver {
|
|
|
1355
1822
|
const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
|
|
1356
1823
|
targets.push({
|
|
1357
1824
|
id: instance.InstanceId,
|
|
1358
|
-
name: nameTag,
|
|
1359
|
-
publicIp: instance.PublicIpAddress,
|
|
1360
|
-
privateIp: instance.PrivateIpAddress,
|
|
1361
|
-
status: instance.State?.Name
|
|
1362
|
-
});
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
return targets;
|
|
1366
|
-
}
|
|
1367
|
-
bashWrap(commands) {
|
|
1368
|
-
return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
|
|
1369
|
-
`), "TS_CLOUD_BASH_EOF"];
|
|
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);
|
|
1386
|
-
}
|
|
1387
|
-
if (!options.tags || Object.keys(options.tags).length === 0) {
|
|
1388
|
-
return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
|
|
1389
|
-
}
|
|
1390
|
-
const result = await ssm.sendCommandByTags({
|
|
1391
|
-
tags: options.tags,
|
|
1392
|
-
commands: this.bashWrap(options.commands),
|
|
1393
|
-
timeoutSeconds: options.timeoutSeconds || 600,
|
|
1394
|
-
comment: options.comment
|
|
1395
|
-
});
|
|
1396
|
-
return {
|
|
1397
|
-
success: result.success,
|
|
1398
|
-
instanceCount: result.instanceCount,
|
|
1399
|
-
perInstance: result.perInstance.map((item) => ({
|
|
1400
|
-
instanceId: item.instanceId,
|
|
1401
|
-
status: item.status,
|
|
1402
|
-
output: item.output,
|
|
1403
|
-
error: item.error
|
|
1404
|
-
})),
|
|
1405
|
-
error: result.error
|
|
1406
|
-
};
|
|
1407
|
-
}
|
|
1408
|
-
async pollSsmCommand(ssm, commandId, expectedCount) {
|
|
1409
|
-
const pollInterval = 3000;
|
|
1410
|
-
const maxWait = 600000;
|
|
1411
|
-
const startTime = Date.now();
|
|
1412
|
-
const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
|
|
1413
|
-
let lastInvocations = [];
|
|
1414
|
-
while (Date.now() - startTime < maxWait) {
|
|
1415
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1416
|
-
try {
|
|
1417
|
-
const invocations = await ssm.listCommandInvocations({ CommandId: commandId, Details: true });
|
|
1418
|
-
lastInvocations = invocations;
|
|
1419
|
-
if (lastInvocations.length >= expectedCount && lastInvocations.every((i) => terminalStatuses.has(i.Status || ""))) {
|
|
1420
|
-
break;
|
|
1421
|
-
}
|
|
1422
|
-
} catch {}
|
|
1423
|
-
}
|
|
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
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
// src/drivers/hetzner/driver.ts
|
|
1441
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1442
|
-
import { homedir } from "node:os";
|
|
1443
|
-
import { join as join3 } from "node:path";
|
|
1444
|
-
import { execSync } from "node:child_process";
|
|
1445
|
-
|
|
1446
|
-
// src/drivers/hetzner/client.ts
|
|
1447
|
-
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
1448
|
-
|
|
1449
|
-
class HetznerClient {
|
|
1450
|
-
name = "hetzner";
|
|
1451
|
-
apiToken;
|
|
1452
|
-
baseUrl;
|
|
1453
|
-
fetchImpl;
|
|
1454
|
-
constructor(options) {
|
|
1455
|
-
this.apiToken = options.apiToken;
|
|
1456
|
-
this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
|
|
1457
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
1458
|
-
}
|
|
1459
|
-
async request(method, path, body) {
|
|
1460
|
-
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
1461
|
-
method,
|
|
1462
|
-
headers: {
|
|
1463
|
-
Authorization: `Bearer ${this.apiToken}`,
|
|
1464
|
-
"Content-Type": "application/json"
|
|
1465
|
-
},
|
|
1466
|
-
body: body === undefined ? undefined : JSON.stringify(body)
|
|
1467
|
-
});
|
|
1468
|
-
const text = await response.text();
|
|
1469
|
-
let data;
|
|
1470
|
-
try {
|
|
1471
|
-
data = text ? JSON.parse(text) : {};
|
|
1472
|
-
} catch {
|
|
1473
|
-
if (!response.ok) {
|
|
1474
|
-
const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
|
|
1475
|
-
throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
|
|
1476
|
-
}
|
|
1477
|
-
throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
|
|
1478
|
-
}
|
|
1479
|
-
if (!response.ok) {
|
|
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");
|
|
1825
|
+
name: nameTag,
|
|
1826
|
+
publicIp: instance.PublicIpAddress,
|
|
1827
|
+
privateIp: instance.PrivateIpAddress,
|
|
1828
|
+
status: instance.State?.Name
|
|
1829
|
+
});
|
|
1613
1830
|
}
|
|
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
1831
|
}
|
|
1628
|
-
|
|
1832
|
+
return targets;
|
|
1629
1833
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
if (!token) {
|
|
1634
|
-
throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
|
|
1834
|
+
bashWrap(commands) {
|
|
1835
|
+
return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
|
|
1836
|
+
`), "TS_CLOUD_BASH_EOF"];
|
|
1635
1837
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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} ]
|
|
1658
|
-
`;
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
// src/deploy/site-target.ts
|
|
1662
|
-
function siteInstallBase(slug, siteName) {
|
|
1663
|
-
return `/var/www/${slug}-${siteName}`;
|
|
1664
|
-
}
|
|
1665
|
-
var PHP_SITE_TYPES = new Set([
|
|
1666
|
-
"laravel",
|
|
1667
|
-
"php",
|
|
1668
|
-
"statamic",
|
|
1669
|
-
"wordpress"
|
|
1670
|
-
]);
|
|
1671
|
-
function isPhpSite(site) {
|
|
1672
|
-
return site.type != null && PHP_SITE_TYPES.has(site.type);
|
|
1673
|
-
}
|
|
1674
|
-
function resolveSiteDeployTarget(site) {
|
|
1675
|
-
if (site.deploy)
|
|
1676
|
-
return site.deploy;
|
|
1677
|
-
if (isPhpSite(site))
|
|
1678
|
-
return "server";
|
|
1679
|
-
if (site.start)
|
|
1680
|
-
return "server";
|
|
1681
|
-
return "bucket";
|
|
1682
|
-
}
|
|
1683
|
-
function resolveSiteKind(site) {
|
|
1684
|
-
if (site.redirect)
|
|
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";
|
|
1692
|
-
}
|
|
1693
|
-
function hasComputeConfigured(config) {
|
|
1694
|
-
return config.infrastructure?.compute != null;
|
|
1695
|
-
}
|
|
1696
|
-
function validateDeploymentConfig(config) {
|
|
1697
|
-
const errors = [];
|
|
1698
|
-
const warnings = [];
|
|
1699
|
-
const sites = config.sites || {};
|
|
1700
|
-
const computeConfigured = hasComputeConfigured(config);
|
|
1701
|
-
const coexistence = deploymentCoexistenceError(config);
|
|
1702
|
-
if (coexistence)
|
|
1703
|
-
errors.push(coexistence);
|
|
1704
|
-
const portOwners = new Map;
|
|
1705
|
-
for (const [name, site] of Object.entries(sites)) {
|
|
1706
|
-
if (!site) {
|
|
1707
|
-
continue;
|
|
1708
|
-
}
|
|
1709
|
-
const target = resolveSiteDeployTarget(site);
|
|
1710
|
-
const kind = resolveSiteKind(site);
|
|
1711
|
-
if (kind === "redirect") {
|
|
1712
|
-
if (!site.domain)
|
|
1713
|
-
errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
|
|
1714
|
-
const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
|
|
1715
|
-
if (!to)
|
|
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.`);
|
|
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
|
|
1848
|
+
});
|
|
1849
|
+
if (!sendResult.CommandId) {
|
|
1850
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1719
1851
|
}
|
|
1720
|
-
|
|
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;
|
|
1852
|
+
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1728
1853
|
}
|
|
1729
|
-
if (
|
|
1730
|
-
|
|
1731
|
-
continue;
|
|
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" };
|
|
1732
1856
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
+
};
|
|
1874
|
+
}
|
|
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;
|
|
1750
1888
|
}
|
|
1751
|
-
}
|
|
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
|
-
}
|
|
1889
|
+
} catch {}
|
|
1773
1890
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
out.preservePath = input.preservePath;
|
|
1789
|
-
return out;
|
|
1790
|
-
}
|
|
1791
|
-
function resolveRouteAuth(site) {
|
|
1792
|
-
const auth = site.auth;
|
|
1793
|
-
if (!auth || auth.enabled === false || !auth.password)
|
|
1794
|
-
return;
|
|
1795
|
-
return {
|
|
1796
|
-
username: auth.username || "admin",
|
|
1797
|
-
password: auth.password,
|
|
1798
|
-
...auth.realm ? { realm: auth.realm } : {}
|
|
1799
|
-
};
|
|
1800
|
-
}
|
|
1801
|
-
function normalizeRoutePath(path) {
|
|
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}`);
|
|
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"
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1818
1905
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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`);
|
|
1839
1945
|
}
|
|
1840
|
-
if (
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
proxies.push({
|
|
1845
|
-
to: site.domain,
|
|
1846
|
-
path,
|
|
1847
|
-
from,
|
|
1848
|
-
id,
|
|
1849
|
-
...auth ? { auth } : {},
|
|
1850
|
-
...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
|
|
1851
|
-
});
|
|
1852
|
-
} else {
|
|
1853
|
-
proxies.push({
|
|
1854
|
-
to: site.domain,
|
|
1855
|
-
path,
|
|
1856
|
-
static: `${wwwRoot}/${installSlug}-${name}/current`,
|
|
1857
|
-
cleanUrls: site.pathRewriteStyle !== "flat",
|
|
1858
|
-
spa: site.spa ?? false,
|
|
1859
|
-
...auth ? { auth } : {},
|
|
1860
|
-
id
|
|
1861
|
-
});
|
|
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}`);
|
|
1862
1950
|
}
|
|
1863
|
-
|
|
1951
|
+
return data;
|
|
1864
1952
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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);
|
|
1874
|
-
}
|
|
1953
|
+
async listServers() {
|
|
1954
|
+
const data = await this.request("GET", "/servers");
|
|
1955
|
+
return data.servers;
|
|
1875
1956
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
return (b.path?.length ?? 0) - (a.path?.length ?? 0);
|
|
1880
|
-
});
|
|
1881
|
-
const config = {
|
|
1882
|
-
proxies,
|
|
1883
|
-
productionCerts: { certsDir },
|
|
1884
|
-
https: true,
|
|
1885
|
-
hostsManagement: false,
|
|
1886
|
-
cleanup: { hosts: false, certs: false }
|
|
1887
|
-
};
|
|
1888
|
-
if (options.proxy.onDemandTls && domains.size > 0) {
|
|
1889
|
-
config.onDemandTls = {
|
|
1890
|
-
enabled: true,
|
|
1891
|
-
allowedSuffixes: [...domains],
|
|
1892
|
-
email: options.proxy.onDemandTlsEmail,
|
|
1893
|
-
certsDir
|
|
1894
|
-
};
|
|
1957
|
+
async getServer(id) {
|
|
1958
|
+
const data = await this.request("GET", `/servers/${id}`);
|
|
1959
|
+
return data.server;
|
|
1895
1960
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
});
|
|
@@ -3019,11 +3025,20 @@ ${out}`);
|
|
|
3019
3025
|
"-o",
|
|
3020
3026
|
"LogLevel=ERROR"
|
|
3021
3027
|
];
|
|
3028
|
+
static SSH_KEEPALIVE_OPTS = [
|
|
3029
|
+
"-o",
|
|
3030
|
+
"ConnectTimeout=30",
|
|
3031
|
+
"-o",
|
|
3032
|
+
"ServerAliveInterval=15",
|
|
3033
|
+
"-o",
|
|
3034
|
+
"ServerAliveCountMax=4"
|
|
3035
|
+
];
|
|
3022
3036
|
sshBaseArgs(host, extra = []) {
|
|
3023
3037
|
return [
|
|
3024
3038
|
"-i",
|
|
3025
3039
|
this.sshPrivateKeyPath,
|
|
3026
3040
|
...HetznerDriver.SSH_HOST_KEY_OPTS,
|
|
3041
|
+
...HetznerDriver.SSH_KEEPALIVE_OPTS,
|
|
3027
3042
|
"-o",
|
|
3028
3043
|
"BatchMode=yes",
|
|
3029
3044
|
...extra,
|
|
@@ -3031,16 +3046,27 @@ ${out}`);
|
|
|
3031
3046
|
];
|
|
3032
3047
|
}
|
|
3033
3048
|
scpToHost(host, localPath, remotePath) {
|
|
3034
|
-
|
|
3049
|
+
const cmd = [
|
|
3035
3050
|
"scp",
|
|
3036
3051
|
"-i",
|
|
3037
3052
|
this.sshPrivateKeyPath,
|
|
3038
3053
|
...HetznerDriver.SSH_HOST_KEY_OPTS,
|
|
3054
|
+
...HetznerDriver.SSH_KEEPALIVE_OPTS,
|
|
3039
3055
|
"-o",
|
|
3040
3056
|
"BatchMode=yes",
|
|
3041
3057
|
localPath,
|
|
3042
3058
|
`${this.sshUser}@${host}:${remotePath}`
|
|
3043
|
-
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" ")
|
|
3059
|
+
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" ");
|
|
3060
|
+
const attempts = 3;
|
|
3061
|
+
for (let attempt = 1;attempt <= attempts; attempt++) {
|
|
3062
|
+
try {
|
|
3063
|
+
execSync(cmd, { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
|
|
3064
|
+
return;
|
|
3065
|
+
} catch (error) {
|
|
3066
|
+
if (attempt === attempts)
|
|
3067
|
+
throw new Error(formatSshFailure(error));
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3044
3070
|
}
|
|
3045
3071
|
sshExec(host, script) {
|
|
3046
3072
|
const escaped = script.replace(/'/g, `'\\''`);
|
|
@@ -4398,7 +4424,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4398
4424
|
appBase: appBase2,
|
|
4399
4425
|
defaultPhpVersion: phpVersion
|
|
4400
4426
|
});
|
|
4401
|
-
const useNginx = compute2
|
|
4427
|
+
const useNginx = !usesRpxProxy(compute2);
|
|
4402
4428
|
const sslProvider = resolveSslProvider(site);
|
|
4403
4429
|
const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
|
|
4404
4430
|
const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
|
|
@@ -4480,7 +4506,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4480
4506
|
healthCheckPath: site.healthCheck?.path
|
|
4481
4507
|
});
|
|
4482
4508
|
const compute = config.infrastructure?.compute;
|
|
4483
|
-
const wantsNginxStatic = kind === "server-static" && compute
|
|
4509
|
+
const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
|
|
4484
4510
|
const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
|
|
4485
4511
|
siteName,
|
|
4486
4512
|
domain: site.domain,
|
|
@@ -4741,4 +4767,4 @@ function buildCloudFrontOriginConfig(options) {
|
|
|
4741
4767
|
CustomErrorResponses: { Quantity: 0 }
|
|
4742
4768
|
};
|
|
4743
4769
|
}
|
|
4744
|
-
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 };
|