@stacksjs/ts-cloud 0.6.2 → 0.7.2

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.
@@ -48,6 +48,29 @@ export declare class HetznerDriver implements CloudDriver {
48
48
  * over the private network (wired into their `.env` at deploy time).
49
49
  */
50
50
  private provisionFleet;
51
+ /**
52
+ * Provision a load-balanced **bun/node/deno** fleet: a private network, N
53
+ * app servers running the runtime directly (no local rpx gateway — the LB
54
+ * box reaches them over the private network), and ONE dedicated box running
55
+ * only the rpx gateway in load-balancing mode, fronting every `server-app`
56
+ * site's port across all app boxes (rpx v0.11.24+ multi-upstream routes with
57
+ * health-check failover — see {@link buildRpxLbConfig}).
58
+ *
59
+ * Mirrors {@link provisionFleet}'s idempotency/reconciliation/teardown
60
+ * patterns (reuse-by-label, create only the delta, destroy extras on
61
+ * scale-down), but fronts the app boxes with rpx instead of Hetzner's native
62
+ * Load Balancer product — this is the bun/rpx analogue of that PHP path.
63
+ *
64
+ * Edge case: a bun app that sets `compute.servicesServer` explicitly (wants
65
+ * a dedicated DB/cache box) but only ever runs one app server still lands
66
+ * here (see the `topology.dedicatedServices` dispatch condition) — in that
67
+ * case we still provision the dedicated services box (provider/runtime
68
+ * -agnostic infra, reusing the same PHP-fleet services-box mechanism is
69
+ * unnecessary since bun app servers need no PHP/nginx either way) but only
70
+ * stand up the rpx LB box when `topology.loadBalancer` is actually true
71
+ * (i.e. more than one app server, or `compute.server?.loadBalancer`).
72
+ */
73
+ private provisionBunFleet;
51
74
  /**
52
75
  * Tear down the compute — single server or full fleet (load balancer, all
53
76
  * app + services servers, firewalls, and the private network) — and clear
@@ -13,6 +13,14 @@ export interface HetznerDriverState {
13
13
  loadBalancerId?: number;
14
14
  servicesServerId?: number;
15
15
  servicesPrivateIp?: string;
16
+ /**
17
+ * Bun+rpx fleet (see `HetznerDriver`'s bun-fleet path): the dedicated rpx
18
+ * load-balancer box (role `lb`) and the N app boxes (role `app`) it fronts.
19
+ * Distinct from {@link loadBalancerId} (a Hetzner-native Load Balancer
20
+ * resource, used by the PHP fleet path) — this is a real server running rpx.
21
+ */
22
+ lbServerId?: number;
23
+ appServerIds?: number[];
16
24
  }
17
25
  export declare function driverStatePath(stackName: string): string;
18
26
  export declare function readDriverState(stackName: string): Promise<HetznerDriverState | null>;
@@ -17,7 +17,7 @@
17
17
  * It replaces the old Caddyfile generation — pantry/stacks use rpx (their own
18
18
  * tooling), so the gateway is rpx, not Caddy.
19
19
  */
20
- import type { ComputeProxyConfig, SiteConfig, SiteRedirectConfig } from '@ts-cloud/core';
20
+ import type { ComputeProxyConfig, RpxLoadBalancerConfig, SiteConfig, SiteRedirectConfig } from '@ts-cloud/core';
21
21
  /** Default directory on the box that holds real per-domain TLS certs. */
22
22
  export declare const DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
23
23
  /** Default webroot the gateway serves ACME http-01 challenges from on `:80`. */
