mirror of
https://github.com/KevinMidboe/schleppe-pulumi.git
synced 2026-01-09 02:45:51 +00:00
Compare commits
2 Commits
ec0eb23acd
...
7b42f2e3bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b42f2e3bd | |||
| 80b58a9f3e |
74
docker-compose/00_traefik/docker-compose.yml
Normal file
74
docker-compose/00_traefik/docker-compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: "traefik:latest"
|
||||||
|
container_name: traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# PORTS
|
||||||
|
ports:
|
||||||
|
# HTTP entrypoint
|
||||||
|
# Exposed on all external addresses (0.0.0.0:80)
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
# Traefik API & Dashboard
|
||||||
|
# Accessible on http://<host>:8080
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# COMMAND (STATIC CONFIGURATION)
|
||||||
|
command:
|
||||||
|
# Enable Traefik API & Dashboard
|
||||||
|
- "--api.dashboard=true"
|
||||||
|
- "--api.insecure=true"
|
||||||
|
|
||||||
|
# Log settings
|
||||||
|
- "--log.level=INFO"
|
||||||
|
- "--accesslog=true"
|
||||||
|
|
||||||
|
# EntryPoints
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.traefik.address=:8080"
|
||||||
|
|
||||||
|
# Docker provider
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
|
||||||
|
# Optional: file provider for dynamic config
|
||||||
|
- "--providers.file.directory=/etc/traefik/dynamic"
|
||||||
|
- "--providers.file.watch=true"
|
||||||
|
|
||||||
|
# Global settings
|
||||||
|
- "--global.checknewversion=true"
|
||||||
|
- "--global.sendanonymoususage=false"
|
||||||
|
|
||||||
|
# VOLUMES
|
||||||
|
volumes:
|
||||||
|
# Docker socket (required for Docker provider)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
# Dynamic configuration directory (middlewares, routers, TLS, etc.)
|
||||||
|
- ./dynamic:/etc/traefik/dynamic:ro
|
||||||
|
|
||||||
|
# Logs (optional)
|
||||||
|
- ./logs:/logs
|
||||||
|
|
||||||
|
# NETWORKS
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# LABELS (OPTIONAL SELF-ROUTING)
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this container
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# Router for dashboard (via Traefik itself)
|
||||||
|
- "traefik.http.routers.traefik.rule=Host(`traefik.localhost`)"
|
||||||
|
- "traefik.http.routers.traefik.entrypoints=web"
|
||||||
|
- "traefik.http.routers.traefik.service=api@internal"
|
||||||
|
|
||||||
|
# NETWORK DEFINITIONS
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
name: traefik
|
||||||
|
driver: bridge
|
||||||
62
docker-compose/compose-all.sh
Normal file
62
docker-compose/compose-all.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# CONFIG
|
||||||
|
########################################
|
||||||
|
COMPOSE_FILE_NAME="docker-compose.yml"
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# ARGUMENT CHECK
|
||||||
|
########################################
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "Usage: $0 {up|down}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTION="$1"
|
||||||
|
|
||||||
|
if [[ "$ACTION" != "up" && "$ACTION" != "down" ]]; then
|
||||||
|
echo "Invalid action: $ACTION"
|
||||||
|
echo "Allowed actions: up, down"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# SAVE STARTING DIRECTORY
|
||||||
|
########################################
|
||||||
|
START_DIR="$(pwd)"
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# FIND COMPOSE FILES
|
||||||
|
########################################
|
||||||
|
mapfile -t COMPOSE_DIRS < <(
|
||||||
|
find . -type f -name "$COMPOSE_FILE_NAME" -print0 \
|
||||||
|
| xargs -0 -n1 dirname | sort
|
||||||
|
)
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# LOOP THROUGH DIRECTORIES
|
||||||
|
########################################
|
||||||
|
for DIR in "${COMPOSE_DIRS[@]}"; do
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "Processing: $DIR"
|
||||||
|
echo "Action: docker-compose $ACTION"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
if [[ "$ACTION" == "up" ]]; then
|
||||||
|
docker-compose up -d
|
||||||
|
else
|
||||||
|
docker-compose down
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$START_DIR"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "Completed docker-compose $ACTION for all stacks"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
28
docker-compose/k9e/docker-compose.yml
Normal file
28
docker-compose/k9e/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
k9e:
|
||||||
|
image: kevinmidboe/k9e.no:latest
|
||||||
|
container_name: k9e
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# NETWORK
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# TRAEFIK LABELS
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this container
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# Router definition
|
||||||
|
- "traefik.http.routers.k9e.rule=Host(`k9e.no`)"
|
||||||
|
- "traefik.http.routers.k9e.entrypoints=web"
|
||||||
|
|
||||||
|
# Service definition
|
||||||
|
- "traefik.http.services.k9e.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# NETWORK DEFINITIONS
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
28
docker-compose/planetposen/docker-compose.yml
Normal file
28
docker-compose/planetposen/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
planetposen-original:
|
||||||
|
image: kevinmidboe/planetposen-original:latest
|
||||||
|
container_name: planetposen
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# NETWORK
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# TRAEFIK LABELS
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this container
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# Router definition
|
||||||
|
- "traefik.http.routers.planetposen-original.rule=Host(`planetposen.no`)"
|
||||||
|
- "traefik.http.routers.planetposen-original.entrypoints=web"
|
||||||
|
|
||||||
|
# Service definition
|
||||||
|
- "traefik.http.services.planetposen-original.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# NETWORK DEFINITIONS
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
29
docker-compose/whoami/docker-compose.yml
Normal file
29
docker-compose/whoami/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
whoami:
|
||||||
|
image: traefik/whoami
|
||||||
|
container_name: whoami
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# NETWORK
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# TRAEFIK LABELS
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this container
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# Router definition
|
||||||
|
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
|
||||||
|
- "traefik.http.routers.whoami.entrypoints=web"
|
||||||
|
|
||||||
|
# Service definition
|
||||||
|
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# NETWORK DEFINITIONS
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|
||||||
2
hetzner-pulumi/.gitignore
vendored
Normal file
2
hetzner-pulumi/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/bin/
|
||||||
|
/node_modules/
|
||||||
6
hetzner-pulumi/Pulumi.yaml
Normal file
6
hetzner-pulumi/Pulumi.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name: hetzner-pulumi
|
||||||
|
description: Manages schleppe ha project hetzner resources
|
||||||
|
runtime:
|
||||||
|
name: nodejs
|
||||||
|
options:
|
||||||
|
packagemanager: yarn
|
||||||
54
hetzner-pulumi/index.ts
Normal file
54
hetzner-pulumi/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
subNetwork,
|
||||||
|
regionalNetwork,
|
||||||
|
} from "./resources/network";
|
||||||
|
import { genServer } from "./resources/compute";
|
||||||
|
|
||||||
|
import {
|
||||||
|
VmSize,
|
||||||
|
OS,
|
||||||
|
NetworkRegion,
|
||||||
|
NetworkRole,
|
||||||
|
ServerLocations,
|
||||||
|
} from "./resources/types";
|
||||||
|
|
||||||
|
const eu = regionalNetwork("ha", "10.24.0.0/18", NetworkRegion.eu);
|
||||||
|
const usEast = regionalNetwork("ha", "10.25.0.0/18", NetworkRegion.usEast);
|
||||||
|
|
||||||
|
const network = {
|
||||||
|
eu: {
|
||||||
|
lb: subNetwork(eu, NetworkRole.lb, NetworkRegion.eu, "10.24.1.0/24"),
|
||||||
|
cache: subNetwork(eu, NetworkRole.cache, NetworkRegion.eu, "10.24.2.0/24"),
|
||||||
|
web: subNetwork(eu, NetworkRole.web, NetworkRegion.eu, "10.24.3.0/24"),
|
||||||
|
// db: subNetwork(eu, NetworkRole.db, "10.24.4.0/24")
|
||||||
|
},
|
||||||
|
us: {
|
||||||
|
lb: subNetwork(usEast, NetworkRole.lb, NetworkRegion.usEast, "10.25.1.0/24"),
|
||||||
|
web: subNetwork(usEast, NetworkRole.web, NetworkRegion.usEast, "10.25.2.0/24"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hel1 = ServerLocations.helsinki;
|
||||||
|
const hil = ServerLocations.hillsboro;
|
||||||
|
|
||||||
|
const haproxyEU1 = genServer("haproxy-1", VmSize.small, OS.debian, hel1, network.eu.lb);
|
||||||
|
const haproxyEU2 = genServer("haproxy-2", VmSize.small, OS.debian, hel1, network.eu.lb);
|
||||||
|
const haproxyUS1 = genServer("haproxy-1", VmSize.small, OS.debian, hil, network.us.lb);
|
||||||
|
|
||||||
|
const haproxyCache1 = genServer("varnish-1", VmSize.small, OS.debian, hel1, network.eu.cache);
|
||||||
|
const haproxyCache2 = genServer("varnish-2", VmSize.small, OS.debian, hel1, network.eu.cache);
|
||||||
|
// const varnishUS = genServer(2, 'varnish', VmSize.small, OS.debian, hel1, network.us.cache)
|
||||||
|
|
||||||
|
export const servers = [
|
||||||
|
haproxyEU1, haproxyEU2, haproxyUS1, haproxyCache1, haproxyCache2
|
||||||
|
];
|
||||||
|
|
||||||
|
export const networks = [
|
||||||
|
eu,
|
||||||
|
usEast,
|
||||||
|
network.eu.lb,
|
||||||
|
network.eu.cache,
|
||||||
|
network.eu.web,
|
||||||
|
network.us.lb,
|
||||||
|
network.us.web,
|
||||||
|
];
|
||||||
14
hetzner-pulumi/package.json
Normal file
14
hetzner-pulumi/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "hetzner-pulumi",
|
||||||
|
"main": "index.ts",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^18",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pulumi/hcloud": "^1.29.0",
|
||||||
|
"@pulumi/pulumi": "^3.213.0",
|
||||||
|
"@pulumi/random": "^4.18.4",
|
||||||
|
"zod": "^4.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
hetzner-pulumi/resources/compute.ts
Normal file
52
hetzner-pulumi/resources/compute.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
|
import * as hcloud from "@pulumi/hcloud";
|
||||||
|
import * as random from "@pulumi/random";
|
||||||
|
import { config } from './config';
|
||||||
|
import { getCheapestServerType } from './utils';
|
||||||
|
|
||||||
|
import { VmSize, OS, ServerLocations } from "./types";
|
||||||
|
|
||||||
|
// “Tag” servers using labels. Hetzner firewalls can target servers by label selectors. :contentReference[oaicite:2]{index=2}
|
||||||
|
const serverLabels = {
|
||||||
|
app: "demo",
|
||||||
|
role: "web",
|
||||||
|
env: pulumi.getStack(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
function getSshPublicKey(): hcloud.SshKey {
|
||||||
|
const sshPublicKey = config.require("sshPublicKey");
|
||||||
|
return sshKey;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sshPublicKey = config.require("sshPublicKey");
|
||||||
|
const sshKey = new hcloud.SshKey("ssh-key", {
|
||||||
|
name: `pulumi-${pulumi.getStack()}-ssh`,
|
||||||
|
publicKey: sshPublicKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function genServer(
|
||||||
|
name: string,
|
||||||
|
size: VmSize,
|
||||||
|
os: OS = OS.debian,
|
||||||
|
location: ServerLocations,
|
||||||
|
network: hcloud.NetworkSubnet
|
||||||
|
): hcloud.Server {
|
||||||
|
const ceap = getCheapestServerType('eu');
|
||||||
|
const hexId = new random.RandomId(`${name}-${location}`, {
|
||||||
|
byteLength: 2, // 2 bytes = 4 hex characters
|
||||||
|
});
|
||||||
|
|
||||||
|
name = `${name}-${location}`
|
||||||
|
|
||||||
|
return new hcloud.Server(name, {
|
||||||
|
name,
|
||||||
|
image: os,
|
||||||
|
serverType: ceap,
|
||||||
|
location,
|
||||||
|
networks: [network],
|
||||||
|
sshKeys: [sshKey.name],
|
||||||
|
labels: serverLabels
|
||||||
|
})
|
||||||
|
}
|
||||||
16
hetzner-pulumi/resources/config.ts
Normal file
16
hetzner-pulumi/resources/config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
|
|
||||||
|
const config = new pulumi.Config();
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
osImage: config.get("image") || "debian-11",
|
||||||
|
machineType: config.get("serverType") || "f1-micro",
|
||||||
|
machineLocation: config.get("location") || "hel1",
|
||||||
|
instanceTag: config.get("instanceTag") || "webserver",
|
||||||
|
servicePort: config.get("servicePort") || "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
variables,
|
||||||
|
config
|
||||||
|
}
|
||||||
95
hetzner-pulumi/resources/network.ts
Normal file
95
hetzner-pulumi/resources/network.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
|
import * as hcloud from "@pulumi/hcloud";
|
||||||
|
|
||||||
|
import type { NetworkRegion } from "./types";
|
||||||
|
|
||||||
|
// Required
|
||||||
|
|
||||||
|
// make sure to have regional parent networks
|
||||||
|
|
||||||
|
const networkName = (name: string, region: NetworkRegion) =>
|
||||||
|
`${name}-net-${region}`;
|
||||||
|
|
||||||
|
export function regionalNetwork(
|
||||||
|
prefix: string,
|
||||||
|
cidr: string,
|
||||||
|
region: NetworkRegion,
|
||||||
|
) {
|
||||||
|
const name = networkName(prefix, region);
|
||||||
|
const parentNetworkRange = 8;
|
||||||
|
const [ip, _] = cidr.split("/");
|
||||||
|
|
||||||
|
const net = new hcloud.Network(name, {
|
||||||
|
ipRange: `${ip}/${parentNetworkRange}`,
|
||||||
|
labels: {
|
||||||
|
region,
|
||||||
|
hiearchy: "parent",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return net;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subNetwork(
|
||||||
|
parentNetwork: hcloud.Network,
|
||||||
|
prefix: string,
|
||||||
|
region: NetworkRegion,
|
||||||
|
cidr: string,
|
||||||
|
): hcloud.NetworkSubnet {
|
||||||
|
const name = `${prefix}-subnet-${region}`;
|
||||||
|
|
||||||
|
const net = new hcloud.NetworkSubnet(name, {
|
||||||
|
networkId: parentNetwork.id.apply(id => Number(id)),
|
||||||
|
type: "cloud",
|
||||||
|
networkZone: "eu-central",
|
||||||
|
ipRange: cidr,
|
||||||
|
});
|
||||||
|
|
||||||
|
return net;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allowHttp = new hcloud.Firewall("allow-http", {
|
||||||
|
name: "allow-http",
|
||||||
|
applyTos: [
|
||||||
|
{
|
||||||
|
labelSelector: `role=load-balancer,env=${pulumi.getStack()}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
direction: "in",
|
||||||
|
protocol: "tcp",
|
||||||
|
port: "80",
|
||||||
|
sourceIps: ["0.0.0.0/0", "::/0"],
|
||||||
|
description: "Allow HTTP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "in",
|
||||||
|
protocol: "tcp",
|
||||||
|
port: "443",
|
||||||
|
sourceIps: ["0.0.0.0/0", "::/0"],
|
||||||
|
description: "Allow HTTPS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "in",
|
||||||
|
protocol: "udp",
|
||||||
|
port: "443",
|
||||||
|
sourceIps: ["0.0.0.0/0", "::/0"],
|
||||||
|
description: "Allow QUIC",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const allowSSH = new hcloud.Firewall("allow-ssh", {
|
||||||
|
name: "allow-ssh",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
direction: "in",
|
||||||
|
protocol: "tcp",
|
||||||
|
port: "22",
|
||||||
|
sourceIps: ["127.0.0.0/24"],
|
||||||
|
description: "Allow SSH from approved CIDRs only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
2
hetzner-pulumi/resources/types/index.ts
Normal file
2
hetzner-pulumi/resources/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./network";
|
||||||
|
export * from "./server";
|
||||||
12
hetzner-pulumi/resources/types/network.ts
Normal file
12
hetzner-pulumi/resources/types/network.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export enum NetworkRegion {
|
||||||
|
eu = "eu-central",
|
||||||
|
usWest = "us-west",
|
||||||
|
usEast = "us-east",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NetworkRole {
|
||||||
|
lb = "load-balancer",
|
||||||
|
cache = "varnish-cache",
|
||||||
|
web = "webserver",
|
||||||
|
db = "database",
|
||||||
|
}
|
||||||
19
hetzner-pulumi/resources/types/server.ts
Normal file
19
hetzner-pulumi/resources/types/server.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export enum VmSize {
|
||||||
|
small = "small",
|
||||||
|
medium = "medium",
|
||||||
|
large = "large",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OS {
|
||||||
|
debian = "debian",
|
||||||
|
ubuntu = "ubuntu",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ServerLocations {
|
||||||
|
helsinki = "hel1",
|
||||||
|
falkenstein = "fsn1",
|
||||||
|
nuremberg = "nbg1",
|
||||||
|
hillsboro = "hil",
|
||||||
|
ashburn = "ash",
|
||||||
|
sinapore = "sig",
|
||||||
|
}
|
||||||
105
hetzner-pulumi/resources/utils.ts
Normal file
105
hetzner-pulumi/resources/utils.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
|
import { z } from "zod";
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Region abstraction exposed to users
|
||||||
|
*/
|
||||||
|
export type PricingRegion = "eu" | "us" | "ap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hetzner region → locations mapping
|
||||||
|
*/
|
||||||
|
const regionToLocations: Record<PricingRegion, string[]> = {
|
||||||
|
eu: ["nbg1", "fsn1", "hel1"],
|
||||||
|
us: ["ash", "hil"],
|
||||||
|
ap: ["sin"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const HCLOUD_API = "https://api.hetzner.cloud/v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime validation for Hetzner /server_types response
|
||||||
|
*/
|
||||||
|
const serverTypesResponseSchema = z.object({
|
||||||
|
server_types: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
deprecated: z.boolean().optional(),
|
||||||
|
prices: z.array(
|
||||||
|
z.object({
|
||||||
|
location: z.string(),
|
||||||
|
price_monthly: z.object({
|
||||||
|
gross: z.string(),
|
||||||
|
}),
|
||||||
|
price_hourly: z.object({
|
||||||
|
gross: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cheapest available server type name
|
||||||
|
* for a given abstract region (eu | us | ap).
|
||||||
|
*
|
||||||
|
* Pricing basis: monthly gross
|
||||||
|
*/
|
||||||
|
export function getCheapestServerType(
|
||||||
|
region: PricingRegion,
|
||||||
|
): pulumi.Output<string> {
|
||||||
|
const locations = regionToLocations[region];
|
||||||
|
const hcloudCfg = new pulumi.Config("hcloud");
|
||||||
|
const token = hcloudCfg.requireSecret("token");
|
||||||
|
|
||||||
|
return pulumi.all([token]).apply(async ([t]) => {
|
||||||
|
const res = await fetch(`${HCLOUD_API}/server_types`, {
|
||||||
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new pulumi.RunError(
|
||||||
|
`Hetzner API error: ${res.status} ${res.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const parsed = serverTypesResponseSchema.safeParse(json);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(JSON.stringify(json))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 12);
|
||||||
|
|
||||||
|
throw new pulumi.RunError(
|
||||||
|
`Unexpected Hetzner /server_types payload (sha256:${hash})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cheapest = parsed.data.server_types
|
||||||
|
.filter((st) => st.deprecated !== true)
|
||||||
|
.flatMap((st) =>
|
||||||
|
st.prices
|
||||||
|
.filter((p) => locations.includes(p.location))
|
||||||
|
.map((p) => ({
|
||||||
|
name: st.name,
|
||||||
|
price: Number.parseFloat(p.price_hourly.gross),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.filter((x) => Number.isFinite(x.price))
|
||||||
|
.sort((a, b) => a.price - b.price)[0];
|
||||||
|
|
||||||
|
if (!cheapest) {
|
||||||
|
throw new pulumi.RunError(
|
||||||
|
`No priced server types found for region=${region}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cheapest.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
18
hetzner-pulumi/tsconfig.json
Normal file
18
hetzner-pulumi/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "bin",
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"pretty": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
1917
hetzner-pulumi/yarn.lock
Normal file
1917
hetzner-pulumi/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user