Compare commits

...

5 Commits

32 changed files with 435 additions and 238 deletions

View File

@@ -2,20 +2,100 @@
Defines code which describes a HA & cached scalable way of serving web applications.
## Architecture
```
+-----------------------------------------------------------+
| REGION: EU |
| |
| +-------------- Floating IP ---------+ |
| | | |
| +----+---------+ +----+---------+ |
| | HAProxy #1 | | HAProxy #2 | |
| +----+---------+ +----+---------+ |
| \__________ active / standby _______/ |
| | |
| v |
| +------+--------+ |
| | haproxy (a) | |
| +----+----+--+--+ |
| | | A |
| direct | | | via cache |
| | v | |
| | +-+--+---------+ |
| | | varnish (n) | |
| | +------+-------+ |
| | | HIT / MISS |
| | | |
| +---------+ |
| | |
| v |
| +---------+-------+ |
| | web server (n) | |
| +-----------------+ |
| |
+-----------------------------------------------------------+
```
Where varnish & web server is 2-n number of instances. Currently two regions, EU & US.
## infrastructure
Configured cloud resources in hezner with Pulumi.
Hetzner has two regions:
- us
- eu
```bash
# first time, init pulumi stack (name optional)
pulumi stack init kevinmidboe/hetzner
Each region has:
- haproxy x2
- varnish x2
- webservers
# required configuration values
pulumi config set sshPublicKey "$(cat ~/.ssh/id_ed25519.pub)"
pulumi config set --secret hcloud:token $HETZNER_API_KEY
# up infrastructure
pulumi up
# (optional w/ adding private IP)
# private ips struggle, need to run again to assign correctly
pulumi up
```
## provision
Ansible is used to provision software and environments for different software needed.
Ansible is used to provision software and environments for software needed and services.
get ansible configuration values from pulumi output:
```bash
# generate inventory (manualy update inventory file)
./scripts/generate-inventory.sh | pbcopy
# following updates config files in place
./scripts/update-config_certbot-domains.sh
./scripts/update-config_webserver-ips.sh
```
run playbooks:
```bash
# install, configure & start haproxy
ansible-playbook plays/haproxy.yml -i hetzner.ini -l haproxy
# install, configure & start varnish
ansible-playbook plays/varnish.yml -i hetzner.ini -l varnish
# install web resources & dependencies, pull & starts docker containers
ansible-playbook plays/docker.yml -i hetzner.ini -l web
ansible-playbook plays/web.yml -i hetzner.ini -l web
```
# Manual steps
- [x] floating ip DNS registration
- [x] extract variables from pulumi stack outputs
- [ ] add all cloudflare api keys
- `mkdir /root/.ssh/certbot/cloudflare_k9e-no.ini`
- [ ] generate certs for appropriate domains
- `certbot certonly --agree-tos --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare_k9e-no.ini -d k9e.no`
- [ ] combine generated certs into a cert for traefik
- `cat /etc/letsencrypt/live/k9e.no/fullchain.pem /etc/letsencrypt/live/k9e.no/privkey.pem > /etc/haproxy/certs/ssl-k9e.no.pem`

View File

@@ -1,6 +0,0 @@
---
# CI specific vars
users:
- root
ssh_keys_users: ['drone']

View File

@@ -1,5 +0,0 @@
---
# Consul server specific
consul_is_server: true
consul_is_ui: true
consul_bootstrap_expect: 1

View File

@@ -6,12 +6,3 @@ dns_nameservers:
- "2606:4700:4700::1001"
default_user: "kevin"
# Consul cluster
consul_datacenter: "schleppe"
consul_servers:
- "10.0.0.140"
- "10.0.0.141"
- "10.0.0.142"
consul_install_dnsmasq: false

View File

@@ -1,6 +0,0 @@
---
# python path
ansible_python_interpreter: /usr/local/bin/python3
users:
- kevin

View File

@@ -1,6 +0,0 @@
---
apt_packages:
- git
- build-essential
- openjdk-21-jdk
minecraft_version: 1.20.6

View File

@@ -1,2 +0,0 @@
---
proxmox_install_qemu_guest_agent: true

View File