@@ -40,8 +40,14 @@ export interface RpxRoute {
40
40
  to: string;
41
41
  /** Path prefix within the host this route owns (e.g. `/api`). Omitted = `/`. */
42
42
  path?: string;
43
- /** Upstream `host:port` for a `server-app` route. */
44
- from?: string;
43
+ /**
44
+ * Upstream(s) for a `server-app` route: a single `host:port` for a co-located
45
+ * (single-box) deploy, or an array of `host:port` — one per app box — when the
46
+ * route is fronted by a dedicated load-balancer box (see
47
+ * {@link buildRpxLbConfig}). rpx turns an array into a real load-balanced pool
48
+ * with automatic health-check failover (see rpx's `ProxyFrom`/`UpstreamTarget`).
49
+ */
50
+ from?: string | string[];
45
51
  /** Absolute directory served for a `server-static` route (`/var/www/<name>`). */
46
52
  static?: string;
47
53
  /**
@@ -65,6 +71,12 @@ export interface RpxRoute {
65
71
  password: string;
66
72
  realm?: string;
67
73
  };
74
+ /**
75
+ * Load-balancing strategy/health-check tuning for a multi-upstream `from`
76
+ * (see {@link ComputeProxyConfig.loadBalancer}). Only meaningful when `from`
77
+ * is an array — rpx ignores it for a single-upstream route.
78
+ */
79
+ loadBalancer?: RpxLoadBalancerConfig;
68
80
  /** Stable id used when rpx registers the route. Derived from `to`+`path`. */
69
81
  id: string;
70
82
  }
@@ -143,8 +155,32 @@ export declare function deriveRouteId(to: string, path?: string): string;
143
155
  * Routes are grouped by domain so rpx's path-based routing can serve an app +
144
156
  * several static dirs under one host. Bucket sites and sites without a `domain`
145
157
  * (or a `server-app` without a `port`) are skipped.
158
+ *
159
+ * This is the single-box path: every `server-app` route always resolves to
160
+ * `localhost:<port>` — unchanged, byte-for-byte, from before load-balanced
161
+ * fleets existed. Use {@link buildRpxLbConfig} for a dedicated LB box fronting
162
+ * more than one app box.
146
163
  */
147
164
  export declare function buildRpxConfig(sites: Record<string, SiteConfig | undefined>, options: BuildRpxConfigOptions): RpxGatewayConfig;
