@stacksjs/ts-cloud 0.2.19 → 0.2.21
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.
|
@@ -86,10 +86,17 @@ export interface CreateFirewallOptions {
|
|
|
86
86
|
server: number;
|
|
87
87
|
}>;
|
|
88
88
|
}
|
|
89
|
+
export interface CreateSshKeyOptions {
|
|
90
|
+
name: string;
|
|
91
|
+
publicKey: string;
|
|
92
|
+
labels?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
/** Minimal fetch signature the client relies on (always called with a string URL). */
|
|
95
|
+
export type HetznerFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
89
96
|
export interface HetznerClientOptions {
|
|
90
97
|
apiToken: string;
|
|
91
98
|
baseUrl?: string;
|
|
92
|
-
fetchImpl?:
|
|
99
|
+
fetchImpl?: HetznerFetch;
|
|
93
100
|
}
|
|
94
101
|
export declare class HetznerClient {
|
|
95
102
|
readonly name = "hetzner";
|
|
@@ -115,6 +122,7 @@ export declare class HetznerClient {
|
|
|
115
122
|
server: number;
|
|
116
123
|
}>): Promise<HetznerAction[]>;
|
|
117
124
|
listSshKeys(): Promise<HetznerSshKey[]>;
|
|
125
|
+
createSshKey(options: CreateSshKeyOptions): Promise<HetznerSshKey>;
|
|
118
126
|
waitForAction(actionId: number, options?: {
|
|
119
127
|
pollIntervalMs?: number;
|
|
120
128
|
maxWaitMs?: number;
|
|
@@ -125,3 +133,9 @@ export declare class HetznerClient {
|
|
|
125
133
|
}): Promise<HetznerServer>;
|
|
126
134
|
}
|
|
127
135
|
export declare function resolveHetznerApiToken(configToken?: string): string;
|
|
136
|
+
/**
|
|
137
|
+
* Normalize an OpenSSH public key to its `<type> <base64>` body, dropping the
|
|
138
|
+
* trailing comment. Lets us match a local key against keys already registered
|
|
139
|
+
* in the Hetzner project regardless of differing comments/whitespace.
|
|
140
|
+
*/
|
|
141
|
+
export declare function normalizeSshPublicKey(publicKey: string): string;
|
|
@@ -13,5 +13,12 @@ export interface UbuntuBootstrapOptions {
|
|
|
13
13
|
export declare function generateUbuntuAppCloudInit(options?: UbuntuBootstrapOptions): string;
|
|
14
14
|
/**
|
|
15
15
|
* Wrap a bash bootstrap script as Hetzner cloud-init user_data (#cloud-config).
|
|
16
|
+
*
|
|
17
|
+
* The script is written to disk via `write_files` and then executed through an
|
|
18
|
+
* explicit `bash` invocation in `runcmd`. cloud-init runs bare `runcmd` entries
|
|
19
|
+
* with `/bin/sh` (dash on Ubuntu), which chokes on bash-only syntax like
|
|
20
|
+
* `set -o pipefail` and aborts the whole bootstrap — so embedding the script
|
|
21
|
+
* inline under `runcmd:` silently breaks bun/caddy installation. Writing the
|
|
22
|
+
* file (shebang preserved) and running `bash <file>` guarantees a bash shell.
|
|
16
23
|
*/
|
|
17
24
|
export declare function wrapCloudInitUserData(bootstrapScript: string): string;
|
|
@@ -3,6 +3,7 @@ import { HetznerClient } from './client';
|
|
|
3
3
|
export interface HetznerDriverOptions {
|
|
4
4
|
apiToken?: string;
|
|
5
5
|
sshPrivateKeyPath?: string;
|
|
6
|
+
sshPublicKeyPath?: string;
|
|
6
7
|
sshUser?: string;
|
|
7
8
|
location?: string;
|
|
8
9
|
client?: HetznerClient;
|
|
@@ -12,6 +13,7 @@ export declare class HetznerDriver implements CloudDriver {
|
|
|
12
13
|
readonly usesCloudFormation = false;
|
|
13
14
|
private client;
|
|
14
15
|
private sshPrivateKeyPath;
|
|
16
|
+
private sshPublicKeyPath;
|
|
15
17
|
private sshUser;
|
|
16
18
|
private location;
|
|
17
19
|
constructor(options?: HetznerDriverOptions);
|
|
@@ -20,6 +22,13 @@ export declare class HetznerDriver implements CloudDriver {
|
|
|
20
22
|
uploadRelease(options: UploadReleaseOptions): Promise<UploadReleaseResult>;
|
|
21
23
|
findComputeTargets(options: FindComputeTargetsOptions): Promise<ComputeTarget[]>;
|
|
22
24
|
runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Ensure the local SSH public key is registered in the Hetzner project and
|
|
27
|
+
* return its id, so the freshly created server authorizes the same key the
|
|
28
|
+
* deploy step (SCP/SSH) uses. Without this, deploys fail with "Permission
|
|
29
|
+
* denied (publickey)" because the server has no authorized keys.
|
|
30
|
+
*/
|
|
31
|
+
private ensureSshKey;
|
|
23
32
|
private outputsFromState;
|
|
24
33
|
private sshBaseArgs;
|
|
25
34
|
private scpToHost;
|
|
@@ -15,6 +15,13 @@ export interface BuildSiteDeployScriptOptions {
|
|
|
15
15
|
execStart: string;
|
|
16
16
|
envEntries: Record<string, string>;
|
|
17
17
|
port?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Commands run inside `appDir` after extraction + `.env` write, before the
|
|
20
|
+
* systemd unit is (re)written and started. Typically dependency install
|
|
21
|
+
* and/or build steps (e.g. `bun install --frozen-lockfile`, `bun run build`)
|
|
22
|
+
* so the release tarball can omit `node_modules`.
|
|
23
|
+
*/
|
|
24
|
+
preStartCommands?: string[];
|
|
18
25
|
}
|
|
19
26
|
/**
|
|
20
27
|
* Build the remote shell commands that install/refresh a site on a compute target.
|
package/dist/index.js
CHANGED
|
@@ -81153,6 +81153,7 @@ class AwsDriver {
|
|
|
81153
81153
|
}
|
|
81154
81154
|
|
|
81155
81155
|
// src/drivers/hetzner/driver.ts
|
|
81156
|
+
import { existsSync as existsSync17, readFileSync as readFileSync10 } from "node:fs";
|
|
81156
81157
|
import { homedir as homedir7 } from "node:os";
|
|
81157
81158
|
import { join as join13 } from "node:path";
|
|
81158
81159
|
import { execSync } from "node:child_process";
|
|
@@ -81279,6 +81280,14 @@ class HetznerClient {
|
|
|
81279
81280
|
const data = await this.request("GET", "/ssh_keys");
|
|
81280
81281
|
return data.ssh_keys;
|
|
81281
81282
|
}
|
|
81283
|
+
async createSshKey(options) {
|
|
81284
|
+
const data = await this.request("POST", "/ssh_keys", {
|
|
81285
|
+
name: options.name,
|
|
81286
|
+
public_key: options.publicKey,
|
|
81287
|
+
labels: options.labels
|
|
81288
|
+
});
|
|
81289
|
+
return data.ssh_key;
|
|
81290
|
+
}
|
|
81282
81291
|
async waitForAction(actionId, options) {
|
|
81283
81292
|
const pollInterval = options?.pollIntervalMs ?? 2000;
|
|
81284
81293
|
const maxWait = options?.maxWaitMs ?? 300000;
|
|
@@ -81314,6 +81323,10 @@ function resolveHetznerApiToken(configToken) {
|
|
|
81314
81323
|
}
|
|
81315
81324
|
return token;
|
|
81316
81325
|
}
|
|
81326
|
+
function normalizeSshPublicKey(publicKey) {
|
|
81327
|
+
const [type, body] = publicKey.trim().split(/\s+/);
|
|
81328
|
+
return body ? `${type} ${body}` : type;
|
|
81329
|
+
}
|
|
81317
81330
|
|
|
81318
81331
|
// src/drivers/hetzner/cloud-init.ts
|
|
81319
81332
|
function generateUbuntuAppCloudInit(options = {}) {
|
|
@@ -81422,12 +81435,19 @@ echo "ts-cloud bootstrap complete — instance is ready for site deploys"
|
|
|
81422
81435
|
return script;
|
|
81423
81436
|
}
|
|
81424
81437
|
function wrapCloudInitUserData(bootstrapScript) {
|
|
81438
|
+
const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
|
|
81439
|
+
const indented = bootstrapScript.split(`
|
|
81440
|
+
`).map((line) => ` ${line}`).join(`
|
|
81441
|
+
`);
|
|
81425
81442
|
return `#cloud-config
|
|
81443
|
+
write_files:
|
|
81444
|
+
- path: ${scriptPath}
|
|
81445
|
+
permissions: '0755'
|
|
81446
|
+
owner: root:root
|
|
81447
|
+
content: |
|
|
81448
|
+
${indented}
|
|
81426
81449
|
runcmd:
|
|
81427
|
-
-
|
|
81428
|
-
${bootstrapScript.split(`
|
|
81429
|
-
`).join(`
|
|
81430
|
-
`)}
|
|
81450
|
+
- [ bash, ${scriptPath} ]
|
|
81431
81451
|
`;
|
|
81432
81452
|
}
|
|
81433
81453
|
|
|
@@ -81451,10 +81471,10 @@ function buildHetznerFirewallRules(config6) {
|
|
|
81451
81471
|
// src/drivers/hetzner/instance-sizes.ts
|
|
81452
81472
|
var HETZNER_INSTANCE_TYPES = {
|
|
81453
81473
|
micro: "cpx11",
|
|
81454
|
-
small: "
|
|
81455
|
-
medium: "
|
|
81456
|
-
large: "
|
|
81457
|
-
xlarge: "
|
|
81474
|
+
small: "cx23",
|
|
81475
|
+
medium: "cx33",
|
|
81476
|
+
large: "cx43",
|
|
81477
|
+
xlarge: "cx53",
|
|
81458
81478
|
"2xlarge": "ccx33"
|
|
81459
81479
|
};
|
|
81460
81480
|
function resolveHetznerServerType(size) {
|
|
@@ -81503,6 +81523,7 @@ async function writeDriverState(stackName, state) {
|
|
|
81503
81523
|
}
|
|
81504
81524
|
|
|
81505
81525
|
// src/drivers/hetzner/driver.ts
|
|
81526
|
+
var SSH_MAX_BUFFER = 1024 * 1024 * 256;
|
|
81506
81527
|
function expandHome(path) {
|
|
81507
81528
|
return path.startsWith("~/") ? join13(homedir7(), path.slice(2)) : path;
|
|
81508
81529
|
}
|
|
@@ -81512,6 +81533,7 @@ class HetznerDriver {
|
|
|
81512
81533
|
usesCloudFormation = false;
|
|
81513
81534
|
client;
|
|
81514
81535
|
sshPrivateKeyPath;
|
|
81536
|
+
sshPublicKeyPath;
|
|
81515
81537
|
sshUser;
|
|
81516
81538
|
location;
|
|
81517
81539
|
constructor(options = {}) {
|
|
@@ -81519,6 +81541,7 @@ class HetznerDriver {
|
|
|
81519
81541
|
apiToken: resolveHetznerApiToken(options.apiToken)
|
|
81520
81542
|
});
|
|
81521
81543
|
this.sshPrivateKeyPath = expandHome(options.sshPrivateKeyPath || process.env.HCLOUD_SSH_KEY || "~/.ssh/id_ed25519");
|
|
81544
|
+
this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
|
|
81522
81545
|
this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
|
|
81523
81546
|
this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
|
|
81524
81547
|
}
|
|
@@ -81557,10 +81580,11 @@ class HetznerDriver {
|
|
|
81557
81580
|
name: firewallName,
|
|
81558
81581
|
labels,
|
|
81559
81582
|
rules: buildHetznerFirewallRules({
|
|
81560
|
-
allowSsh: compute.allowSsh,
|
|
81583
|
+
allowSsh: compute.allowSsh !== false,
|
|
81561
81584
|
sitePorts
|
|
81562
81585
|
})
|
|
81563
81586
|
});
|
|
81587
|
+
const sshKeyId = await this.ensureSshKey(slug, environment, labels);
|
|
81564
81588
|
const { server, action } = await this.client.createServer({
|
|
81565
81589
|
name: serverName,
|
|
81566
81590
|
serverType,
|
|
@@ -81568,6 +81592,7 @@ class HetznerDriver {
|
|
|
81568
81592
|
location: config6.hetzner?.location || this.location,
|
|
81569
81593
|
userData,
|
|
81570
81594
|
labels,
|
|
81595
|
+
sshKeys: sshKeyId ? [sshKeyId] : undefined,
|
|
81571
81596
|
firewalls: [{ firewall: firewall.id }]
|
|
81572
81597
|
});
|
|
81573
81598
|
await this.client.waitForAction(action.id);
|
|
@@ -81672,6 +81697,23 @@ class HetznerDriver {
|
|
|
81672
81697
|
error: success ? undefined : "One or more SSH deploy commands failed"
|
|
81673
81698
|
};
|
|
81674
81699
|
}
|
|
81700
|
+
async ensureSshKey(slug, environment, labels) {
|
|
81701
|
+
if (!existsSync17(this.sshPublicKeyPath)) {
|
|
81702
|
+
throw new Error(`SSH public key not found at ${this.sshPublicKeyPath}. ts-cloud deploys to Hetzner over SSH and needs a public key to authorize on the server. ` + `Generate one (\`ssh-keygen -t ed25519\`) or set hetzner.sshPrivateKeyPath / HCLOUD_SSH_PUBLIC_KEY.`);
|
|
81703
|
+
}
|
|
81704
|
+
const publicKey = readFileSync10(this.sshPublicKeyPath, "utf8").trim();
|
|
81705
|
+
const normalized = normalizeSshPublicKey(publicKey);
|
|
81706
|
+
const existing = await this.client.listSshKeys();
|
|
81707
|
+
const match = existing.find((key) => normalizeSshPublicKey(key.public_key) === normalized);
|
|
81708
|
+
if (match)
|
|
81709
|
+
return match.id;
|
|
81710
|
+
const created = await this.client.createSshKey({
|
|
81711
|
+
name: `${slug}-${environment}-deploy`,
|
|
81712
|
+
publicKey,
|
|
81713
|
+
labels
|
|
81714
|
+
});
|
|
81715
|
+
return created.id;
|
|
81716
|
+
}
|
|
81675
81717
|
outputsFromState(state, server) {
|
|
81676
81718
|
return {
|
|
81677
81719
|
deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
|
|
@@ -81702,13 +81744,14 @@ class HetznerDriver {
|
|
|
81702
81744
|
"BatchMode=yes",
|
|
81703
81745
|
localPath,
|
|
81704
81746
|
`${this.sshUser}@${host}:${remotePath}`
|
|
81705
|
-
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe" });
|
|
81747
|
+
].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
|
|
81706
81748
|
}
|
|
81707
81749
|
sshExec(host, script) {
|
|
81708
81750
|
const escaped = script.replace(/'/g, `'\\''`);
|
|
81709
81751
|
return execSync(`ssh ${this.sshBaseArgs(host).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} '${escaped}'`, {
|
|
81710
81752
|
encoding: "utf8",
|
|
81711
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
81753
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81754
|
+
maxBuffer: SSH_MAX_BUFFER
|
|
81712
81755
|
});
|
|
81713
81756
|
}
|
|
81714
81757
|
}
|
|
@@ -81758,12 +81801,14 @@ function buildSiteDeployScript(options) {
|
|
|
81758
81801
|
artifactFetch,
|
|
81759
81802
|
execStart,
|
|
81760
81803
|
envEntries,
|
|
81761
|
-
port
|
|
81804
|
+
port,
|
|
81805
|
+
preStartCommands = []
|
|
81762
81806
|
} = options;
|
|
81763
81807
|
const appDir = options.appDir ?? `/var/www/${siteName}`;
|
|
81764
81808
|
const serviceName = `${slug}-${siteName}.service`;
|
|
81765
81809
|
const envFile = Object.entries(envEntries).map(([k, v]) => `${k}=${JSON.stringify(String(v))}`).join(`
|
|
81766
81810
|
`);
|
|
81811
|
+
const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
|
|
81767
81812
|
return [
|
|
81768
81813
|
"set -euo pipefail",
|
|
81769
81814
|
...artifactFetch,
|
|
@@ -81774,6 +81819,7 @@ function buildSiteDeployScript(options) {
|
|
|
81774
81819
|
envFile,
|
|
81775
81820
|
"TS_CLOUD_ENV_EOF",
|
|
81776
81821
|
`chmod 600 ${appDir}/.env`,
|
|
81822
|
+
...preStart,
|
|
81777
81823
|
`cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
|
|
81778
81824
|
"[Unit]",
|
|
81779
81825
|
`Description=${siteName} (managed by ts-cloud)`,
|
|
@@ -81852,7 +81898,8 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
|
|
|
81852
81898
|
artifactFetch,
|
|
81853
81899
|
execStart: resolveExecStart(site.start, runtime),
|
|
81854
81900
|
envEntries: site.env || {},
|
|
81855
|
-
port: site.port
|
|
81901
|
+
port: site.port,
|
|
81902
|
+
preStartCommands: site.preStart
|
|
81856
81903
|
});
|
|
81857
81904
|
logger4.step(`Deploying to ${targets.length} target(s)...`);
|
|
81858
81905
|
const result = await driver.runRemoteDeploy({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/ts-cloud",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.21",
|
|
5
5
|
"description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
"test": "bun test"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@ts-cloud/aws-types": "0.2.
|
|
93
|
-
"@ts-cloud/core": "0.2.
|
|
92
|
+
"@ts-cloud/aws-types": "0.2.21",
|
|
93
|
+
"@ts-cloud/core": "0.2.21",
|
|
94
94
|
"@stacksjs/ts-xml": "^0.1.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|