Initial Pulumi proxmox-infra setup with Gitea Actions
Deploy Proxmox Infra / Pulumi Preview (push) Has been skipped
Deploy Proxmox Infra / Pulumi Deploy (push) Failing after 55s

This commit is contained in:
2026-05-27 20:26:52 +02:00
commit 57dc56c690
10 changed files with 3154 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Copy this file to .env.local and fill in your values.
# .env.local is gitignored and never committed.
# Gitea HTTP API base URL for this repo (no trailing slash)
# Example: http://192.168.1.208:3000/api/v1/repos/kasun/homelab-infrastructure-as-code
GITEA_API_URL=http://<your-gitea-host>/api/v1/repos/<owner>/<repo>
# Gitea personal access token with read/write Actions Secrets permission
# Generate at: Gitea → Settings → Applications → Access Tokens
GITEA_TOKEN=<your-gitea-token>
@@ -0,0 +1,76 @@
name: Deploy Proxmox Infra
on:
push:
branches:
- main
paths:
- 'proxmox-infra/**'
pull_request:
branches:
- main
paths:
- 'proxmox-infra/**'
jobs:
preview:
name: Pulumi Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Restore Stack Config
run: echo "${{ secrets.PULUMI_DEV_YAML }}" | base64 -d > proxmox-infra/Pulumi.dev.yaml
- name: Preview
uses: pulumi/actions@v5
with:
command: preview
stack-name: dev
work-dir: proxmox-infra
cloud-url: ${{ secrets.PULUMI_BACKEND_URL }}
env:
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
deploy:
name: Pulumi Deploy
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Restore Stack Config
run: echo "${{ secrets.PULUMI_DEV_YAML }}" | base64 -d > proxmox-infra/Pulumi.dev.yaml
- name: Refresh State
uses: pulumi/actions@v5
with:
command: refresh
stack-name: dev
work-dir: proxmox-infra
cloud-url: ${{ secrets.PULUMI_BACKEND_URL }}
env:
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
- name: Deploy
uses: pulumi/actions@v5
with:
command: up
stack-name: dev
work-dir: proxmox-infra
cloud-url: ${{ secrets.PULUMI_BACKEND_URL }}
env:
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -e
YAML_FILE="proxmox-infra/Pulumi.dev.yaml"
# Load local env if present
if [ -f .env.local ]; then
export $(grep -v '^#' .env.local | xargs)
fi
if [ ! -f "$YAML_FILE" ]; then
echo "[pre-push] $YAML_FILE not found — skipping Gitea secret sync"
exit 0
fi
if [ -z "$GITEA_API_URL" ] || [ -z "$GITEA_TOKEN" ]; then
echo "[pre-push] GITEA_API_URL or GITEA_TOKEN not set — skipping Gitea secret sync"
echo "[pre-push] Add these to .env.local to enable automatic sync"
exit 0
fi
ENCODED=$(base64 -w 0 "$YAML_FILE")
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"data\": \"$ENCODED\"}" \
"$GITEA_API_URL/actions/secrets/PULUMI_DEV_YAML")
if [ "$HTTP_STATUS" = "201" ] || [ "$HTTP_STATUS" = "204" ]; then
echo "[pre-push] Gitea secret PULUMI_DEV_YAML updated"
else
echo "[pre-push] Failed to update Gitea secret (HTTP $HTTP_STATUS)"
echo "[pre-push] Check GITEA_API_URL and GITEA_TOKEN in .env.local"
exit 1
fi
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
.idea/
.vscode
node_modules/
bin/
Pulumi.dev.yaml
.env.local
+70
View File
@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Pulumi TypeScript project (`proxmox-infra`) for provisioning VMs and LXC containers in Proxmox using the `@muhlba91/pulumi-proxmoxve` provider. The stack name is `dev`.
## Common Commands
```bash
# Install dependencies
npm install
# Preview infrastructure changes
pulumi preview
# Sync Pulumi state with actual Proxmox state (run before up if resources were changed manually)
pulumi refresh --yes
# Deploy infrastructure
pulumi refresh --yes && pulumi up --yes
# Destroy infrastructure
pulumi destroy
# View current stack outputs
pulumi stack output
# View stack config
pulumi config
```
## Architecture
- **Entry point**: `index.ts` — all Pulumi resources are declared here
- **Provider**: `@muhlba91/pulumi-proxmoxve` v8.x — community Proxmox provider (not an official Pulumi provider)
- **Stack**: `dev` — configured in `Pulumi.dev.yaml`
- **Runtime**: Node.js with `npm`, TypeScript compiled to `bin/` (excluded from git)
## CI/CD (Gitea Actions)
Workflow file: `../.gitea/workflows/deploy-proxmox-infra.yaml`
- **Pull request** → `pulumi preview` (no changes deployed)
- **Push to main** → `pulumi refresh` then `pulumi up`
Secrets required in Gitea (`Settings → Actions → Secrets`):
- `PULUMI_BACKEND_URL` — PostgreSQL connection string for the self-hosted state backend
- `PULUMI_CONFIG_PASSPHRASE` — passphrase used to decrypt secrets in `Pulumi.dev.yaml`
- `PULUMI_DEV_YAML` — base64-encoded content of `Pulumi.dev.yaml` (auto-synced by pre-push hook)
## Local Setup (one-time)
1. Copy `.env.local.example` to `.env.local` and fill in your Gitea API URL and token
2. Register the git hook so it runs automatically on push:
```bash
git config core.hooksPath .githooks
```
3. Generate a Gitea personal access token at: Gitea → Settings → Applications → Access Tokens (needs read/write Actions Secrets permission)
After this, every `git push` automatically encodes `Pulumi.dev.yaml` and updates the `PULUMI_DEV_YAML` Gitea secret.
## Key Notes
- Credentials for both Proxmox nodes are stored as encrypted secrets in `Pulumi.dev.yaml` and decrypted at runtime using `PULUMI_CONFIG_PASSPHRASE`. Do not pass Proxmox credentials via environment variables — the code uses `config.requireSecret()`.
- There are two Proxmox providers: `pveProvider` (main node `pve`) and `pveBckpProvider` (backup node `pve-bckp`). Always pass the correct provider when adding resources.
- `Pulumi.dev.yaml` contains the encryption salt — never delete it or secrets become unrecoverable.
- If a Proxmox resource was changed outside of Pulumi, run `pulumi refresh` before `pulumi up` to avoid state drift conflicts.
- TypeScript is compiled with strict mode, `nodenext` module resolution, and `noImplicitReturns` — all functions must have explicit return types when TypeScript cannot infer them.
+10
View File
@@ -0,0 +1,10 @@
name: proxmox-infra
description: Setting up VM and LXC in Proxmox
runtime:
name: nodejs
options:
packagemanager: npm
config:
pulumi:tags:
value:
pulumi:template: typescript
+246
View File
@@ -0,0 +1,246 @@
import * as pulumi from "@pulumi/pulumi";
import * as proxmox from "@muhlba91/pulumi-proxmoxve";
const config = new pulumi.Config();
// ---------------------------------------------------------------------------
// Providers — one per standalone Proxmox machine
// ---------------------------------------------------------------------------
const pveProvider = new proxmox.Provider("pve", {
endpoint: config.requireSecret("pve1Endpoint"),
apiToken: config.requireSecret("pve1ApiToken"),
insecure: true,
});
const pveBckpProvider = new proxmox.Provider("pve-bckp", {
endpoint: config.requireSecret("pve2Endpoint"),
apiToken: config.requireSecret("pve2ApiToken"),
insecure: true,
});
// ---------------------------------------------------------------------------
// Download Ubuntu Noble cloud image to each node's ISO storage
// ---------------------------------------------------------------------------
const ubuntuNobleUrl =
"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img";
const ubuntuImagePve = new proxmox.download.File(
"ubuntu-noble-pve",
{
nodeName: "pve",
datastoreId: "pve-local-ext1",
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: ubuntuNobleUrl,
overwrite: true,
overwriteUnmanaged: true,
},
{ provider: pveProvider },
);
const ubuntuImagePveBckp = new proxmox.download.File(
"ubuntu-noble-pve-bckp",
{
nodeName: "pve-bckp",
datastoreId: "local",
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: ubuntuNobleUrl,
overwrite: true,
overwriteUnmanaged: true,
},
{ provider: pveBckpProvider },
);
// ---------------------------------------------------------------------------
// VM templates — one per node, cloned from the downloaded cloud image.
// Templates are not started and have no cloud-init config; that is applied
// per-clone so each node gets its own hostname (derived from VM name).
// ---------------------------------------------------------------------------
const templateSettings = {
template: true,
started: false,
stopOnDestroy: true,
scsiHardware: "virtio-scsi-pci",
cpu: {
cores: 2,
sockets: 1,
type: "host",
numa: true,
},
memory: {
dedicated: 2048,
floating: 0,
},
networkDevices: [{ bridge: "vmbr0", model: "virtio" }],
serialDevices: [{}],
vga: { type: "serial0" },
agent: { enabled: false },
};
const pveTemplate = new proxmox.VmLegacy(
"k3s-template-pve",
{
...templateSettings,
nodeName: "pve",
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: "local-lvm",
importFrom: pulumi.interpolate`${ubuntuImagePve.datastoreId}:import/${ubuntuImagePve.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: pveProvider },
);
const pveBckpTemplate = new proxmox.VmLegacy(
"k3s-template-pve-bckp",
{
...templateSettings,
nodeName: "pve-bckp",
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: "local",
importFrom: pulumi.interpolate`${ubuntuImagePveBckp.datastoreId}:import/${ubuntuImagePveBckp.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: pveBckpProvider },
);
// ---------------------------------------------------------------------------
// k3s nodes — full clones of their respective templates
// ---------------------------------------------------------------------------
const k3sVmPassword = config.requireSecret("k3sVmPassword");
const sshPvePublicKey = config.requireSecret("sshPvePublicKey");
interface NodeConfig {
name: string;
role: "master" | "worker";
nodeName: string;
provider: proxmox.Provider;
template: proxmox.VmLegacy;
diskDatastore: string;
}
const nodeConfigs: NodeConfig[] = [
{
name: "k3s-master-1",
role: "master",
nodeName: "pve",
provider: pveProvider,
template: pveTemplate,
diskDatastore: "local-lvm",
},
{
name: "k3s-master-2",
role: "master",
nodeName: "pve",
provider: pveProvider,
template: pveTemplate,
diskDatastore: "local-lvm",
},
{
name: "k3s-worker-1",
role: "worker",
nodeName: "pve",
provider: pveProvider,
template: pveTemplate,
diskDatastore: "local-lvm",
},
{
name: "k3s-master-3",
role: "master",
nodeName: "pve-bckp",
provider: pveBckpProvider,
template: pveBckpTemplate,
diskDatastore: "local",
},
{
name: "k3s-worker-2",
role: "worker",
nodeName: "pve-bckp",
provider: pveBckpProvider,
template: pveBckpTemplate,
diskDatastore: "local",
},
];
const k3sVms = nodeConfigs.map(
(node) =>
new proxmox.VmLegacy(
node.name,
{
nodeName: node.nodeName,
name: node.name,
description: "k3s " + node.role + " node — managed by Pulumi",
tags: ["k3s", node.role],
clone: {
vmId: node.template.vmId,
full: true,
datastoreId: node.diskDatastore,
},
cpu: {
cores: 2,
sockets: 1,
type: "host",
numa: true,
},
memory: {
dedicated: 2048,
floating: 0,
},
disks: [
{
interface: "scsi0",
datastoreId: node.diskDatastore,
size: 10,
ssd: true,
discard: "on",
},
],
initialization: {
datastoreId: node.diskDatastore,
ipConfigs: [{ ipv4: { address: "dhcp" } }],
userAccount: {
username: "ubuntu",
password: k3sVmPassword,
keys: [sshPvePublicKey.apply((k) => k.trim())],
},
},
networkDevices: [{ bridge: "vmbr0", model: "virtio" }],
scsiHardware: "virtio-scsi-pci",
serialDevices: [{}],
vga: { type: "serial0" },
agent: { enabled: false },
started: false,
stopOnDestroy: true,
},
{
provider: node.provider,
retainOnDelete: true,
ignoreChanges: ["clone"],
},
),
);
export const clusterInfo = k3sVms.map((vm, index) => ({
nodeName: vm.nodeName,
vmId: vm.vmId,
name: nodeConfigs[index].name,
role: nodeConfigs[index].role,
}));
+2669
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "proxmox-infra",
"main": "index.ts",
"devDependencies": {
"@types/node": "^18",
"typescript": "^5.0.0"
},
"dependencies": {
"@muhlba91/pulumi-proxmoxve": "^8.2.1",
"@pulumi/pulumi": "^3.113.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2020",
"module": "nodenext",
"moduleResolution": "nodenext",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}