feat: Add bpg/proxmox provider for bare-metal VM management (Step 4.5)
Some checks failed
PR Checks / tofu-checks (pull_request) Failing after 4s
1/1 projects applied successfully.

- Enable bpg/proxmox provider (~> 0.90) in production environment
- Add data source to verify Proxmox connectivity (read nodes)
- SOPS-encrypt Proxmox API token (root@pam!tofu)
- Custom Atlantis workflow: decrypt SOPS → inject PROXMOX_VE_API_TOKEN
- Update all OPA policies for bpg resource types:
  - proxmox_vm_qemu → proxmox_virtual_environment_vm
  - proxmox_lxc → proxmox_virtual_environment_container
  - Adjust field paths (cpu[0].cores, memory[0].dedicated, etc.)
  - Firewall check: per-network-device instead of top-level
  - Password check: via after_sensitive for cloud-init
  - Tags: list of strings instead of comma-separated
This commit is contained in:
Claude AI 2026-02-11 08:17:39 +01:00
parent f26e327de7
commit 5155f08584
8 changed files with 82 additions and 44 deletions

View File

@ -5,6 +5,7 @@ projects:
- name: production
dir: environments/production
workspace: default
workflow: proxmox
autoplan:
when_modified:
- "**/*.tf"
@ -12,3 +13,20 @@ projects:
enabled: true
apply_requirements:
- approved
workflows:
proxmox:
plan:
steps:
- env:
name: PROXMOX_VE_API_TOKEN
command: "sops -d --extract '[\"proxmox_api_token\"]' proxmox.secrets.yaml"
- init
- plan
apply:
steps:
- env:
name: PROXMOX_VE_API_TOKEN
command: "sops -d --extract '[\"proxmox_api_token\"]' proxmox.secrets.yaml"
- init
- apply

View File

@ -16,18 +16,26 @@ terraform {
use_path_style = true
}
# Proxmox provider will be added when bare-metal is connected
# required_providers {
# proxmox = {
# source = "bpg/proxmox"
# version = "~> 0.66"
# }
# }
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.90"
}
}
}
# Proxmox provider configuration (uncomment when ready)
# provider "proxmox" {
# endpoint = var.proxmox_endpoint
# api_token = var.proxmox_api_token
# insecure = true
# }
provider "proxmox" {
endpoint = "https://217.168.244.244:8006/"
insecure = true # self-signed cert
# api_token read from PROXMOX_VE_API_TOKEN env var
# Decrypted from SOPS by Atlantis custom workflow
}
# Verify Proxmox connectivity read cluster nodes
data "proxmox_virtual_environment_nodes" "nodes" {}
output "proxmox_nodes" {
description = "Proxmox cluster node names"
value = data.proxmox_virtual_environment_nodes.nodes.names
}

View File

