From 80a99c6f7e35da1a4fc32286cb6021f93343e812 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Tue, 30 Jun 2026 18:57:54 +0200 Subject: [PATCH] feat(provision): Phase-0 throwaway test VM via vendored @olsitec/pulumi-hetzner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vendor hetzner module (Stage-1, trimmed to @pulumi/hcloud+js-yaml; dropped unused types.ts + bcrypt/axios/tls/vault deps). GOTCHA documented: cloud-init moves SSH to port 222. - provision/: isolated stack (platformName foundation-test, no collision with olsicloud4-*) — one cx23 in nbg1-dc3 + firewall (222/80/443/2222) + Docker cloud-init. Dedicated throwaway ed25519 key (operator id_rsa already registered → uniqueness_error). - Provisioned + verified: foundation-test @ 91.98.117.152, Docker 29.6.1, docker-over-SSH OK. Token via ENV (pass), never committed; provision/state gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + bun.lock | 35 ++ .../2026-06-30_foundation-bootstrap.log | 18 + package.json | 3 +- packages/pulumi-hetzner/VENDORED.md | 12 + packages/pulumi-hetzner/cloudinit-config.ts | 126 ++++++ packages/pulumi-hetzner/index.ts | 392 ++++++++++++++++++ packages/pulumi-hetzner/package.json | 17 + packages/pulumi-hetzner/tsconfig.json | 27 ++ provision/Pulumi.yaml | 6 + provision/index.ts | 98 +++++ provision/package.json | 12 + provision/tsconfig.json | 8 + 13 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 packages/pulumi-hetzner/VENDORED.md create mode 100644 packages/pulumi-hetzner/cloudinit-config.ts create mode 100644 packages/pulumi-hetzner/index.ts create mode 100644 packages/pulumi-hetzner/package.json create mode 100644 packages/pulumi-hetzner/tsconfig.json create mode 100644 provision/Pulumi.yaml create mode 100644 provision/index.ts create mode 100644 provision/package.json create mode 100644 provision/tsconfig.json diff --git a/.gitignore b/.gitignore index eeefdb8..2245353 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ bootstrap/state/ *.local.* # os .DS_Store +provision/state/ diff --git a/bun.lock b/bun.lock index a7fdc9a..cbd5ba1 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,20 @@ "typescript": "^5.0.0", }, }, + "packages/pulumi-hetzner": { + "name": "@olsitec/pulumi-hetzner", + "version": "1.0.0", + "dependencies": { + "@pulumi/hcloud": "^1.21.1", + "@pulumi/pulumi": "^3.138.0", + "js-yaml": "^4.1.0", + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^18", + "typescript": "^5.0.0", + }, + }, "packages/pulumi-vault": { "name": "@olsitec/pulumi-vault", "version": "0.0.0", @@ -55,6 +69,19 @@ "typescript": "^5.0.0", }, }, + "provision": { + "name": "@olsitec/foundation-provision", + "version": "0.0.0", + "dependencies": { + "@olsitec/pulumi-hetzner": "workspace:*", + "@pulumi/hcloud": "^1.21.1", + "@pulumi/pulumi": "^3.138.0", + }, + "devDependencies": { + "@types/node": "^18", + "typescript": "^5.0.0", + }, + }, }, "packages": { "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -133,8 +160,12 @@ "@olsitec/foundation-bootstrap": ["@olsitec/foundation-bootstrap@workspace:bootstrap"], + "@olsitec/foundation-provision": ["@olsitec/foundation-provision@workspace:provision"], + "@olsitec/pulumi-docker": ["@olsitec/pulumi-docker@workspace:packages/pulumi-docker"], + "@olsitec/pulumi-hetzner": ["@olsitec/pulumi-hetzner@workspace:packages/pulumi-hetzner"], + "@olsitec/pulumi-vault": ["@olsitec/pulumi-vault@workspace:packages/pulumi-vault"], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -197,6 +228,8 @@ "@pulumi/eslint-plugin": ["@pulumi/eslint-plugin@0.2.0", "", { "dependencies": { "@typescript-eslint/type-utils": "^5.33.1", "@typescript-eslint/typescript-estree": "^5.33.1", "@typescript-eslint/utils": "^5.33.1", "tsutils": "^3.21.0", "typescript": "^4.7.4" } }, "sha512-tb2Wo1pO8kmNIt+ECkVd7ykRHgadFJfddjLG8Of002X+qbRkNZNttdt55o7EdCDHGB6Dn1RFo/MJYNuHjYn/Dg=="], + "@pulumi/hcloud": ["@pulumi/hcloud@1.39.0", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0" } }, "sha512-rrjOZ1bPliOpsuoGBrd6b9GOeM+CoNSLTJrd061JzwAREdztVP6vy8UEROQj7zIUypEI0+eCqXAA1bxYIQSwkQ=="], + "@pulumi/pulumi": ["@pulumi/pulumi@3.248.0", "", { "dependencies": { "@grpc/grpc-js": "^1.10.1", "@logdna/tail-file": "^2.0.6", "@npmcli/arborist": "^9.0.0", "@opentelemetry/api": "^1.9", "@opentelemetry/exporter-trace-otlp-grpc": "^0.57", "@opentelemetry/exporter-zipkin": "^1.30", "@opentelemetry/instrumentation": "^0.57", "@opentelemetry/instrumentation-grpc": "^0.57", "@opentelemetry/resources": "^1.30", "@opentelemetry/sdk-trace-base": "^1.30", "@opentelemetry/sdk-trace-node": "^1.30", "@types/google-protobuf": "^3.15.5", "@types/semver": "^7.5.6", "@types/tmp": "^0.2.6", "execa": "^5.1.0", "fdir": "^6.5.0", "google-protobuf": "^3.21.4", "ini": "^2.0.0", "js-yaml": "^4.0.0", "minimist": "^1.2.6", "normalize-package-data": "^6.0.0", "picomatch": "^4.0.0", "require-from-string": "^2.0.1", "semver": "^7.5.2", "source-map-support": "^0.5.6", "tmp": "^0.2.4", "upath": "^1.1.0" }, "peerDependencies": { "ts-node": ">= 7.0.1 < 12", "typescript": ">= 3.8.3 < 7" }, "optionalPeers": ["ts-node", "typescript"] }, "sha512-EqgeHjVIqMS8voAM7F8SOzFAMHnVXUDdKTNF1o3Lg85YwVI0j4/eIlWG0iIVAWJl3DX0KOOM6++X0wLKHWWwmQ=="], "@pulumi/random": ["@pulumi/random@4.21.0", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0" } }, "sha512-k4JHqQFOKXWkbSLZtX37xBlRsurfgZbLd1Zib0cLSDP4Dt3uTllR3CBWfMLSnXJGAoTr9MlwyHCer/5tmSaF0Q=="], @@ -225,6 +258,8 @@ "@types/google-protobuf": ["@types/google-protobuf@3.15.12", "", {}, "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], diff --git a/documentation/command-log/2026-06-30_foundation-bootstrap.log b/documentation/command-log/2026-06-30_foundation-bootstrap.log index 5c5574a..e9ceb54 100644 --- a/documentation/command-log/2026-06-30_foundation-bootstrap.log +++ b/documentation/command-log/2026-06-30_foundation-bootstrap.log @@ -10,3 +10,21 @@ NOTE: T03 precursor live validation — prove Docker-over-SSH provider creates f CMD: (above) pulumi up/inspect/destroy — foundation-net on crunchy01 EXIT: 0 — created (subnet 172.30.0.0/24, bridge, attachable, verified) then destroyed clean; nothing persisted. --- +--- 2026-06-30T16:51:46Z --- +HOST: mac-studio -> hetzner-api (eu-central/nbg1-dc3) +CWD: /Users/andiolsi/work/olsitec-foundation/foundation/provision PROJECT: foundation-provision STACK: foundation-test +ENVIRONMENT: test(throwaway) +CMD: pulumi up --yes (HetznerDeployment cx22 + firewall + docker cloud-init) +EXIT: RUNNING +NOTE: Phase-0 provision of throwaway foundation TEST VM via vendored @olsitec/pulumi-hetzner. ~14 hcloud resources. Billable (~EUR0.007/hr). Destroyable via 'pulumi destroy' in provision/. +--- +--- 2026-06-30T16:51:49Z UPDATE --- +EXIT: 0 — see outputs (publicIp). VM provisioning; docker installs via cloud-init (~1-2 min). +--- +--- 2026-06-30T16:54:41Z UPDATE --- +EXIT: 0 — VM created (cx23, nbg1-dc3). publicIp in outputs. Docker installing via cloud-init. +--- +--- 2026-06-30T16:57:30Z UPDATE --- +EXIT: 0 — test VM foundation-test @ 91.98.117.152 (cx23, nbg1-dc3), SSH:222, Docker 29.6.1 verified. +NOTE: docker-over-SSH provider path needs SSH_PRIVATE_KEY_PATH=~/.ssh/foundation-test_ed25519 + FOUNDATION_DOCKER_HOST=ssh://root@91.98.117.152:222. DESTROY: cd provision && pulumi destroy (stack foundation-test). +--- diff --git a/package.json b/package.json index 5cf8652..2a4be8f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "olsitec-foundation mono-repo — the bootstrap egg (bootstrap/) + vendored shared Pulumi modules (packages/*). One git clone = the full DR unit (000_TOPOLOGY.md, ADR-005).", "workspaces": [ "packages/*", - "bootstrap" + "bootstrap", + "provision" ], "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/pulumi-hetzner/VENDORED.md b/packages/pulumi-hetzner/VENDORED.md new file mode 100644 index 0000000..6ef6c31 --- /dev/null +++ b/packages/pulumi-hetzner/VENDORED.md @@ -0,0 +1,12 @@ +# VENDORED — @olsitec/pulumi-hetzner + +Stage-1 vendor (000_TOPOLOGY.md §5; ADR-005), 2026-06-30. + +- **Source**: `/Users/andiolsi/work/olsicloud4/pulumi/modules/hetzner/` (index.ts, cloudinit-config.ts, tsconfig.json). +- **Trimmed**: dropped `types.ts` (standalone, unused by `HetznerDeployment`) and the unused deps it pulled + (`bcrypt`, `axios`, `@pulumi/{tls,vault,random}`, `deepmerge`, `yaml`). Real import surface is only + `@pulumi/hcloud`, `@pulumi/pulumi`, `fs`, `js-yaml`. Logic unchanged. +- **GOTCHA — SSH port 222**: `getCloudInitConfig` writes an sshd drop-in that moves SSH to **port 222** + and creates `root` + `andiolsi` users. Consumers (the Docker-over-SSH provider, ssh checks) MUST use + `:222`. The module creates **no firewall** — the consumer adds one. +- Stage-2 (publish to the foundation registry) is a later task. diff --git a/packages/pulumi-hetzner/cloudinit-config.ts b/packages/pulumi-hetzner/cloudinit-config.ts new file mode 100644 index 0000000..ec73dbf --- /dev/null +++ b/packages/pulumi-hetzner/cloudinit-config.ts @@ -0,0 +1,126 @@ +import * as yaml from "js-yaml"; + +export interface GetCloudInitConfigArgs { + sshAuthorizedKeys?: string[]; + extraPackages?: string[]; + additionalFiles?: { + path: string; + permissions: string; + content: string; + }[]; + lateCommands?: string[]; +} + +export const getCloudInitConfig = (config: GetCloudInitConfigArgs) => { + const defaultCloudInitConfig = { + users: [ + { + name: "root", + sudo: "ALL=(ALL) NOPASSWD:ALL", + "lock-passwd": false, + passwd: + "$6$hB5RrvU5$ll99.7zgATrkGEbInSRCF7o8t3TatZEYQK4QWqk6Ri.DT3LgG0l38Dz47CT.nBjLhhIUVgzIF6t2ZrWmDOcVl1", + ssh_authorized_keys: config.sshAuthorizedKeys || [], + }, + { + name: "andiolsi", + sudo: "ALL=(ALL) NOPASSWD:ALL", + "lock-passwd": false, + passwd: + "$6$sKcNr2f8$AubrAJ..bZOPxnGVEWKQQrWShW/i7ClAZRvGLstrmeDcIS92/.BVCtYzgvg2tg6ub9rz8agfLRJg.ryDoM7jG/", + ssh_authorized_keys: config.sshAuthorizedKeys || [], + }, + ], + disable_root: false, + ssh_pwauth: false, + locale: "en_US.UTF-8", + mounts: [["tmpfs", "/var/log", "tmpfs", "defaults,size=10%", "0", "0"]], + package_update: true, + package_upgrade: true, + packages: [ + "curl", + "apt-transport-https", + "ca-certificates", + "vim", + "jq", + "locales", + ...(config.extraPackages || []), + ], + write_files: [ + { + path: "/etc/ssh/sshd_config.d/99-cloud-init-manual.conf", + permissions: "0600", + content: ` +Port 222 +PermitRootLogin without-password +PubkeyAuthentication yes +PubkeyAcceptedKeyTypes=+ssh-rsa + +`, + }, + { + path: "/etc/logrotate.d/var_log", + permissions: "0644", + content: ` +/var/log/*.log { + daily + missingok + rotate 7 + compress + delaycompress + notifempty + create 0640 root adm + sharedscripts + postrotate + systemctl reload rsyslog > /dev/null 2>/dev/null || true + endscript +}`, + }, + { + path: "/etc/selinux/config", + permissions: "0644", + content: "SELINUX=disabled\nSELINUXTYPE=targeted\n", + }, + { + path: "/etc/security/limits.conf", + permissions: "0644", + content: "* soft nofile 32768\n* hard nofile 65536\n", + }, + { + path: "/etc/sysctl.d/99-custom.conf", + permissions: "0644", + content: ` +net.ipv4.ip_forward=1 +net.ipv4.conf.all.arp_ignore=1 +net.ipv4.conf.all.arp_announce=2 +net.ipv4.ip_nonlocal_bind=1 +vm.dirty_ratio=10 +vm.swappiness=0 +vm.dirty_background_ratio=5 +net.ipv6.conf.all.disable_ipv6=1 +fs.nr_open=3000000 +fs.file-max=3000000 +fs.inotify.max_user_instances=1048576 +fs.inotify.max_queued_events=1048576 +fs.inotify.max_user_watches=1048576 +vm.max_map_count=262144 +net.netfilter.nf_conntrack_max=262144 +net.netfilter.nf_conntrack_tcp_timeout_established=600 +net.netfilter.nf_conntrack_tcp_timeout_time_wait=30 +net.netfilter.nf_conntrack_tcp_timeout_close_wait=30 +net.netfilter.nf_conntrack_tcp_timeout_fin_wait=30 +`, + }, + ...(config.additionalFiles || []), + ], + runcmd: [ + "echo never > /sys/kernel/mm/transparent_hugepage/enabled", + "echo never > /sys/kernel/mm/transparent_hugepage/defrag", + "systemctl stop apparmor || true", + "systemctl disable apparmor || true", + "sysctl --system", + ...(config.lateCommands || []), + ], + }; + return `#cloud-config\n` + yaml.dump(defaultCloudInitConfig); +}; diff --git a/packages/pulumi-hetzner/index.ts b/packages/pulumi-hetzner/index.ts new file mode 100644 index 0000000..54fb8c4 --- /dev/null +++ b/packages/pulumi-hetzner/index.ts @@ -0,0 +1,392 @@ +// hetzner/index.ts +import * as pulumi from "@pulumi/pulumi"; +import * as hcloud from "@pulumi/hcloud"; +import * as fs from "fs"; +import { + getCloudInitConfig, + type GetCloudInitConfigArgs, +} from "./cloudinit-config"; +import { LoadBalancerServiceHealthCheck } from "@pulumi/hcloud/types/input"; + +interface HetznerDeploymentConfig { + platformName: string; + datacenter: string; +} + +interface HetznerDeploymentArgs { + platformName: string; + hcloudToken: pulumi.Input; + vswitchId?: number; + sshKeyPath: string; + datacenter: string; + servers: { + name: string; + hostname: string; + image?: string; + type: string; + labels?: Record; + cloudInitConfig?: GetCloudInitConfigArgs; + }[]; + loadbalancers?: { + name: string; + services: { + name: string; + port: number; + targetPort: number; + proxyprotocol?: boolean; + healthCheck?: LoadBalancerServiceHealthCheck; + }[]; + targets: Partial[]; + }[]; +} + +class HetznerDeployment extends pulumi.ComponentResource { + public readonly deploymentConfig: HetznerDeploymentConfig; + public readonly servers: hcloud.Server[]; + public readonly platformNetwork: hcloud.Network; + public readonly platformNetworkSubnetCloud: hcloud.NetworkSubnet; + public readonly platformNetworkSubnetDedicated?: + | hcloud.NetworkSubnet + | undefined; + public readonly platformNetworkSubnetLoadBalancers: hcloud.NetworkSubnet; + public readonly publicIps: hcloud.PrimaryIp[]; + public readonly placementGroup: hcloud.PlacementGroup; + public readonly networkAttachments: hcloud.ServerNetwork[]; + // public readonly loadBalancers: hcloud.LoadBalancer[]; + public readonly loadBalancerInstances: { + loadBalancer: hcloud.LoadBalancer; + networkAttachment: hcloud.LoadBalancerNetwork; + services: hcloud.LoadBalancerService[]; + targets: hcloud.LoadBalancerTarget[]; + }[]; + public readonly serverInfo: { + name: pulumi.Output; + publicIp: pulumi.Output; + privateIp: string; + }[]; + + constructor( + name: string, + args: HetznerDeploymentArgs, + opts?: pulumi.ComponentResourceOptions, + ) { + super("custom:resource:HetznerDeployment", name, {}, opts); + this.deploymentConfig = args; + + // Hetzner Provider + const hcloudProvider = new hcloud.Provider( + `${this.deploymentConfig.platformName}-hcloud-provider`, + { + token: args.hcloudToken, + }, + { parent: this }, + ); + + /* + Hetzner API has 2 functions to get datacenters: + function getDatacenters(args: GetDatacentersArgs, opts?: InvokeOptions): Promise + function getDatacentersOutput(args: GetDatacentersOutputArgs, opts?: InvokeOptions): Output + + We need to check wether this.deploymentConfig.datacenter is a valid datacenter. + */ + hcloud + .getDatacentersOutput({ provider: hcloudProvider, parent: this }) + .apply((datacenters) => { + const isValid = datacenters.datacenters.some( + (dc) => dc.name === this.deploymentConfig.datacenter, + ); + if (!isValid) { + throw new Error( + `Invalid datacenter: ${this.deploymentConfig.datacenter}, valid datacenters are: ${datacenters.datacenters + .map((dc) => dc.name) + .join(", ")}`, + ); + } + }); + + const sshPublicKey = fs.readFileSync(args.sshKeyPath, "utf-8"); + const sshKey = new hcloud.SshKey( + `${this.deploymentConfig.platformName}-ssh-key`, + { + name: `${this.deploymentConfig.platformName}-ssh-key`, + publicKey: sshPublicKey, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [hcloudProvider], + deleteBeforeReplace: true, + }, + ); + + this.placementGroup = new hcloud.PlacementGroup( + `${this.deploymentConfig.platformName}-hcloud-placement-group`, + { + name: `${this.deploymentConfig.platformName}-hcloud-placement-group`, + type: "spread", + labels: { + platform: this.deploymentConfig.platformName, + }, + }, + { parent: this, provider: hcloudProvider, dependsOn: [hcloudProvider] }, + ); + + // Allocate public IPs + this.publicIps = args.servers.map((server, serverIndex) => { + return new hcloud.PrimaryIp( + `${this.deploymentConfig.platformName}-hcloud-primary-ip-${(serverIndex + 1).toString().padStart(2, "0")}`, + { + type: "ipv4", + assigneeType: "server", + autoDelete: false, + datacenter: this.deploymentConfig.datacenter, + labels: { + platform: this.deploymentConfig.platformName, + role: "controlplane", + node: server.hostname, + }, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [hcloudProvider], + }, + ); + }); + + // Create a network + this.platformNetwork = new hcloud.Network( + `${this.deploymentConfig.platformName}-hcloud-network`, + { + name: `${this.deploymentConfig.platformName}-hcloud-network`, + ipRange: "10.254.0.0/16", + labels: { + platform: this.deploymentConfig.platformName, + }, + deleteProtection: false, + }, + { parent: this, provider: hcloudProvider, dependsOn: [hcloudProvider] }, + ); + + // Create a network subnet for cloud vms + this.platformNetworkSubnetCloud = new hcloud.NetworkSubnet( + `${this.deploymentConfig.platformName}-hcloud-network-subnet-cloud`, + { + type: "cloud", + networkId: this.platformNetwork.id.apply((id) => Number(id)), + networkZone: "eu-central", + ipRange: "10.254.1.0/24", + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [hcloudProvider, this.platformNetwork], + }, + ); + + if (args.vswitchId) { + // Create a network subnet for dedicated servers + this.platformNetworkSubnetDedicated = new hcloud.NetworkSubnet( + `${this.deploymentConfig.platformName}-hcloud-network-subnet-dedicated`, + { + type: "vswitch", + networkId: this.platformNetwork.id.apply((id) => Number(id)), + networkZone: "eu-central", + ipRange: "10.254.2.0/24", + vswitchId: args.vswitchId, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [hcloudProvider, this.platformNetwork], + }, + ); + } + this.servers = args.servers.map((server, serverIndex) => { + return new hcloud.Server( + `${this.deploymentConfig.platformName}-hcloud-server-${(serverIndex + 1).toString().padStart(2, "0")}-${server.name}`, + { + name: server.name, + serverType: server.type, + deleteProtection: false, + image: server.image || "debian-12", + datacenter: this.deploymentConfig.datacenter, + placementGroupId: this.placementGroup.id.apply((id) => Number(id)), + sshKeys: [sshKey.id], + publicNets: [ + { + ipv4Enabled: true, + ipv6Enabled: false, + ipv4: this.publicIps[serverIndex].id.apply((id) => Number(id)), + }, + ], + labels: { + platform: this.deploymentConfig.platformName, + ...server.labels, + }, + userData: getCloudInitConfig({ + ...(server.cloudInitConfig || {}), + sshAuthorizedKeys: [sshPublicKey], + }), + }, + { + parent: this, + provider: hcloudProvider, + ignoreChanges: ["userData"], + dependsOn: [ + this.placementGroup, + this.platformNetwork, + this.platformNetworkSubnetCloud, + this.publicIps[serverIndex], + sshKey, + ], + }, + ); + }); + + this.networkAttachments = this.servers.map((server, serverIndex) => { + return new hcloud.ServerNetwork( + `${this.deploymentConfig.platformName}-hcloud-server-network-${(serverIndex + 1).toString().padStart(2, "0")}-${args.servers[serverIndex].name}`, + { + serverId: server.id.apply((id) => Number(id)), + networkId: this.platformNetworkSubnetCloud.networkId.apply((id) => + Number(id), + ), + ip: `10.254.1.${serverIndex + 1 + 10}`, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [ + this.platformNetwork, + this.publicIps[serverIndex], + this.servers[serverIndex], + ], + deletedWith: this.servers[serverIndex], + }, + ); + }); + // Create a network subnet for dedicated servers + this.platformNetworkSubnetLoadBalancers = new hcloud.NetworkSubnet( + `${this.deploymentConfig.platformName}-hcloud-network-subnet-loadbalancers`, + { + type: "cloud", + networkId: this.platformNetwork.id.apply((id) => Number(id)), + networkZone: "eu-central", + ipRange: "10.254.3.0/24", + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [hcloudProvider, this.platformNetwork], + }, + ); + + this.loadBalancerInstances = + args.loadbalancers?.map((loadBalancerArg, loadBalancerIndex) => { + const loadBalancer = new hcloud.LoadBalancer( + `${this.deploymentConfig.platformName}-hcloud-loadbalancer-${loadBalancerIndex}-${loadBalancerArg.name}`, + { + name: loadBalancerArg.name, + loadBalancerType: "lb11", + networkZone: "eu-central", + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [ + this.platformNetwork, + this.platformNetworkSubnetLoadBalancers, + ], + }, + ); + const networkAttachment = new hcloud.LoadBalancerNetwork( + `${this.deploymentConfig.platformName}-hcloud-loadbalancer-network-${loadBalancerIndex}-${loadBalancerArg.name}`, + { + subnetId: this.platformNetworkSubnetLoadBalancers.id.apply( + (id) => id, + ), + // networkId: this.platformNetwork.id.apply((id) => Number(id)), + loadBalancerId: loadBalancer.id.apply((id) => Number(id)), + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [ + loadBalancer, + this.platformNetwork, + this.platformNetworkSubnetLoadBalancers, + ], + }, + ); + const loadBalancerTargets = loadBalancerArg.targets.map( + (target, targetIndex) => { + return new hcloud.LoadBalancerTarget( + `${this.deploymentConfig.platformName}-hcloud-loadbalancer-target-${loadBalancerIndex}-${loadBalancerArg.name}-${targetIndex}`, + { + type: target.type || "ip", + loadBalancerId: loadBalancer.id.apply((id) => Number(id)), + ...target, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [loadBalancer], + }, + ); + }, + ); + const loadBalancerServices = loadBalancerArg.services.map( + (service, serviceIndex) => { + return new hcloud.LoadBalancerService( + `${this.deploymentConfig.platformName}-hcloud-loadbalancer-service-${loadBalancerIndex}-${loadBalancerArg.name}-${serviceIndex}`, + { + loadBalancerId: loadBalancer.id.apply((id) => id), + protocol: "tcp", + listenPort: service.port, + destinationPort: service.targetPort, + proxyprotocol: service.proxyprotocol || false, + healthCheck: service.healthCheck || { + port: service.targetPort, + protocol: "tcp", + interval: 15, + timeout: 10, + retries: 3, + }, + }, + { + parent: this, + provider: hcloudProvider, + dependsOn: [loadBalancer], + deleteBeforeReplace: true, + }, + ); + }, + ); + return { + loadBalancer: loadBalancer, + networkAttachment: networkAttachment, + targets: loadBalancerTargets, + services: loadBalancerServices, + }; + }) || []; + + this.serverInfo = this.servers.map((server, serverIndex) => { + return { + name: server.name, + publicIp: this.publicIps[serverIndex].ipAddress, + privateIp: `10.254.1.${serverIndex + 1 + 10}`, + }; + }); + + this.registerOutputs({ + servers: this.servers, + platformNetwork: this.platformNetwork, + publicIps: this.publicIps, + serverInfo: this.serverInfo, + networkAttachments: this.networkAttachments, + }); + } +} + +export { HetznerDeployment }; diff --git a/packages/pulumi-hetzner/package.json b/packages/pulumi-hetzner/package.json new file mode 100644 index 0000000..4c5c9d0 --- /dev/null +++ b/packages/pulumi-hetzner/package.json @@ -0,0 +1,17 @@ +{ + "name": "@olsitec/pulumi-hetzner", + "version": "1.0.0", + "private": true, + "main": "index.ts", + "description": "Vendored Hetzner Cloud provisioning module (Stage-1 vendor; see VENDORED.md). Trimmed to the deps HetznerDeployment actually uses.", + "dependencies": { + "@pulumi/hcloud": "^1.21.1", + "@pulumi/pulumi": "^3.138.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^18", + "typescript": "^5.0.0" + } +} diff --git a/packages/pulumi-hetzner/tsconfig.json b/packages/pulumi-hetzner/tsconfig.json new file mode 100644 index 0000000..977a963 --- /dev/null +++ b/packages/pulumi-hetzner/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "bin", + "build", + "out", + "_test_" + ] +} diff --git a/provision/Pulumi.yaml b/provision/Pulumi.yaml new file mode 100644 index 0000000..1eea5c0 --- /dev/null +++ b/provision/Pulumi.yaml @@ -0,0 +1,6 @@ +name: foundation-provision +description: Phase-0 — provision the throwaway foundation test VM on Hetzner Cloud (isolated stack). +runtime: + name: nodejs + options: + packagemanager: bun diff --git a/provision/index.ts b/provision/index.ts new file mode 100644 index 0000000..f9cac10 --- /dev/null +++ b/provision/index.ts @@ -0,0 +1,98 @@ +// provision/index.ts — Phase 0: provision the throwaway foundation TEST VM. +// +// Isolated stack (platformName "foundation-test" → no name collision with the +// production olsicloud4-* resources). Uses the vendored @olsitec/pulumi-hetzner +// HetznerDeployment for one cx22, adds a firewall, and installs Docker via the +// module's cloud-init lateCommands. +// +// GOTCHA (VENDORED.md): the module's cloud-init moves SSH to PORT 222 and creates +// root + andiolsi users. The firewall + the Docker-over-SSH provider use :222. +// +// Token comes from ENV (export HCLOUD_TOKEN="$(pass olsicloud4/HCLOUD_TOKEN)"), +// never committed. +import * as pulumi from "@pulumi/pulumi"; +import * as hcloud from "@pulumi/hcloud"; +import { HetznerDeployment } from "@olsitec/pulumi-hetzner"; +import * as os from "os"; + +const token = process.env.HCLOUD_TOKEN; +if (!token) { + throw new Error( + 'HCLOUD_TOKEN env required: export HCLOUD_TOKEN="$(pass olsicloud4/HCLOUD_TOKEN)"', + ); +} + +const platformName = "foundation-test"; +const datacenter = "nbg1-dc3"; // matches existing cx22 servers (known-good) +// Dedicated throwaway key: the operator's id_rsa pubkey is already registered in +// the project (olsicloud4-ssh-key) and Hetzner rejects duplicate key CONTENT, so +// the test VM uses its own ed25519 key. SSH/Docker-over-SSH use the matching +// private key (~/.ssh/foundation-test_ed25519) on port 222. +const sshKeyPath = + process.env.FOUNDATION_TEST_SSH_PUBKEY || + `${os.homedir()}/.ssh/foundation-test_ed25519.pub`; + +// Install Docker CE on Debian 12 (runs in cloud-init runcmd, after base packages). +const dockerInstall = [ + "install -m 0755 -d /etc/apt/keyrings", + "curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc", + "chmod a+r /etc/apt/keyrings/docker.asc", + 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list', + "apt-get update", + "apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin", + "systemctl enable --now docker", + "touch /root/.provision-done", +]; + +const dep = new HetznerDeployment(platformName, { + platformName, + hcloudToken: token, + sshKeyPath, + datacenter, + servers: [ + { + name: "foundation-test", + hostname: "foundation-test", + type: "cx23", // current-gen Intel shared (cx22 is legacy/unavailable for new servers); 2c/4G + image: "debian-12", + labels: { purpose: "foundation-test", ephemeral: "true" }, + cloudInitConfig: { lateCommands: dockerInstall }, + }, + ], +}); + +// Own provider for the firewall (the module's provider is internal/private). +const provider = new hcloud.Provider("foundation-test-hcloud", { token }); + +const firewall = new hcloud.Firewall( + "foundation-test-fw", + { + name: "foundation-test-fw", + rules: [ + // SSH — cloud-init moves sshd to 222 (VENDORED.md gotcha) + { direction: "in", protocol: "tcp", port: "222", sourceIps: ["0.0.0.0/0", "::/0"] }, + // Caddy HTTP/HTTPS + { direction: "in", protocol: "tcp", port: "80", sourceIps: ["0.0.0.0/0", "::/0"] }, + { direction: "in", protocol: "tcp", port: "443", sourceIps: ["0.0.0.0/0", "::/0"] }, + // Forgejo git-over-SSH (CONTRACT_001 forgeSshPort) + { direction: "in", protocol: "tcp", port: "2222", sourceIps: ["0.0.0.0/0", "::/0"] }, + { direction: "in", protocol: "icmp", sourceIps: ["0.0.0.0/0", "::/0"] }, + ], + }, + { provider }, +); + +new hcloud.FirewallAttachment( + "foundation-test-fw-attach", + { + firewallId: firewall.id.apply((id) => Number(id)), + serverIds: [dep.servers[0].id.apply((id) => Number(id))], + }, + { provider }, +); + +// Outputs consumed by the foundation test stack + the operator. +export const serverName = dep.serverInfo[0].name; +export const publicIp = dep.serverInfo[0].publicIp; +export const sshCommand = pulumi.interpolate`ssh -p 222 root@${dep.serverInfo[0].publicIp}`; +export const dockerHost = pulumi.interpolate`ssh://root@${dep.serverInfo[0].publicIp}:222`; diff --git a/provision/package.json b/provision/package.json new file mode 100644 index 0000000..e7bbb7f --- /dev/null +++ b/provision/package.json @@ -0,0 +1,12 @@ +{ + "name": "@olsitec/foundation-provision", + "private": true, + "version": "0.0.0", + "main": "index.ts", + "dependencies": { + "@olsitec/pulumi-hetzner": "workspace:*", + "@pulumi/hcloud": "^1.21.1", + "@pulumi/pulumi": "^3.138.0" + }, + "devDependencies": { "@types/node": "^18", "typescript": "^5.0.0" } +} diff --git a/provision/tsconfig.json b/provision/tsconfig.json new file mode 100644 index 0000000..97a45fd --- /dev/null +++ b/provision/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": true, "outDir": "bin", "target": "es2020", "module": "commonjs", + "moduleResolution": "node", "sourceMap": true, "experimentalDecorators": true, + "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true + }, + "files": ["index.ts"] +}