165
+ /** An app box's addresses, as known to the LB box building routes to it. */
166
+ export interface RpxLbAppBox {
167
+ /** Private IP of the app box, reachable from the LB over the fleet's private network. Preferred. */
168
+ privateIp?: string;
169
+ /** Public IP of the app box — used only when no private IP is available. */
170
+ publicIp?: string;
171
+ }
172
+ /**
173
+ * Build the rpx gateway config for a **dedicated load-balancer box**: like
174
+ * {@link buildRpxConfig}, but every `server-app` route's `from` is an array of
175
+ * `host:port` — one per entry in `appBoxes` (private IP preferred, public IP as
176
+ * fallback) — instead of `localhost:<port>`. rpx turns that array into a real
177
+ * load-balanced pool with health-check failover (see rpx's `ProxyFrom`).
178
+ *
179
+ * `server-static`/`redirect` routes are unaffected (the LB box doesn't serve
180
+ * static files or own redirects itself in the primary bun-fleet flow — those
181
+ * kinds simply pass through unchanged if present in `sites`).
182
+ */
183
+ export declare function buildRpxLbConfig(sites: Record<string, SiteConfig | undefined>, appBoxes: RpxLbAppBox[], options: BuildRpxConfigOptions): RpxGatewayConfig;
148
184
  /**
149
185
  * Render the rpx gateway config as a self-contained launcher TS module. The
150
186
  * systemd unit runs `bun <file>`, which imports `startProxies` from the
package/dist/index.js CHANGED
@@ -85584,6 +85584,17 @@ ${servicesProvision.join(`
85584
85584
  # breaking a from-scratch provision (adopting an existing box masked it).
85585
85585
  export HOME="\${HOME:-/root}"
85586
85586
  export BUN_INSTALL="/root/.bun"
85587
+ # bun.sh's installer references $HOME internally and runs under this
85588
+ # script's \`set -u\` (inherited by the piped bash) — cloud-init's runcmd
85589
+ # environment doesn't export HOME by default, so without this the
85590
+ # installer dies with "HOME: unbound variable" partway through (after
85591
+ # the binary is already downloaded, so it looks like a partial success)
85592
+ # and \`set -e\` aborts everything after it: the bun symlink, /var/www +
85593
+ # /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
85594
+ # never run. Confirmed against a real Hetzner box (stacksjs/status#1
85595
+ # Phase 9 e2e deploy) — cloud-init reported success with none of that
85596
+ # actually done.
85597
+ export HOME="\${HOME:-/root}"
85587
85598
  curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
85588
85599
  ln -sf /root/.bun/bin/bun /usr/local/bin/bun
85589
85600
  echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
@@ -86230,9 +86241,15 @@ function deriveRouteId(to, path) {
86230
86241
  const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
86231
86242
  return cleaned.length > 0 ? cleaned : "rpx";
86232
86243
  }
86233
- function buildRpxConfig(sites, options) {
86244
+ function resolveServerAppFrom(port, appBoxes) {
86245
+ if (!appBoxes || appBoxes.length === 0)
86246
+ return `localhost:${port}`;
86247
+ return appBoxes.map((box2) => `${box2.privateIp ?? box2.publicIp}:${port}`);
86248
+ }
86249
+ function buildRpxConfigInternal(sites, options, appBoxes) {
86234
86250
  const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
86235
86251
  const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
86252
+ const loadBalancer = options.proxy.loadBalancer;
86236
86253
  const proxies = [];
86237
86254
  const domains = new Set;
86238
86255
  for (const [name, site] of Object.entries(sites)) {
@@ -86252,7 +86269,15 @@ function buildRpxConfig(sites, options) {
86252
86269
  if (kind === "server-app") {
86253
86270
  if (typeof site.port !== "number")
86254
86271
  continue;
86255
- proxies.push({ to: site.domain, path, from: `localhost:${site.port}`, id, ...auth ? { auth } : {} });
86272
+ const from = resolveServerAppFrom(site.port, appBoxes);
86273
+ proxies.push({
86274
+ to: site.domain,
86275
+ path,
86276
+ from,
86277
+ id,
86278
+ ...auth ? { auth } : {},
86279
+ ...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
86280
+ });
86256
86281
  } else {
86257
86282
  proxies.push({
86258
86283
  to: site.domain,
@@ -86299,6 +86324,12 @@ function buildRpxConfig(sites, options) {
86299
86324
  }
86300
86325
  return config6;
86301
86326
  }
86327
+ function buildRpxConfig(sites, options) {
86328
+ return buildRpxConfigInternal(sites, options);
86329
+ }
86330
+ function buildRpxLbConfig(sites, appBoxes, options) {
86331
+ return buildRpxConfigInternal(sites, options, appBoxes);
86332
+ }
86302
86333
  var RPX_DIR = "/etc/rpx";
86303
86334
  var RPX_INSTALL_DIR = "/opt/rpx-gateway";
86304
86335
  var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
@@ -86637,9 +86668,11 @@ class HetznerDriver {
86637
86668
  }
86638
86669
  const stackName = resolveProjectStackName(config6, environment);
86639
86670
  const serverName = `${slug}-${environment}-app`;
86671
+ const phpBox = compute.runtime === "php" || !!compute.php;
86640
86672
  const topology = resolveFleetTopology(compute);
86641
- if (topology.dedicatedServices || topology.appServers > 1)
86642
- return this.provisionFleet(options, topology);
86673
+ if (topology.dedicatedServices || topology.appServers > 1) {
86674
+ return phpBox ? this.provisionFleet(options, topology) : this.provisionBunFleet(options, topology);
86675
+ }
86643
86676
  const existing = await readDriverState(stackName);
86644
86677
  if (existing?.serverId) {
86645
86678
  const server2 = await this.tryGetServer(existing.serverId);
@@ -86876,6 +86909,202 @@ class HetznerDriver {
86876
86909
  sshUser: this.sshUser
86877
86910
  };
86878
86911
  }
86912
+ async provisionBunFleet(options, topology) {
86913
+ const { config: config6, environment } = options;
86914
+ const slug = config6.project.slug;
86915
+ const compute = config6.infrastructure.compute;
86916
+ const stackName = resolveProjectStackName(config6, environment);
86917
+ const serverType = resolveHetznerServerType(compute.size);
86918
+ const image = compute.image || config6.hetzner?.image || "ubuntu-24.04";
86919
+ const location = config6.hetzner?.location || this.location;
86920
+ const baked = compute.bakedImage === true;
86921
+ const sites = config6.sites || {};
86922
+ const existingState = await readDriverState(stackName);
86923
+ if (existingState?.lbServerId) {
86924
+ const lb = await this.tryGetServer(existingState.lbServerId);
86925
+ if (lb && lb.status !== "off") {
86926
+ return {
86927
+ appPublicIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
86928
+ loadBalancerIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
86929
+ servicesPrivateIp: existingState.servicesPrivateIp,
86930
+ deployStoragePath: "/var/ts-cloud/staging",
86931
+ sshUser: this.sshUser
86932
+ };
86933
+ }
86934
+ }
86935
+ const sshKeyId = await this.ensureSshKey(slug, environment, tsCloudLabels(slug, environment, "app"));
86936
+ const netName = `${slug}-${environment}-net`;
86937
+ const networks = await this.client.listNetworks().catch(() => []);
86938
+ const network = networks.find((n) => n.name === netName) ?? await this.client.createNetwork({ name: netName, labels: tsCloudLabels(slug, environment, "app") });
86939
+ const sitePorts = this.collectUpstreamPorts(sites);
86940
+ const { firewall: appFw } = await this.ensureFirewall(`${slug}-${environment}-app-fw`, tsCloudLabels(slug, environment, "app"), [
86941
+ { direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
86942
+ ...sitePorts.map((port) => ({
86943
+ direction: "in",
86944
+ protocol: "tcp",
86945
+ port: String(port),
86946
+ source_ips: [network.ip_range],
86947
+ description: `ts-cloud port ${port} (private, LB-only)`
86948
+ }))
86949
+ ]);
86950
+ const { firewall: lbFw } = await this.ensureFirewall(`${slug}-${environment}-lb-fw`, tsCloudLabels(slug, environment, "lb"), buildHetznerFirewallRules({ allowSsh: true, sitePorts: [] }));
86951
+ const all = await this.client.listServers().catch(() => []);
86952
+ const newServerIds = [];
86953
+ let servicesServerId;
86954
+ let servicesPrivateIp;
86955
+ if (topology.dedicatedServices) {
86956
+ const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
86957
+ { direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
86958
+ { direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
86959
+ { direction: "in", protocol: "tcp", port: "5432", source_ips: [network.ip_range] },
86960
+ { direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
86961
+ { direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
86962
+ ]);
86963
+ const servicesProvision = [
86964
+ ...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
86965
+ ...buildDatabaseSetupScript(config6.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
86966
+ ...buildAutoUpdatesScript(true),
86967
+ ...buildMonitoringScript(true),
86968
+ ...buildAuthorizedKeysScript(compute.sshKeys)
86969
+ ];
86970
+ const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "bun", servicesProvision, baked }));
86971
+ let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
86972
+ if (!svcServer) {
86973
+ const { server, action } = await this.client.createServer({
86974
+ name: `${slug}-${environment}-services`,
86975
+ serverType: resolveHetznerServerType(typeof compute.servicesServer === "object" ? compute.servicesServer.size : compute.size),
86976
+ image,
86977
+ location,
86978
+ userData: servicesUserData,
86979
+ labels: tsCloudLabels(slug, environment, "services"),
86980
+ sshKeys: sshKeyId ? [sshKeyId] : undefined,
86981
+ firewalls: [{ firewall: svcFw.id }],
86982
+ networks: [network.id]
86983
+ });
86984
+ await this.client.waitForAction(action.id);
86985
+ svcServer = await this.client.waitForServerRunning(server.id);
86986
+ newServerIds.push(server.id);
86987
+ }
86988
+ servicesServerId = svcServer.id;
86989
+ servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
86990
+ }
86991
+ const appProvisionScripts = buildComputeProvisionScripts(config6);
86992
+ const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
86993
+ runtime: appProvisionScripts.runtime,
86994
+ runtimeVersion: appProvisionScripts.runtimeVersion,
86995
+ systemPackages: compute.systemPackages,
86996
+ database: config6.infrastructure?.database,
86997
+ servicesProvision: appProvisionScripts.servicesProvision,
86998
+ baked
86999
+ }));
87000
+ const existingApp = all.filter((s) => matchesTsCloudLabels(s.labels, slug, environment, "app"));
87001
+ const appServerIds = existingApp.map((s) => s.id);
87002
+ if (existingApp.length > topology.appServers) {
87003
+ for (const extra of existingApp.slice(topology.appServers)) {
87004
+ await this.client.deleteServer(extra.id).catch(() => {});
87005
+ appServerIds.splice(appServerIds.indexOf(extra.id), 1);
87006
+ }
87007
+ }
87008
+ for (let i = existingApp.length;i < topology.appServers; i++) {
87009
+ const { server, action } = await this.client.createServer({
87010
+ name: `${slug}-${environment}-app-${i + 1}`,
87011
+ serverType,
87012
+ image,
87013
+ location,
87014
+ userData: appUserData,
87015
+ labels: tsCloudLabels(slug, environment, "app"),
87016
+ sshKeys: sshKeyId ? [sshKeyId] : undefined,
87017
+ firewalls: [{ firewall: appFw.id }],
87018
+ networks: [network.id]
87019
+ });
87020
+ await this.client.waitForAction(action.id);
87021
+ appServerIds.push(server.id);
87022
+ newServerIds.push(server.id);
87023
+ }
87024
+ if (this.waitForBoot && newServerIds.length > 0) {
87025
+ await Promise.all(newServerIds.map(async (id) => {
87026
+ const running = await this.client.waitForServerRunning(id);
87027
+ const ip = running.public_net.ipv4?.ip;
87028
+ if (ip) {
87029
+ await this.waitForSshReady(ip);
87030
+ await this.waitForCloudInit(ip);
87031
+ }
87032
+ }));
87033
+ }
87034
+ const appBoxes = [];
87035
+ for (const id of appServerIds) {
87036
+ let server = all.find((s) => s.id === id);
87037
+ if (!server || !server.private_net?.[0]?.ip)
87038
+ server = await this.client.getServer(id).catch(() => server);
87039
+ appBoxes.push({ privateIp: server?.private_net?.[0]?.ip, publicIp: server?.public_net.ipv4?.ip });
87040
+ }
87041
+ const lbName = `${slug}-${environment}-lb`;
87042
+ let lbId;
87043
+ let lbIp;
87044
+ if (topology.loadBalancer) {
87045
+ let lbServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "lb"));
87046
+ if (!lbServer) {
87047
+ const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
87048
+ const lbRpxProvision = buildRpxProvisionScript({
87049
+ proxy: rpxProxy,
87050
+ config: buildRpxLbConfig(sites, appBoxes, { proxy: rpxProxy }),
87051
+ slug,
87052
+ bunBin: appProvisionScripts.runtime === "node" || appProvisionScripts.runtime === "deno" ? undefined : "/usr/local/bin/bun"
87053
+ });
87054
+ const lbUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
87055
+ runtime: "bun",
87056
+ rpxProvision: lbRpxProvision,
87057
+ baked: false
87058
+ }));
87059
+ const lbSize = typeof compute.loadBalancer === "object" ? compute.loadBalancer.size : undefined;
87060
+ const { server, action } = await this.client.createServer({
87061
+ name: lbName,
87062
+ serverType: resolveHetznerServerType(lbSize ?? "micro"),
87063
+ image,
87064
+ location,
87065
+ userData: lbUserData,
87066
+ labels: tsCloudLabels(slug, environment, "lb"),
87067
+ sshKeys: sshKeyId ? [sshKeyId] : undefined,
87068
+ firewalls: [{ firewall: lbFw.id }],
87069
+ networks: [network.id]
87070
+ });
87071
+ await this.client.waitForAction(action.id);
87072
+ lbServer = await this.client.waitForServerRunning(server.id);
87073
+ if (this.waitForBoot) {
87074
+ const ip = lbServer.public_net.ipv4?.ip;
87075
+ if (ip) {
87076
+ await this.waitForSshReady(ip);
87077
+ await this.waitForCloudInit(ip);
87078
+ }
87079
+ }
87080
+ }
87081
+ lbId = lbServer.id;
87082
+ lbIp = lbServer.public_net.ipv4?.ip;
87083
+ }
87084
+ const appPublicIp = lbIp ?? (await this.client.getServer(appServerIds[0]).catch(() => {
87085
+ return;
87086
+ }))?.public_net.ipv4?.ip;
87087
+ const state = {
87088
+ provider: "hetzner",
87089
+ stackName,
87090
+ networkId: network.id,
87091
+ lbServerId: lbId,
87092
+ appServerIds,
87093
+ servicesServerId,
87094
+ servicesPrivateIp,
87095
+ publicIp: appPublicIp,
87096
+ deployStoragePath: "/var/ts-cloud/staging",
87097
+ sshUser: this.sshUser
87098
+ };
87099
+ await writeDriverState(stackName, state);
87100
+ return {
87101
+ appPublicIp,
87102
+ loadBalancerIp: lbIp,
87103
+ servicesPrivateIp,
87104
+ deployStoragePath: "/var/ts-cloud/staging",
87105
+ sshUser: this.sshUser
87106
+ };
87107
+ }
86879
87108
  async destroyCompute(options) {
86880
87109
  const { config: config6, environment } = options;
86881
87110
  const slug = config6.project.slug;
@@ -86894,13 +87123,17 @@ class HetznerDriver {
86894
87123
  const allServers = await this.client.listServers().catch(() => []);
86895
87124
  const serverIds = new Set;
86896
87125
  for (const s of allServers) {
86897
- if (matchesTsCloudLabels(s.labels, slug, environment, "app") || matchesTsCloudLabels(s.labels, slug, environment, "services"))
87126
+ if (matchesTsCloudLabels(s.labels, slug, environment, "app") || matchesTsCloudLabels(s.labels, slug, environment, "services") || matchesTsCloudLabels(s.labels, slug, environment, "lb"))
86898
87127
  serverIds.add(s.id);
86899
87128
  }
86900
87129
  if (state?.serverId)
86901
87130
  serverIds.add(state.serverId);
86902
87131
  if (state?.servicesServerId)
86903
87132
  serverIds.add(state.servicesServerId);
87133
+ if (state?.lbServerId)
87134
+ serverIds.add(state.lbServerId);
87135
+ for (const id of state?.appServerIds ?? [])
87136
+ serverIds.add(id);
86904
87137
  for (const id of serverIds) {
86905
87138
  try {
86906
87139
  await this.client.deleteServer(id);
@@ -86908,7 +87141,7 @@ class HetznerDriver {
86908
87141
  } catch {}
86909
87142
  }
86910
87143
  const firewalls = await this.client.listFirewalls().catch(() => []);
86911
- for (const name of [`${slug}-${environment}-app-fw`, `${slug}-${environment}-services-fw`]) {
87144
+ for (const name of [`${slug}-${environment}-app-fw`, `${slug}-${environment}-services-fw`, `${slug}-${environment}-lb-fw`]) {
86912
87145
  const fw = firewalls.find((f) => f.name === name);
86913
87146
  if (!fw)
86914
87147
  continue;
@@ -86946,6 +87179,15 @@ class HetznerDriver {
86946
87179
  const server = await this.client.getServer(state.serverId);
86947
87180
  return this.outputsFromState(state, server);
86948
87181
  }
87182
+ if (state?.lbServerId) {
87183
+ const lb = await this.tryGetServer(state.lbServerId);
87184
+ const lbIp = lb?.public_net.ipv4?.ip ?? state.publicIp;
87185
+ return {
87186
+ ...this.outputsFromState(state),
87187
+ appPublicIp: lbIp,
87188
+ loadBalancerIp: lbIp
87189
+ };
87190
+ }
86949
87191
  if (state?.loadBalancerId || state?.servicesPrivateIp) {
86950
87192
  return this.outputsFromState(state);
86951
87193
  }
@@ -86971,7 +87213,8 @@ class HetznerDriver {
86971
87213
  if (targets.length === 0) {
86972
87214
  throw new Error("No Hetzner compute targets found for release upload");
86973
87215
  }
86974
- const remotePath = `/var/ts-cloud/staging/${options.remoteKey.split("/").pop()}`;
87216
+ const stagingName = options.remoteKey.replace(/^releases\//, "").replace(/\//g, "-");
87217
+ const remotePath = `/var/ts-cloud/staging/${stagingName}`;
86975
87218
  for (const target of targets) {
86976
87219
  if (!target.publicIp) {
86977
87220
  throw new Error(`Target ${target.id} has no public IP for SCP upload`);