@@ -1,2 +0,0 @@
---
ssh_keys_users: ['kevin', 'kasper']

View File

@@ -1,16 +0,0 @@
---
- name: Check if vault is reachable for dynamic config
hosts: all
connection: local
gather_facts: false
pre_tasks:
- name: Check for vault env variables
set_fact:
has_vault: "{{ lookup('env', 'VAULT_ADDR') and lookup('env', 'VAULT_TOKEN') and lookup('env', 'HAS_VAULT') != 'FALSE' }}"
roles:
- { role: roles/vault-config, when: has_vault }
- name: Install all bind9 service and transfer zone files
hosts: all
roles:
- role: roles/bind9

View File

@@ -1,5 +0,0 @@
---
- name: Consul
hosts: all
roles:
- role: roles/consul

View File

@@ -1,5 +0,0 @@
---
- name: Provision git server with gitea
hosts: all
roles:
- role: roles/gitea

View File

@@ -1,7 +0,0 @@
---
- name: Install and setup immich backup service
hosts: all
roles:
# - role: roles/docker
- role: roles/immich

View File

@@ -1,7 +0,0 @@
---
- name: Setup minecraft requirements w/ latest server jar
hosts: all
roles:
- role: roles/apt
- role: roles/minecraft

View File

@@ -1,19 +0,0 @@
---
- name: Check if vault is reachable for dynamic config
hosts: all
connection: local
gather_facts: false
pre_tasks:
- name: Check for vault env variables
set_fact:
has_vault: "{{ lookup('env', 'VAULT_ADDR') and lookup('env', 'VAULT_TOKEN') }}"
TELEGRAF_TOKEN: "{{ lookup('env', 'TELEGRAF_TOKEN') }}"
roles:
- { role: roles/vault-config, when: has_vault }
- name: Basic setup for proxmox vm clients
hosts: proxmox_nodes
roles:
# - role: roles/prox-telegraf-metrics
- role: roles/prox-templates

View File

@@ -1,5 +0,0 @@
---
- name: Install & configure syncthing
hosts: all
roles:
- role: roles/syncthing

View File

@@ -1,6 +0,0 @@
---
- name: Install traefik binary & config
hosts: all
roles:
- role: roles/traefik

View File

@@ -1,7 +0,0 @@
---
- name: Install all required packages, built and start service for vault
hosts: all
roles:
- role: roles/vault
- role: roles/firewall
enable_vault_ufw_port: true

View File

@@ -1,5 +0,0 @@
---
- name: Install all required packages, built and start service for vinlottis
hosts: all
roles:
- role: roles/vinlottis

6
ansible/plays/web.yml Normal file
View File

@@ -0,0 +1,6 @@
---
- name: copies docker-compose files to all web hosts
hosts: web
roles:
- role: roles/web

View File

@@ -1,6 +0,0 @@
- name: Setup wireguard
hosts: all
roles:
- role: roles/docker
- role: roles/firewall
- role: roles/wireguard

View File

@@ -1,21 +0,0 @@
---
- name: Check if vault is reachable for dynamic config
hosts: all
connection: local
gather_facts: false
pre_tasks:
- name: Check for vault env variables
set_fact:
has_vault: "{{ lookup('env', 'VAULT_ADDR') and lookup('env', 'VAULT_TOKEN') }}"
XWIKI_DB_USER: "{{ lookup('env', 'XWIKI_DB_USER') }}"
XWIKI_DB_PASSWORD: "{{ lookup('env', 'XWIKI_DB_PASSWORD') }}"
XWIKI_DB_ROOT_PASSWORD: "{{ lookup('env', 'XWIKI_DB_ROOT_PASSWORD') }}"
roles:
- { role: roles/vault-config, when: has_vault }
- name: Setup xwiki working directory and move docker-compose file
hosts: all
roles:
- role: roles/docker
- role: roles/firewall
- role: roles/xwiki

View File