@ -0,0 +1,16 @@
proxmox_api_token: ENC[AES256_GCM,data:Dg8+7TWwsaDuQ9JJPyWBI6pc+6n3tVbg3TsjMx8OIS6R00eVTD6o2rAF6CTyIvLN2MI=,iv:cPq5O1Fl2azbVQST0+piq/3yA0Br6OZhcmkl52p2f5Q=,tag:P/CHM/ufI2xm/W4pr91QIQ==,type:str]
sops:
age:
- recipient: age1yttnttdpafzn73mf3g8fw4x04444gymwsfrfm99fv9qkcxqzqs7sld8hln
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUU0MyOEhrWXE1K1V2aUEw
VFVkcHMzdnhTSUlhUjQ3b2UxYzhmdHQ5OUhVCkhHRHlFbzlhMkViRmxPTWZCUHJy
V3BsYUhmOVRYWEpHWkJrMFFyL1liL3cKLS0tIDB4NWVwN3NhUmoyZWp5Rnk4Yit0
VUdrSFVpT0FmTklybFpnOHJYbVdtbDgKzocwM5FdTxgbgL3oi344BH/2Z4oKWDN4
mzeExtxt+cg4KGvQXamQIzqwso4j9QrYpOB76EfWhLUL8ijGsdcWlQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-02-11T07:09:40Z"
mac: ENC[AES256_GCM,data:A89cdpQPFOH/x5PBSwdlv1SpupcSi2wp8DiRl6TNMOUDlQfP9d1ThQNE2a1lDG+H1NGDdP7josvERmZ+Y6IIh0QicyQutSizhZXDtPcNIiGBRHaI74g6Ed4TqSSgrbkZ253JGPvZqzcQOHUrfHykKJavYitHYMbQxwEUKTbamKM=,iv:PIg3H0T0IUgwDa6HjZLFghfxjUwF/8Km1x16cDlvnvQ=,tag:Oe8LU8q8lZDMI66xusZw7A==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0

View File

@ -1,13 +1,6 @@
# Variables for production environment
# Secrets are injected via SOPS or environment variables in Atlantis
# variable "proxmox_endpoint" {
# description = "Proxmox API endpoint URL"
# type = string
# }
# variable "proxmox_api_token" {
# description = "Proxmox API token (user@realm!token=secret)"
# type = string
# sensitive = true
# }
#
# Proxmox API credentials are injected via environment variables:
# PROXMOX_VE_API_TOKEN decrypted from SOPS by Atlantis workflow
#
# No explicit variables needed bpg/proxmox provider reads env vars.

View File

@ -5,25 +5,25 @@ import rego.v1
# VMs must not exceed 16 cores
deny contains msg if {
some resource in input.resource_changes
resource.type == "proxmox_vm_qemu"
resource.type == "proxmox_virtual_environment_vm"
resource.change.actions[_] in {"create", "update"}
to_number(resource.change.after.cores) > 16
to_number(resource.change.after.cpu[0].cores) > 16
msg := sprintf(
"COST: VM '%s' requests %v cores. Maximum 16. File ADR for exception.",
[resource.address, resource.change.after.cores],
[resource.address, resource.change.after.cpu[0].cores],
)
}
# VMs must not exceed 32 GB RAM
deny contains msg if {
some resource in input.resource_changes
resource.type == "proxmox_vm_qemu"
resource.type == "proxmox_virtual_environment_vm"
resource.change.actions[_] in {"create", "update"}
to_number(resource.change.after.memory) > 32768
to_number(resource.change.after.memory[0].dedicated) > 32768
msg := sprintf(
"COST: VM '%s' requests %v MB RAM. Maximum 32768 (32 GB). File ADR for exception.",
[resource.address, resource.change.after.memory],
[resource.address, resource.change.after.memory[0].dedicated],
)
}

View File

@ -7,7 +7,7 @@ deny contains msg if {
some resource in input.resource_changes
resource.change.actions[_] == "delete"
stateful := {"proxmox_vm_qemu", "proxmox_lxc", "docker_volume"}
stateful := {"proxmox_virtual_environment_vm", "proxmox_virtual_environment_container", "docker_volume"}
resource.type in stateful
msg := sprintf(
@ -22,7 +22,7 @@ deny contains msg if {
actions := resource.change.actions
"delete" in actions
"create" in actions
startswith(resource.type, "proxmox_vm")
startswith(resource.type, "proxmox_virtual_environment_vm")
msg := sprintf(
"BLOCKED: Resource '%s' will be REPLACED (destroy + recreate). Data loss risk.",

View File

@ -4,16 +4,16 @@ import rego.v1
required_tags := {"environment", "managed_by"}
# Resources being created or updated must have required tags
# VMs being created or updated must have required tags
warn contains msg if {
some resource in input.resource_changes
resource.type == "proxmox_virtual_environment_vm"
resource.change.actions[_] in {"create", "update"}
resource.type == "proxmox_vm_qemu"
tags := object.get(resource.change.after, "tags", "")
tags := object.get(resource.change.after, "tags", [])
some tag in required_tags
not contains(tags, tag)
not tag in tags
msg := sprintf(
"TAGS: VM '%s' missing recommended tag '%s'.",

View File

@ -2,14 +2,16 @@ package main
import rego.v1
# VMs must have firewall enabled
# All network devices on VMs must have firewall enabled
deny contains msg if {
some resource in input.resource_changes
resource.type == "proxmox_vm_qemu"
resource.change.after.firewall == false
resource.type == "proxmox_virtual_environment_vm"
resource.change.actions[_] in {"create", "update"}
some nd in resource.change.after.network_device
nd.firewall != true
msg := sprintf(
"SECURITY: VM '%s' has firewall disabled. All VMs must have firewall enabled.",
"SECURITY: VM '%s' has a network device with firewall disabled. All NICs must have firewall enabled.",
[resource.address],
)
}
@ -17,11 +19,12 @@ deny contains msg if {
# No password authentication on VMs — SSH keys only
deny contains msg if {
some resource in input.resource_changes
resource.type == "proxmox_vm_qemu"
resource.change.after.cipassword != null
resource.type == "proxmox_virtual_environment_vm"
resource.change.actions[_] in {"create", "update"}
resource.change.after_sensitive.initialization[0].user_account[0].password == true
msg := sprintf(
"SECURITY: VM '%s' uses password auth. Use SSH keys only (sshkeys parameter).",
"SECURITY: VM '%s' uses password auth. Use SSH keys only.",
[resource.address],
)
}