@stacksjs/ts-cloud 0.7.17 → 0.7.19
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 +747 -621
- package/dist/{chunk-pr3g0b0t.js → chunk-nt2hrnva.js} +744 -52
- package/dist/{chunk-qergre5a.js → chunk-xgnxz5dz.js} +828 -758
- package/dist/deploy/dashboard-auth.d.ts +82 -0
- package/dist/deploy/dashboard-auth.test.d.ts +1 -0
- package/dist/deploy/dashboard-guard.d.ts +40 -0
- package/dist/deploy/dashboard-login-page.d.ts +16 -0
- package/dist/deploy/dashboard-pages.test.d.ts +1 -0
- package/dist/deploy/dashboard-policy.d.ts +37 -0
- package/dist/deploy/dashboard-policy.test.d.ts +1 -0
- package/dist/deploy/dashboard-scope.d.ts +24 -0
- package/dist/deploy/dashboard-scope.test.d.ts +1 -0
- package/dist/deploy/dashboard-session.d.ts +52 -0
- package/dist/deploy/dashboard-users.d.ts +60 -0
- package/dist/deploy/index.js +2 -2
- package/dist/deploy/local-dashboard-server.d.ts +1 -0
- package/dist/drivers/hetzner/client.d.ts +9 -1
- package/dist/drivers/hetzner/config.d.ts +83 -0
- package/dist/drivers/hetzner/config.test.d.ts +1 -0
- package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -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 +4 -4
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +1 -1
- package/dist/ui/server/backups.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +1 -1
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +1 -1
- package/dist/ui/server/security.html +1 -1
- package/dist/ui/server/services.html +4 -4
- package/dist/ui/server/sites.html +4 -4
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +1214 -0
- package/dist/ui/server/terminal.html +4 -4
- package/dist/ui/server/workers.html +4 -4
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +4 -4
- package/dist/ui/serverless/cost.html +1 -1
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +4 -4
- package/dist/ui/serverless/firewall.html +1 -1
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/metrics.html +1 -1
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/scheduler.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/pages/partials/head.stx +7 -0
- package/dist/ui-src/pages/partials/nav.stx +43 -6
- package/dist/ui-src/pages/server/firewall.stx +1 -1
- package/dist/ui-src/pages/server/sites.stx +42 -20
- package/dist/ui-src/pages/server/team.stx +171 -0
- 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({
|
|
@@ -1362,761 +1829,360 @@ class AwsDriver {
|
|
|
1362
1829
|
});
|
|
1363
1830
|
}
|
|
1364
1831
|
}
|
|
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;
|
|
1832
|
+
return targets;
|
|
1602
1833
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
|
1848
|
+
});
|
|
1849
|
+
if (!sendResult.CommandId) {
|
|
1850
|
+
return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
|
|
1613
1851
|
}
|
|
1614
|
-
|
|
1852
|
+
return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
|
|
1615
1853
|
}
|
|
1616
|
-
|
|
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" };
|
|
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
|
+
};
|
|
1617
1874
|
}
|
|
1618
|
-
async
|
|
1619
|
-
const pollInterval =
|
|
1620
|
-
const maxWait =
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
return server;
|
|
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) {
|
|
1626
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 {}
|
|
1627
1890
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
+
};
|
|
1635
1904
|
}
|
|
1636
|
-
return token;
|
|
1637
|
-
}
|
|
1638
|
-
function normalizeSshPublicKey(publicKey) {
|
|
1639
|
-
const [type, body] = publicKey.trim().split(/\s+/);
|
|
1640
|
-
return body ? `${type} ${body}` : type;
|
|
1641
1905
|
}
|
|
1642
1906
|
|
|
1643
|
-
// src/drivers/hetzner/
|
|
1644
|
-
|
|
1645
|
-
|
|
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} ]
|
|
1658
|
-
`;
|
|
1659
|
-
}
|
|
1907
|
+
// src/drivers/hetzner/driver.ts
|
|
1908
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1909
|
+
import { execSync } from "node:child_process";
|
|
1660
1910
|
|
|
1661
|
-
// src/
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
"
|
|
1667
|
-
"
|
|
1668
|
-
"
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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;
|
|
1911
|
+
// src/drivers/hetzner/config.ts
|
|
1912
|
+
import { homedir } from "node:os";
|
|
1913
|
+
import { join as join2 } from "node:path";
|
|
1914
|
+
var HETZNER_DEFAULTS = {
|
|
1915
|
+
location: "fsn1",
|
|
1916
|
+
image: "ubuntu-24.04",
|
|
1917
|
+
sshUser: "root",
|
|
1918
|
+
sshPrivateKeyPath: "~/.ssh/id_ed25519"
|
|
1919
|
+
};
|
|
1920
|
+
function expandHome(path) {
|
|
1921
|
+
return path.startsWith("~/") ? join2(homedir(), path.slice(2)) : path;
|
|
1695
1922
|
}
|
|
1696
|
-
function
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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.`);
|
|
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;
|
|
1728
|
-
}
|
|
1729
|
-
if (target === "server" && !site.start && !site.root) {
|
|
1730
|
-
errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
|
|
1731
|
-
continue;
|
|
1732
|
-
}
|
|
1733
|
-
if (kind === "server-php") {
|
|
1734
|
-
if (!computeConfigured) {
|
|
1735
|
-
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.`);
|
|
1736
|
-
}
|
|
1737
|
-
if (!site.repository?.url) {
|
|
1738
|
-
errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but has no \`repository.url\` to clone. PHP sites deploy via git.`);
|
|
1739
|
-
}
|
|
1740
|
-
} else if (kind === "server-app") {
|
|
1741
|
-
if (!computeConfigured) {
|
|
1742
|
-
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).`);
|
|
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);
|
|
1750
|
-
}
|
|
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
|
-
}
|
|
1773
|
-
}
|
|
1923
|
+
function env(...names) {
|
|
1924
|
+
for (const name of names) {
|
|
1925
|
+
const value = process.env[name]?.trim();
|
|
1926
|
+
if (value)
|
|
1927
|
+
return value;
|
|
1774
1928
|
}
|
|
1775
|
-
return
|
|
1929
|
+
return;
|
|
1776
1930
|
}
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
const out = { to: input.to };
|
|
1785
|
-
if (input.status != null)
|
|
1786
|
-
out.status = input.status;
|
|
1787
|
-
if (input.preservePath != null)
|
|
1788
|
-
out.preservePath = input.preservePath;
|
|
1789
|
-
return out;
|
|
1931
|
+
function first(...values) {
|
|
1932
|
+
for (const value of values) {
|
|
1933
|
+
const trimmed = value?.trim();
|
|
1934
|
+
if (trimmed)
|
|
1935
|
+
return trimmed;
|
|
1936
|
+
}
|
|
1937
|
+
return;
|
|
1790
1938
|
}
|
|
1791
|
-
function
|
|
1792
|
-
|
|
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
|
-
};
|
|
1939
|
+
function resolveHetznerApiToken(explicit, config) {
|
|
1940
|
+
return first(explicit, config?.hetzner?.apiToken, env("HCLOUD_TOKEN", "HETZNER_API_TOKEN"));
|
|
1800
1941
|
}
|
|
1801
|
-
function
|
|
1802
|
-
|
|
1803
|
-
return;
|
|
1804
|
-
let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
1805
|
-
if (!p.startsWith("/"))
|
|
1806
|
-
p = `/${p}`;
|
|
1807
|
-
return p === "" || p === "/" ? undefined : p;
|
|
1942
|
+
function resolveHetznerLocation(config, explicit) {
|
|
1943
|
+
return first(explicit, config?.hetzner?.location, env("HCLOUD_LOCATION", "HETZNER_LOCATION")) ?? HETZNER_DEFAULTS.location;
|
|
1808
1944
|
}
|
|
1809
|
-
function
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
return cleaned.length > 0 ? cleaned : "rpx";
|
|
1945
|
+
function resolveHetznerImage(config, explicit) {
|
|
1946
|
+
const compute = config?.infrastructure?.compute;
|
|
1947
|
+
return first(explicit, compute?.image, config?.hetzner?.image, env("HCLOUD_IMAGE", "HETZNER_IMAGE")) ?? HETZNER_DEFAULTS.image;
|
|
1813
1948
|
}
|
|
1814
|
-
function
|
|
1815
|
-
|
|
1816
|
-
return `localhost:${port}`;
|
|
1817
|
-
return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
|
|
1949
|
+
function resolveHetznerSshUser(config, explicit) {
|
|
1950
|
+
return first(explicit, config?.hetzner?.sshUser, env("HCLOUD_SSH_USER", "HETZNER_SSH_USER")) ?? HETZNER_DEFAULTS.sshUser;
|
|
1818
1951
|
}
|
|
1819
|
-
function
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
const
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1952
|
+
function resolveHetznerSshPrivateKeyPath(config, explicit) {
|
|
1953
|
+
return expandHome(first(explicit, config?.hetzner?.sshPrivateKeyPath, env("HCLOUD_SSH_KEY", "HETZNER_SSH_KEY")) ?? HETZNER_DEFAULTS.sshPrivateKeyPath);
|
|
1954
|
+
}
|
|
1955
|
+
function resolveHetznerSshPublicKeyPath(config, explicit, privateKeyPath) {
|
|
1956
|
+
const explicitPath = first(explicit, config?.hetzner?.sshPublicKeyPath, env("HCLOUD_SSH_PUBLIC_KEY", "HETZNER_SSH_PUBLIC_KEY"));
|
|
1957
|
+
if (explicitPath)
|
|
1958
|
+
return expandHome(explicitPath);
|
|
1959
|
+
return `${privateKeyPath ?? resolveHetznerSshPrivateKeyPath(config)}.pub`;
|
|
1960
|
+
}
|
|
1961
|
+
function resolveHetznerSettings(config, overrides = {}) {
|
|
1962
|
+
const sshPrivateKeyPath = resolveHetznerSshPrivateKeyPath(config, overrides.sshPrivateKeyPath);
|
|
1963
|
+
return {
|
|
1964
|
+
apiToken: resolveHetznerApiToken(overrides.apiToken, config),
|
|
1965
|
+
location: resolveHetznerLocation(config, overrides.location),
|
|
1966
|
+
image: resolveHetznerImage(config, overrides.image),
|
|
1967
|
+
sshUser: resolveHetznerSshUser(config, overrides.sshUser),
|
|
1968
|
+
sshPrivateKeyPath,
|
|
1969
|
+
sshPublicKeyPath: resolveHetznerSshPublicKeyPath(config, overrides.sshPublicKeyPath, sshPrivateKeyPath)
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// src/drivers/hetzner/client.ts
|
|
1974
|
+
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
1975
|
+
|
|
1976
|
+
class HetznerClient {
|
|
1977
|
+
name = "hetzner";
|
|
1978
|
+
apiToken;
|
|
1979
|
+
baseUrl;
|
|
1980
|
+
fetchImpl;
|
|
1981
|
+
constructor(options) {
|
|
1982
|
+
this.apiToken = options.apiToken;
|
|
1983
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
|
|
1984
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
1985
|
+
}
|
|
1986
|
+
async request(method, path, body) {
|
|
1987
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
1988
|
+
method,
|
|
1989
|
+
headers: {
|
|
1990
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
1991
|
+
"Content-Type": "application/json"
|
|
1992
|
+
},
|
|
1993
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
1994
|
+
});
|
|
1995
|
+
const text = await response.text();
|
|
1996
|
+
let data;
|
|
1997
|
+
try {
|
|
1998
|
+
data = text ? JSON.parse(text) : {};
|
|
1999
|
+
} catch {
|
|
2000
|
+
if (!response.ok) {
|
|
2001
|
+
const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
|
|
2002
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
|
|
2003
|
+
}
|
|
2004
|
+
throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
|
|
1839
2005
|
}
|
|
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
|
-
});
|
|
2006
|
+
if (!response.ok) {
|
|
2007
|
+
const message = data.error?.message || response.statusText || "Hetzner API error";
|
|
2008
|
+
const code = data.error?.code ? ` [${data.error.code}]` : "";
|
|
2009
|
+
throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
|
|
1862
2010
|
}
|
|
1863
|
-
|
|
2011
|
+
return data;
|
|
1864
2012
|
}
|
|
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
|
-
}
|
|
2013
|
+
async listServers() {
|
|
2014
|
+
const data = await this.request("GET", "/servers");
|
|
2015
|
+
return data.servers;
|
|
1875
2016
|
}
|
|
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
|
-
};
|
|
2017
|
+
async getServer(id) {
|
|
2018
|
+
const data = await this.request("GET", `/servers/${id}`);
|
|
2019
|
+
return data.server;
|
|
1895
2020
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
2021
|
+
async createServer(options) {
|
|
2022
|
+
const data = await this.request("POST", "/servers", {
|
|
2023
|
+
name: options.name,
|
|
2024
|
+
server_type: options.serverType,
|
|
2025
|
+
image: options.image,
|
|
2026
|
+
location: options.location,
|
|
2027
|
+
datacenter: options.datacenter,
|
|
2028
|
+
ssh_keys: options.sshKeys,
|
|
2029
|
+
user_data: options.userData,
|
|
2030
|
+
labels: options.labels,
|
|
2031
|
+
firewalls: options.firewalls,
|
|
2032
|
+
networks: options.networks,
|
|
2033
|
+
start_after_create: true
|
|
2034
|
+
});
|
|
2035
|
+
return { server: data.server, action: data.action };
|
|
2036
|
+
}
|
|
2037
|
+
async listNetworks() {
|
|
2038
|
+
const data = await this.request("GET", "/networks");
|
|
2039
|
+
return data.networks;
|
|
2040
|
+
}
|
|
2041
|
+
async createNetwork(options) {
|
|
2042
|
+
const ipRange = options.ipRange ?? "10.0.0.0/16";
|
|
2043
|
+
const data = await this.request("POST", "/networks", {
|
|
2044
|
+
name: options.name,
|
|
2045
|
+
ip_range: ipRange,
|
|
2046
|
+
subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
|
|
2047
|
+
labels: options.labels
|
|
2048
|
+
});
|
|
2049
|
+
return data.network;
|
|
2050
|
+
}
|
|
2051
|
+
async deleteNetwork(id) {
|
|
2052
|
+
await this.request("DELETE", `/networks/${id}`);
|
|
2053
|
+
}
|
|
2054
|
+
async listLoadBalancers() {
|
|
2055
|
+
const data = await this.request("GET", "/load_balancers");
|
|
2056
|
+
return data.load_balancers;
|
|
2057
|
+
}
|
|
2058
|
+
async createLoadBalancer(options) {
|
|
2059
|
+
const data = await this.request("POST", "/load_balancers", {
|
|
2060
|
+
name: options.name,
|
|
2061
|
+
load_balancer_type: options.type ?? "lb11",
|
|
2062
|
+
location: options.location,
|
|
2063
|
+
network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
|
|
2064
|
+
network: options.network,
|
|
2065
|
+
labels: options.labels,
|
|
2066
|
+
targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
|
|
2067
|
+
services: options.services.map((s) => ({
|
|
2068
|
+
protocol: s.protocol ?? "tcp",
|
|
2069
|
+
listen_port: s.listenPort,
|
|
2070
|
+
destination_port: s.destinationPort,
|
|
2071
|
+
health_check: {
|
|
2072
|
+
protocol: "http",
|
|
2073
|
+
port: 80,
|
|
2074
|
+
interval: 15,
|
|
2075
|
+
timeout: 10,
|
|
2076
|
+
retries: 3,
|
|
2077
|
+
http: { path: "/", status_codes: ["2??", "3??"] }
|
|
2078
|
+
}
|
|
2079
|
+
}))
|
|
2080
|
+
});
|
|
2081
|
+
return data.load_balancer;
|
|
2082
|
+
}
|
|
2083
|
+
async deleteLoadBalancer(id) {
|
|
2084
|
+
await this.request("DELETE", `/load_balancers/${id}`);
|
|
2085
|
+
}
|
|
2086
|
+
async deleteServer(id) {
|
|
2087
|
+
const data = await this.request("DELETE", `/servers/${id}`);
|
|
2088
|
+
return data.action;
|
|
2089
|
+
}
|
|
2090
|
+
async listFirewalls() {
|
|
2091
|
+
const data = await this.request("GET", "/firewalls");
|
|
2092
|
+
return data.firewalls;
|
|
2093
|
+
}
|
|
2094
|
+
async createFirewall(options) {
|
|
2095
|
+
const data = await this.request("POST", "/firewalls", {
|
|
2096
|
+
name: options.name,
|
|
2097
|
+
rules: options.rules,
|
|
2098
|
+
labels: options.labels,
|
|
2099
|
+
apply_to: options.applyTo
|
|
2100
|
+
});
|
|
2101
|
+
return { firewall: data.firewall, actions: data.actions };
|
|
2102
|
+
}
|
|
2103
|
+
async setFirewallRules(firewallId, rules) {
|
|
2104
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
|
|
2105
|
+
rules
|
|
2106
|
+
});
|
|
2107
|
+
return data.actions ?? [];
|
|
2108
|
+
}
|
|
2109
|
+
async deleteFirewall(firewallId) {
|
|
2110
|
+
await this.request("DELETE", `/firewalls/${firewallId}`);
|
|
2111
|
+
}
|
|
2112
|
+
async applyFirewallToResources(firewallId, applyTo) {
|
|
2113
|
+
const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
2114
|
+
apply_to: applyTo
|
|
2115
|
+
});
|
|
2116
|
+
return data.actions;
|
|
2117
|
+
}
|
|
2118
|
+
async listSshKeys() {
|
|
2119
|
+
const data = await this.request("GET", "/ssh_keys");
|
|
2120
|
+
return data.ssh_keys;
|
|
2121
|
+
}
|
|
2122
|
+
async createSshKey(options) {
|
|
2123
|
+
const data = await this.request("POST", "/ssh_keys", {
|
|
2124
|
+
name: options.name,
|
|
2125
|
+
public_key: options.publicKey,
|
|
2126
|
+
labels: options.labels
|
|
2127
|
+
});
|
|
2128
|
+
return data.ssh_key;
|
|
2129
|
+
}
|
|
2130
|
+
async waitForAction(actionId, options) {
|
|
2131
|
+
const pollInterval = options?.pollIntervalMs ?? 2000;
|
|
2132
|
+
const maxWait = options?.maxWaitMs ?? 300000;
|
|
2133
|
+
const start = Date.now();
|
|
2134
|
+
while (Date.now() - start < maxWait) {
|
|
2135
|
+
const data = await this.request("GET", `/actions/${actionId}`);
|
|
2136
|
+
if (data.action.status === "success")
|
|
2137
|
+
return data.action;
|
|
2138
|
+
if (data.action.status === "error") {
|
|
2139
|
+
throw new Error(data.action.error?.message || "Hetzner action failed");
|
|
2140
|
+
}
|
|
2141
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
2142
|
+
}
|
|
2143
|
+
throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
|
|
1898
2144
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2145
|
+
async waitForServerRunning(serverId, options) {
|
|
2146
|
+
const pollInterval = options?.pollIntervalMs ?? 3000;
|
|
2147
|
+
const maxWait = options?.maxWaitMs ?? 600000;
|
|
2148
|
+
const start = Date.now();
|
|
2149
|
+
while (Date.now() - start < maxWait) {
|
|
2150
|
+
const server = await this.getServer(serverId);
|
|
2151
|
+
if (server.status === "running")
|
|
2152
|
+
return server;
|
|
2153
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
2154
|
+
}
|
|
2155
|
+
throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
|
|
1906
2156
|
}
|
|
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
2157
|
}
|
|
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)
|
|
2158
|
+
function resolveHetznerApiToken2(configToken, config) {
|
|
2159
|
+
const token = resolveHetznerApiToken(configToken, config);
|
|
2160
|
+
if (!token) {
|
|
2161
|
+
throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
|
|
1965
2162
|
}
|
|
2163
|
+
return token;
|
|
1966
2164
|
}
|
|
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] } } : {}),
|
|
2165
|
+
function normalizeSshPublicKey(publicKey) {
|
|
2166
|
+
const [type, body] = publicKey.trim().split(/\s+/);
|
|
2167
|
+
return body ? `${type} ${body}` : type;
|
|
1976
2168
|
}
|
|
1977
2169
|
|
|
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(`
|
|
2170
|
+
// src/drivers/hetzner/cloud-init.ts
|
|
2171
|
+
function wrapCloudInitUserData(bootstrapScript) {
|
|
2172
|
+
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
2173
|
+
const indented = bootstrapScript.split(`
|
|
2174
|
+
`).map((line) => ` ${line}`).join(`
|
|
2059
2175
|
`);
|
|
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
|
-
];
|
|
2176
|
+
return `#cloud-config
|
|
2177
|
+
write_files:
|
|
2178
|
+
- path: ${scriptPath}
|
|
2179
|
+
permissions: '0755'
|
|
2180
|
+
owner: root:root
|
|
2181
|
+
content: |
|
|
2182
|
+
${indented}
|
|
2183
|
+
runcmd:
|
|
2184
|
+
- [ bash, ${scriptPath} ]
|
|
2185
|
+
`;
|
|
2120
2186
|
}
|
|
2121
2187
|
|
|
2122
2188
|
// src/drivers/shared/fleet.ts
|
|
@@ -2132,21 +2198,21 @@ function resolveFleetTopology(compute = {}) {
|
|
|
2132
2198
|
};
|
|
2133
2199
|
}
|
|
2134
2200
|
function buildFleetServicesEnv(servicesPrivateIp, database) {
|
|
2135
|
-
const
|
|
2201
|
+
const env2 = {
|
|
2136
2202
|
REDIS_HOST: servicesPrivateIp,
|
|
2137
2203
|
MEILISEARCH_HOST: `http://${servicesPrivateIp}:7700`
|
|
2138
2204
|
};
|
|
2139
2205
|
if (database?.name) {
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2206
|
+
env2.DB_CONNECTION = database.engine === "postgres" ? "pgsql" : "mysql";
|
|
2207
|
+
env2.DB_HOST = servicesPrivateIp;
|
|
2208
|
+
env2.DB_PORT = String(database.port ?? (database.engine === "postgres" ? 5432 : 3306));
|
|
2209
|
+
env2.DB_DATABASE = database.name;
|
|
2144
2210
|
if (database.username)
|
|
2145
|
-
|
|
2211
|
+
env2.DB_USERNAME = database.username;
|
|
2146
2212
|
if (database.password)
|
|
2147
|
-
|
|
2213
|
+
env2.DB_PASSWORD = database.password;
|
|
2148
2214
|
}
|
|
2149
|
-
return
|
|
2215
|
+
return env2;
|
|
2150
2216
|
}
|
|
2151
2217
|
|
|
2152
2218
|
// src/drivers/hetzner/firewall-rules.ts
|
|
@@ -2200,10 +2266,10 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
|
|
|
2200
2266
|
|
|
2201
2267
|
// src/drivers/hetzner/state.ts
|
|
2202
2268
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2203
|
-
import { join as
|
|
2269
|
+
import { join as join3 } from "node:path";
|
|
2204
2270
|
var STATE_DIR = ".ts-cloud/state";
|
|
2205
2271
|
function driverStatePath(stackName) {
|
|
2206
|
-
return
|
|
2272
|
+
return join3(process.cwd(), STATE_DIR, `${stackName}.json`);
|
|
2207
2273
|
}
|
|
2208
2274
|
async function readDriverState(stackName) {
|
|
2209
2275
|
try {
|
|
@@ -2215,7 +2281,7 @@ async function readDriverState(stackName) {
|
|
|
2215
2281
|
}
|
|
2216
2282
|
async function writeDriverState(stackName, state) {
|
|
2217
2283
|
const path = driverStatePath(stackName);
|
|
2218
|
-
await mkdir(
|
|
2284
|
+
await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
|
|
2219
2285
|
await writeFile(path, `${JSON.stringify(state, null, 2)}
|
|
2220
2286
|
`, "utf8");
|
|
2221
2287
|
}
|
|
@@ -2236,9 +2302,6 @@ function formatSshFailure(error) {
|
|
|
2236
2302
|
return `Remote SSH command failed${status}${signal}${output ? `
|
|
2237
2303
|
${output}` : ""}`;
|
|
2238
2304
|
}
|
|
2239
|
-
function expandHome(path) {
|
|
2240
|
-
return path.startsWith("~/") ? join3(homedir(), path.slice(2)) : path;
|
|
2241
|
-
}
|
|
2242
2305
|
|
|
2243
2306
|
class HetznerDriver {
|
|
2244
2307
|
name = "hetzner";
|
|
@@ -2252,12 +2315,18 @@ class HetznerDriver {
|
|
|
2252
2315
|
bootWait;
|
|
2253
2316
|
constructor(options = {}) {
|
|
2254
2317
|
this.client = options.client ?? new HetznerClient({
|
|
2255
|
-
apiToken:
|
|
2318
|
+
apiToken: resolveHetznerApiToken2(options.apiToken)
|
|
2319
|
+
});
|
|
2320
|
+
const settings = resolveHetznerSettings(undefined, {
|
|
2321
|
+
sshPrivateKeyPath: options.sshPrivateKeyPath,
|
|
2322
|
+
sshPublicKeyPath: options.sshPublicKeyPath,
|
|
2323
|
+
sshUser: options.sshUser,
|
|
2324
|
+
location: options.location
|
|
2256
2325
|
});
|
|
2257
|
-
this.sshPrivateKeyPath =
|
|
2258
|
-
this.sshPublicKeyPath =
|
|
2259
|
-
this.sshUser =
|
|
2260
|
-
this.location =
|
|
2326
|
+
this.sshPrivateKeyPath = settings.sshPrivateKeyPath;
|
|
2327
|
+
this.sshPublicKeyPath = settings.sshPublicKeyPath;
|
|
2328
|
+
this.sshUser = settings.sshUser;
|
|
2329
|
+
this.location = settings.location;
|
|
2261
2330
|
this.waitForBoot = options.waitForBoot ?? true;
|
|
2262
2331
|
this.bootWait = {
|
|
2263
2332
|
sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
|
|
@@ -2324,7 +2393,7 @@ class HetznerDriver {
|
|
|
2324
2393
|
});
|
|
2325
2394
|
const userData = wrapCloudInitUserData(bootstrap);
|
|
2326
2395
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2327
|
-
const image =
|
|
2396
|
+
const image = resolveHetznerImage(config);
|
|
2328
2397
|
const firewallName = `${slug}-${environment}-app-fw`;
|
|
2329
2398
|
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
2330
2399
|
allowSsh: compute.allowSsh !== false,
|
|
@@ -2367,7 +2436,7 @@ class HetznerDriver {
|
|
|
2367
2436
|
const compute = config.infrastructure.compute;
|
|
2368
2437
|
const stackName = resolveProjectStackName(config, environment);
|
|
2369
2438
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2370
|
-
const image =
|
|
2439
|
+
const image = resolveHetznerImage(config);
|
|
2371
2440
|
const location = config.hetzner?.location || this.location;
|
|
2372
2441
|
const baked = compute.bakedImage === true;
|
|
2373
2442
|
const existingState = await readDriverState(stackName);
|
|
@@ -2435,7 +2504,7 @@ class HetznerDriver {
|
|
|
2435
2504
|
versions: compute.php?.versions,
|
|
2436
2505
|
default: compute.php?.default,
|
|
2437
2506
|
extensions: compute.php?.extensions,
|
|
2438
|
-
installNginx: compute
|
|
2507
|
+
installNginx: !usesRpxProxy(compute),
|
|
2439
2508
|
optimizeForProduction: compute.php?.optimizeForProduction,
|
|
2440
2509
|
ini: compute.php?.ini
|
|
2441
2510
|
});
|
|
@@ -2522,7 +2591,7 @@ class HetznerDriver {
|
|
|
2522
2591
|
const compute = config.infrastructure.compute;
|
|
2523
2592
|
const stackName = resolveProjectStackName(config, environment);
|
|
2524
2593
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2525
|
-
const image =
|
|
2594
|
+
const image = resolveHetznerImage(config);
|
|
2526
2595
|
const location = config.hetzner?.location || this.location;
|
|
2527
2596
|
const baked = compute.bakedImage === true;
|
|
2528
2597
|
const sites = config.sites || {};
|
|
@@ -2805,11 +2874,11 @@ class HetznerDriver {
|
|
|
2805
2874
|
role: "app",
|
|
2806
2875
|
stackName: resolveProjectStackName(options.config, options.environment)
|
|
2807
2876
|
});
|
|
2808
|
-
const
|
|
2877
|
+
const first2 = targets[0];
|
|
2809
2878
|
return {
|
|
2810
2879
|
deployStoragePath: "/var/ts-cloud/staging",
|
|
2811
|
-
appInstanceId:
|
|
2812
|
-
appPublicIp:
|
|
2880
|
+
appInstanceId: first2?.id,
|
|
2881
|
+
appPublicIp: first2?.publicIp,
|
|
2813
2882
|
sshUser: this.sshUser
|
|
2814
2883
|
};
|
|
2815
2884
|
}
|
|
@@ -3131,6 +3200,7 @@ function createCloudDriver(options) {
|
|
|
3131
3200
|
return new HetznerDriver({
|
|
3132
3201
|
apiToken: options.config.hetzner?.apiToken,
|
|
3133
3202
|
sshPrivateKeyPath: options.config.hetzner?.sshPrivateKeyPath,
|
|
3203
|
+
sshPublicKeyPath: options.config.hetzner?.sshPublicKeyPath,
|
|
3134
3204
|
sshUser: options.config.hetzner?.sshUser,
|
|
3135
3205
|
location: options.config.hetzner?.location
|
|
3136
3206
|
});
|
|
@@ -3475,8 +3545,8 @@ function createBoxProvisioner(options) {
|
|
|
3475
3545
|
return new AwsBoxProvisioner(options.aws);
|
|
3476
3546
|
}
|
|
3477
3547
|
// src/drivers/shared/env-file.ts
|
|
3478
|
-
function formatEnvFile(
|
|
3479
|
-
return Object.entries(
|
|
3548
|
+
function formatEnvFile(env2) {
|
|
3549
|
+
return Object.entries(env2).map(([k, v]) => `${k}=${quoteEnvValue(String(v))}`).join(`
|
|
3480
3550
|
`);
|
|
3481
3551
|
}
|
|
3482
3552
|
function quoteEnvValue(value) {
|
|
@@ -4041,8 +4111,8 @@ function defaultDeployScriptFor(type) {
|
|
|
4041
4111
|
function substituteBins(line, phpBin) {
|
|
4042
4112
|
return line.replace(/^php\s+/, `${phpBin} `).replace(/(\s)php\s+artisan\s+/g, `$1${phpBin} artisan `);
|
|
4043
4113
|
}
|
|
4044
|
-
function writeSharedEnv(sharedEnvPath,
|
|
4045
|
-
const body = formatEnvFile(
|
|
4114
|
+
function writeSharedEnv(sharedEnvPath, env2) {
|
|
4115
|
+
const body = formatEnvFile(env2);
|
|
4046
4116
|
return [
|
|
4047
4117
|
`cat > ${sharedEnvPath} <<'TS_CLOUD_ENV_EOF'`,
|
|
4048
4118
|
body,
|
|
@@ -4418,7 +4488,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4418
4488
|
appBase: appBase2,
|
|
4419
4489
|
defaultPhpVersion: phpVersion
|
|
4420
4490
|
});
|
|
4421
|
-
const useNginx = compute2
|
|
4491
|
+
const useNginx = !usesRpxProxy(compute2);
|
|
4422
4492
|
const sslProvider = resolveSslProvider(site);
|
|
4423
4493
|
const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
|
|
4424
4494
|
const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
|
|
@@ -4500,7 +4570,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
|
|
|
4500
4570
|
healthCheckPath: site.healthCheck?.path
|
|
4501
4571
|
});
|
|
4502
4572
|
const compute = config.infrastructure?.compute;
|
|
4503
|
-
const wantsNginxStatic = kind === "server-static" && compute
|
|
4573
|
+
const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
|
|
4504
4574
|
const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
|
|
4505
4575
|
siteName,
|
|
4506
4576
|
domain: site.domain,
|
|
@@ -4761,4 +4831,4 @@ function buildCloudFrontOriginConfig(options) {
|
|
|
4761
4831
|
CustomErrorResponses: { Quantity: 0 }
|
|
4762
4832
|
};
|
|
4763
4833
|
}
|
|
4764
|
-
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR,
|
|
4834
|
+
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
|