@@ -0,0 +1,25 @@
- name: Ensure remote docker directory exists
ansible.builtin.file:
path: "{{ remote_compose_dir }}"
state: directory
owner: root
group: root
mode: '0755'
- name: Copy local docker-compose directory to remote
ansible.builtin.copy:
src: "{{ local_compose_dir }}/"
dest: "{{ remote_compose_dir }}/"
owner: root
group: root
mode: '0644'
- name: Ensure compose-all.sh is executable
ansible.builtin.file:
path: "{{ remote_compose_dir }}/compose-all.sh"
mode: '0755'
- name: Run compose-all.sh up
ansible.builtin.command: bash compose-all.sh up
args:
chdir: "{{ remote_compose_dir }}"

View File

@@ -0,0 +1,3 @@
local_compose_dir: "{{ playbook_dir }}/../../docker-compose"
remote_compose_dir: /opt/docker

View File

@@ -16,7 +16,7 @@ services:
- "traefik.enable=true"
# Router definition
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
- "traefik.http.routers.whoami.rule=Host(`whoami.schleppe.cloud`)"
- "traefik.http.routers.whoami.entrypoints=web"
# Service definition

View File

@@ -2,9 +2,19 @@ import {
subNetwork,
regionalNetwork,
allowHttp,
allowSSH,
allowSSHToCurrentIP,
floatingIP,
attach,
} from "./resources/network";
import { server } from "./resources/compute";
import { dns } from "./resources/cloudflare";
import {
summarizeServer,
summarizeNetwork,
summarizeSubNetwork,
summarizeFloatingIp,
summarizeFirewall,
} from "./resources/utils";
import {
VmSize,
@@ -15,15 +25,19 @@ import {
} from "./resources/types";
// regional vnet
const eu = regionalNetwork("ha", "10.24.0.0/18", NetworkRegion.eu);
const usEast = regionalNetwork("ha", "10.25.0.0/18", NetworkRegion.usEast);
const eu = regionalNetwork("ha-net-eu", "10.24.0.0/18", NetworkRegion.eu);
const usEast = regionalNetwork(
"ha-net-us",
"10.25.0.0/18",
NetworkRegion.usEast,
);
// subnets for reginal vnets
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"),
lb: subNetwork(eu, NetworkRole.lb, NetworkRegion.eu, "10.24.1.0/26"),
cache: subNetwork(eu, NetworkRole.cache, NetworkRegion.eu, "10.24.2.0/26"),
web: subNetwork(eu, NetworkRole.web, NetworkRegion.eu, "10.24.3.0/26"),
// db: subNetwork(eu, NetworkRole.db, "10.24.4.0/24")
},
usEast: {
@@ -31,26 +45,26 @@ const network = {
usEast,
NetworkRole.lb,
NetworkRegion.usEast,
"10.25.1.0/24",
"10.25.1.0/26",
),
cache: subNetwork(
usEast,
NetworkRole.cache,
NetworkRegion.usEast,
"10.25.2.0/24",
"10.25.2.0/26",
),
web: subNetwork(
usEast,
NetworkRole.web,
NetworkRegion.usEast,
"10.25.3.0/24",
"10.25.3.0/26",
),
},
};
// variable un-maps
const hel1 = ServerLocations.helsinki;
const hil = ServerLocations.hillsboro;
const nbg = ServerLocations.nuremberg;
const ash = ServerLocations.ashburn;
const [EU_LB, US_LB, EU_CACHE, US_CACHE, EU_WEB, US_WEB] = [
network.eu.lb,
network.usEast.lb,
@@ -61,32 +75,67 @@ const [EU_LB, US_LB, EU_CACHE, US_CACHE, EU_WEB, US_WEB] = [
];
// compute - server resources
const haEU1 = server("haproxy-1", VmSize.small, OS.debian, hel1, EU_LB);
const haEU2 = server("haproxy-2", VmSize.small, OS.debian, hel1, EU_LB);
const haUS1 = server("haproxy-1", VmSize.small, OS.debian, hil, US_LB);
// const haUS2 = server("haproxy-2", VmSize.small, OS.debian, hil, US_LB);
const haEU1 = server("haproxy-1", VmSize.cx23, OS.debian, nbg, EU_LB, true);
const haEU2 = server("haproxy-2", VmSize.cx23, OS.debian, nbg, EU_LB, true);
const haUS1 = server("haproxy-1", VmSize.cpx11, OS.debian, ash, US_LB, true);
const haUS2 = server("haproxy-2", VmSize.cpx11, OS.debian, ash, US_LB, true);
const cacheEU1 = server("varnish-1", VmSize.small, OS.debian, hel1, EU_CACHE);
const cacheEU2 = server("varnish-2", VmSize.small, OS.debian, hil, EU_CACHE);
// const cacheUS1 = server("varnish-1", VmSize.small, OS.debian, hil, US_CACHE);
// const cacheUS2 = server("varnish-2", VmSize.small, OS.debian, hil, US_CACHE);
const cacheEU1 = server("varnish-1", VmSize.cx23, OS.debian, nbg, EU_CACHE);
const cacheEU2 = server("varnish-2", VmSize.cx23, OS.debian, nbg, EU_CACHE);
const cacheUS1 = server("varnish-1", VmSize.cpx11, OS.debian, ash, US_CACHE);
const cacheUS2 = server("varnish-2", VmSize.cpx11, OS.debian, ash, US_CACHE);
const webEU1 = server("web-1", VmSize.small, OS.debian, hel1, EU_WEB);
// const webEU2 = server("web-2", VmSize.small, OS.debian, hel1, EU_WEB);
// const webUS1 = server("web-1", VmSize.small, OS.debian, hil, US_WEB);
const webEU1 = server("web-1", VmSize.cx23, OS.debian, nbg, EU_WEB);
const webEU2 = server("web-2", VmSize.cx23, OS.debian, nbg, EU_WEB);
const webUS1 = server("web-1", VmSize.cpx11, OS.debian, ash, US_WEB);
// firewall & exports
export const firewalls = [allowHttp, allowSSH];
// floating IPs
const euFloatingIP = floatingIP("schleppe-ha-nbg", haEU1);
const usFloatingIP = floatingIP("schleppe-ha-va", haUS1);
const floatingIPs = [euFloatingIP, usFloatingIP];
const domains = ["k9e.no", "planetposen.no", "whoami.schleppe.cloud"];
// exports contd.
export const servers = [haEU1, haEU2, haUS1, cacheEU1, cacheEU2, webEU1];
// Update Cloudflare DNS
domains.forEach((domain) => {
dns(domain, euFloatingIP, "eu-fip");
dns(domain, usFloatingIP, "us-fip");
});
export const networks = [
eu,
usEast,
// firewall
const allowSSH = allowSSHToCurrentIP();
const firewalls = [allowHttp, allowSSH];
// DISABLED
attach("ssh-fa", allowSSH, [haEU1, haEU2, haUS1, haUS2]);
// exports
const servers = [
haEU1,
haEU2,
haUS1,
haUS2,
cacheEU1,
cacheEU2,
cacheUS1,
cacheUS2,
webEU1,
webEU2,
webUS1,
];
const networks = [eu, usEast];
const subNetworks = [
network.eu.lb,
network.eu.cache,
network.eu.web,
network.usEast.lb,
network.usEast.web,
];
export const inventory = {
vms: servers.map(summarizeServer),
networks: networks.map(summarizeNetwork),
subnetworks: subNetworks.map(summarizeSubNetwork),
firewalls: firewalls.map(summarizeFirewall),
floatingIps: floatingIPs.map(summarizeFloatingIp),
domains,
};

View File

@@ -6,6 +6,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@pulumi/cloudflare": "^6.12.0",
"@pulumi/hcloud": "^1.29.0",
"@pulumi/pulumi": "^3.213.0",
"@pulumi/random": "^4.18.4",

View File

@@ -0,0 +1,44 @@
import * as hcloud from "@pulumi/hcloud";
import * as cloudflare from "@pulumi/cloudflare";
async function getZone(domain: string): Promise<cloudflare.Zone | null> {
let match;
const zones = await cloudflare.getZones();
zones.results.forEach((zone) => {
if (domain.includes(zone.name)) match = zone;
});
if (match) return match;
return null;
}
export async function dns(
domain: string,
ipAddress: hcloud.FloatingIp,
suffix: string,
) {
const ip = ipAddress.ipAddress.apply((ip) => ip);
const name = `${domain}-${suffix}_dns_record`;
const comment = "managed by pulumi - schleppe-ha-project";
const zone = await getZone(domain);
if (!zone)
throw new Error(
"no matching zone found! check cloudflare token scopes & registration",
);
return new cloudflare.DnsRecord(
name,
{
zoneId: zone.id,
name: domain,
ttl: 1,
type: "A",
content: ip,
proxied: false,
comment,
},
{ dependsOn: [ipAddress] },
);
}

View File

@@ -1,51 +1,86 @@
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 { config } from "./config";
import { getCheapestServerType, topicedLabel } 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(),
managed: "pulumi",
};
const sshPublicKey = config.require("sshPublicKey");
const sshKey = new hcloud.SshKey("ssh-key", {
name: `pulumi-${pulumi.getStack()}-ssh`,
publicKey: sshPublicKey,
});
const sshKey = new hcloud.SshKey("ssh-key", {
name: `pulumi-${pulumi.getStack()}-ssh`,
publicKey: sshPublicKey,
});
const serverName = (name: string, location: string) => {
if (name.includes("-")) {
const [n, id] = name.split("-");
return `${n}-${location}-${id}`;
}
return `${name}-${location}`;
};
export function server(
name: string,
size: VmSize,
os: OS = OS.debian,
location: ServerLocations,
network: hcloud.NetworkSubnet
network: hcloud.NetworkSubnet,
ipv4: boolean = false,
): hcloud.Server {
const ceap = getCheapestServerType('eu');
const extraLabel = topicedLabel(name)
name = serverName(name, location);
const networkId = network.networkId.apply((id) => String(id).split("-")[0]);
const hexId = new random.RandomId(`${name}-${location}`, {
byteLength: 2, // 2 bytes = 4 hex characters
});
name = `${name}-${location}`
return new hcloud.Server(name, {
const server = new hcloud.Server(
name,
image: os,
serverType: ceap,
location,
backups: false,
publicNets: [{
ipv4Enabled: false,
ipv6Enabled: true,
}],
networks: [network],
sshKeys: [sshKey.name],
labels: serverLabels
})
{
name,
image: os,
serverType: size,
location,
backups: false,
publicNets: [
{
ipv4Enabled: ipv4,
ipv6Enabled: true,
},
],
networks: [
{
networkId: networkId.apply((nid) => Number(nid)),
},
],
sshKeys: [sshKey.name],
labels: {
...serverLabels,
...extraLabel
},
},
{ dependsOn: [network] },
);
const serverNet = new hcloud.ServerNetwork(
`${name}-servernet-${location}`,
{
serverId: server.id.apply((id) => Number(id)),
subnetId: network.id,
},
{
dependsOn: [network, server],
parent: server,
deleteBeforeReplace: true,
ignoreChanges: [ 'serverId', 'ip', 'aliasIps', 'networkId', 'subnetId' ]
},
);
return server;
}

View File

@@ -2,24 +2,22 @@ import * as pulumi from "@pulumi/pulumi";
import * as hcloud from "@pulumi/hcloud";
import type { NetworkRegion } from "./types";
import { currentIPAddress } from "./utils";
// Required
// make sure to have regional parent networks
// NETWORKS
const networkName = (name: string, region: NetworkRegion) =>
`${name}-net-${region}`;
export function regionalNetwork(
prefix: string,
name: string,
cidr: string,
region: NetworkRegion,
) {
const name = networkName(prefix, region);
const parentNetworkRange = 8;
const parentNetworkRange = 22;
const [ip, _] = cidr.split("/");
const net = new hcloud.Network(name, {
name,
ipRange: `${ip}/${parentNetworkRange}`,
labels: {
region,
@@ -38,16 +36,33 @@ export function subNetwork(
): 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,
});
const net = new hcloud.NetworkSubnet(
name,
{
networkId: parentNetwork.id.apply((id) => Number(id)),
type: "cloud",
networkZone: region,
ipRange: cidr,
},
{ parent: parentNetwork, dependsOn: [parentNetwork] },
);
return net;
}
// FLOATING IPs
export function floatingIP(name: string, server: hcloud.Server) {
return new hcloud.FloatingIp(
name,
{
type: "ipv4",
serverId: server.id.apply((i) => Number(i)),
},
{ dependsOn: [server] },
);
}
// FIREWALL RULES
export const allowHttp = new hcloud.Firewall("allow-http", {
name: "allow-http",
applyTos: [
@@ -80,16 +95,30 @@ export const allowHttp = new hcloud.Firewall("allow-http", {
],
});
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",
},
],
});
export function allowSSHToCurrentIP() {
const ip = currentIPAddress()
return new hcloud.Firewall("allow-ssh", {
name: "allow-ssh",
rules: [
{
direction: "in",
protocol: "tcp",
port: "22",
sourceIps: [ip],
description: "Allow SSH from approved CIDRs only",
},
],
});
}
export function attach(
name: string,
firewall: hcloud.Firewall,
servers: hcloud.Server[],
) {
return new hcloud.FirewallAttachment(name, {
firewallId: firewall.id.apply((id) => Number(id)),
serverIds: servers.map((server) => server.id.apply((id) => Number(id))),
});
}

View File

@@ -2,10 +2,13 @@ export enum VmSize {
small = "small",
medium = "medium",
large = "large",
cx23 = "cx23",
cax11 = "cax11",
cpx11 = "cpx11"
}
export enum OS {
debian = "debian",
debian = "debian-13",
ubuntu = "ubuntu",
}

View File

@@ -1,4 +1,5 @@
import * as pulumi from "@pulumi/pulumi";
import * as hcloud from "@pulumi/hcloud";
import { z } from "zod";
import * as crypto from "node:crypto";
@@ -103,3 +104,62 @@ export function getCheapestServerType(
});
}
interface Label {
role?: string
}
export function topicedLabel(name: string) {
let labels: Label = {};
if (name.includes("haproxy")) {
labels.role = 'load-balancer';
} else if (name.includes("web")) {
labels.role = 'web'
}
return labels
}
export const summarizeServer = (s: hcloud.Server) => ({
name: s.name,
publicIpv4: s.ipv4Address,
publicIpv6: s.ipv6Address,
privateIp: s.networks.apply(nets => nets?.[0]?.ip ?? 'null'),
});
export const summarizeNetwork = (n: hcloud.Network) => ({
name: n.name,
cidr: n.ipRange
});
export const summarizeSubNetwork = (n: hcloud.NetworkSubnet) => ({
gateway: n.gateway,
cidr: n.ipRange,
zone: n.networkZone,
type: n.type
});
export const summarizeFloatingIp = (floatingIp: hcloud.FloatingIp) => ({
name: floatingIp.name,
address: floatingIp.ipAddress,
attachedTo: floatingIp.serverId,
location: floatingIp.homeLocation,
labels: floatingIp.labels
})
export const summarizeFirewall = (firewall: hcloud.Firewall) => ({
name: firewall.name,
rules: firewall.rules,
labels: firewall.labels
})
export const summarizeDns = (firewall: hcloud.Firewall) => ({
name: firewall.name,
rules: firewall.rules,
labels: firewall.labels
})
export async function currentIPAddress(): Promise<string> {
return fetch('https://ifconfig.me/ip')
.then(resp => resp.text())
}

View File

@@ -369,6 +369,13 @@
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
"@pulumi/cloudflare@^6.12.0":
version "6.12.0"
resolved "https://registry.yarnpkg.com/@pulumi/cloudflare/-/cloudflare-6.12.0.tgz#edb2c59d1b594a0c64f12ba8cf63b84ed3937fcc"
integrity sha512-Evhj9u+ZVCO6Cu9+xon4F3dY4RA7U4ZTWc19MJAIa5GgLeG8X0C84iyo5aruZf2nK7nfJ2d47lyRSaXPBHOzjw==
dependencies:
"@pulumi/pulumi" "^3.142.0"
"@pulumi/hcloud@^1.29.0":
version "1.29.0"
resolved "https://registry.yarnpkg.com/@pulumi/hcloud/-/hcloud-1.29.0.tgz#4c622aa3ae9c4590af783eefbeee1e62e711c7f3"