API 요청으로 Terraform CR 생성하기
2026.04.13 - [프로젝트/OpenCSP] - [OpenCSP] Index - Provisioning Flow
[OpenCSP] Index - Provisioning Flow
2026.03.23 - [프로젝트/OpenCSP] - API 요청으로 Terraform CR 생성하기 2026.03.25 - [프로젝트/OpenCSP] - API 요청으로 PVE VM 생성하기 2026.03.26 - [프로젝트/OpenCSP] - OpenCSP Console로 VM 생성해보기 2026.03.28 - [프로젝
miiml.tistory.com
OpenCSP에서 사용자 리소스를 생성하는 흐름은 아래 그림처럼 설계되어 있다.

사용자가 Console에서 리소스 생성 요청을 보내면 Backend에서 전달받은 정보를 기반으로 Core에 API 요청을 보내고
그 이후는 Core에 있는 Tofu-controller(Terraform)와 Semaphore(Ansible)가 Provisioning과 Post Provisioning 을 담당하는 구조.
그리고 각 과정에서 완료 요청을 백엔드에 보고해서 프로비저닝 진행 상황을 Console이 추적/ 관리 할 수 있어야 한다.
근데 OpenCSP를 구성하는 방식이 사용자의 환경마다 다를 수도 있을 거 같은데,
Core에서 어떻게 요청을 안전하게 받으면서 동시에 여러 구성에서 문제 없이 사용할 수 있을 지가 좀 고민됐다.
예를 들면 제일 단순하고 확실한 건(내가 생각하는 BP) 쿠버네티스 내부에 Core와 Console을 같이 배포해주는 방식이지만
어차피 API로 연동하니까 Core와 Console을 분리해서 구성도 가능해야 한다.
앞에 방식은 내부 CoreDNS 같은걸로 찾아서 요청보내면 되고 보안도 클러스터 내부니까 크게 문제는 없겠지만, 뒤에 상황에선 외부에서 온 요청이라 검증이 필요하다.
그래서 결론은 Terraform CR만 Service Account와 Role을 생성해서 바인딩해주고, 내부 배포는 Console 파드에 해당 역할 부여 외부 배포는 저 SA 토큰을 생성해서 그 토큰으로 검증 (이러면 해당 토큰에도 RBAC가 적용됨) 하기로 했다.
코드로 개발하기 전에 기능이 내 생각대로 동작하는지 확인해보는게 이 글에서 다루는 내용임
필요한거 설정하기
우선 지금 내 k3s엔 아래처럼 구성되어 있으니까

몇 가지 셋팅만 해주면 테스트 해볼 수 있을 거 같다.
Tofu-controller가 proxmox에 VM 생성해주려면 OpenCSP-modules에서 가져와야 하니까 gitrepository (flux의 CR) 를 추가해줘야 한다.
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: opencsp-modules
namespace: flux-system
spec:
interval: 5m
url: https://github.com/h001-lab/OpenCSP-modules.git
ref:
branch: main
이거 관리도 flux가 해야하니까 core에 추가해서 올려줬음.
어디에 두는게 좀 더 직관적일지 고민해봤는데 gitrepository가 더 추가될 거 같지 않고, tofu-controller에서만 사용하는거라 거기에 넣어줬다.

그리고 Terraform CR (이건 tofu-controller꺼) 생성을 위한 SA 관련 리소스들 생성해준다.
SA, Role, RoleBinding
apiVersion: v1
kind: ServiceAccount
metadata:
name: terraform-manager
namespace: flux-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: terraform-manager-role
rules:
- apiGroups: ["infra.contrib.fluxcd.io"]
resources: ["terraforms"]
verbs: ["create", "get", "list", "watch", "update", "patch", "delete"]
- apiGroups: ["infra.contrib.fluxcd.io"]
resources: ["terraforms/status"]
verbs: ["get", "list", "watch"]
- apiGroups: ["source.toolkit.fluxcd.io"]
resources: ["gitrepositories"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: terraform-manager-binding
subjects:
- kind: ServiceAccount
name: terraform-manager
namespace: flux-system
roleRef:
kind: ClusterRole
name: terraform-manager-role
apiGroup: rbac.authorization.k8s.io
Token
apiVersion: v1
kind: Secret
metadata:
name: terraform-manager-token
namespace: flux-system
annotations:
kubernetes.io/service-account.name: terraform-manager
type: kubernetes.io/service-account-token
이건 모든 구성이 안정적으로 끝나고 해줘야하니까 Policies layer를 추가해줬다.
curl로 테스트해보기
외부 PC에서 해볼 수도 있지만 우선 local(k3s node)에서 해보기로 했다.
쿠버네티스에 요청을 보낼 때 /apis/{group}/{version}/namespaces/{namespace}/{resource_plural} 경로를 지정해주면 원하는 CR을 지정할 수 있다고 한다.
위에서 flux가 생성해준 토큰은 아래 명령어로 가져올 수 있고,
kubectl get secret terraform-manager-token -n flux-system -o jsonpath='{.data.token}' | base64 -d
아래 명령어로 테스트 해볼 수 있다.
TOKEN="여기 입력"
PVE_K3S_API_SERVER="https://{K3S_IP}:6443"
curl -v -k -X POST \
"${PVE_K3S_API_SERVER}/apis/infra.contrib.fluxcd.io/v1alpha2/namespaces/flux-system/terraforms" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "infra.contrib.fluxcd.io/v1alpha2",
"kind": "Terraform",
"metadata": {
"name": "test-vm-provision",
"namespace": "flux-system"
},
"spec": {
"path": "./terraform/proxmox/provision",
"interval": "10m",
"approvePlan": "auto",
"destroyResourcesOnDeletion": true,
"sourceRef": {
"kind": "GitRepository",
"name": "opencsp-modules",
"namespace": "flux-system"
},
"varsFrom": [
{"kind": "Secret", "name": "terraform-secrets"}
],
"vars": [
{"name": "vm_name", "value": "test-vm3"},
{"name": "vm_id", "value": {기존 VM ID와 겹치지 않게 지정}},
{"name": "cores", "value": 1},
{"name": "memory", "value": 2048},
{"name": "disk_size", "value": "50G"},
{"name": "vm_ip", "value": "{생성할 VM IP}/24"},
{"name": "vm_gw", "value": "{해당 네트워크 대역에 있는 GW IP}"},
{"name": "vm_network_bridge", "value": "vmbr0"},
{"name": "target_node", "value": "pve"},
{"name": "template_name", "value": "ubuntu-2404-template"},
{"name": "storage_pool", "value": "local-lvm"},
{"name": "snippet_storage_pool", "value": "local"}
]
}
}'
그리고 -d로 body에 적어준 json은 어떤 모듈, 어떤 시크릿, 어떤 변수를 참조할지에 대한 정의인데
처음에 추가한 module의 GitRepository CR이랑 terrform이 사용할 크레덴셜들 (PVE의 크레덴셜 등)이 저장된 Secret 을 적어줬고,
해당 모듈에서 사용하는 변수들은 vars에 name을 지정해서 적어줬다.
근데 테라폼 모듈에서는 프로바이더를 지정해주지 않고 requirement 정도만 적어주기 때문에 (사용자가 provider를 지정할 수 있게)
그냥 모듈 경로를 지정해주면 tofu-controller가 프로바이더를 찾지 못해서 에러가 발생함
그래서 OpenCSP-module에 terraform/proxmox/provision 이라는 별도의 wrapper 코드를 만들어주고, 그걸 호출하게 수정해줬다.
(일단 기능 테스트 용도로 바로 추가한거라 구조는 바뀔 예정, 현재 구조는 확장에 불리할 듯)
결과
...
> POST /apis/infra.contrib.fluxcd.io/v1alpha2/namespaces/flux-system/terraforms HTTP/2
> Host: {HOST IP}:6443
> User-Agent: curl/8.5.0
> Accept: */*
> Authorization: Bearer ...
> Content-Type: application/json
> Content-Length: 1130
>
< HTTP/2 201
< audit-id: ...
< cache-control: no-cache, private
< content-type: application/json
< x-kubernetes-pf-flowschema-uid: ...
< x-kubernetes-pf-prioritylevel-uid: ...
< content-length: 3099
< date: Mon, 23 Mar 2026 09:39:49 GMT
<
{
"apiVersion": "infra.contrib.fluxcd.io/v1alpha2",
"kind": "Terraform",
"metadata": {
"creationTimestamp": "2026-03-23T09:39:49Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "infra.contrib.fluxcd.io/v1alpha2",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:alwaysCleanupRunnerPod": {},
"f:approvePlan": {},
"f:destroyResourcesOnDeletion": {},
"f:disableDriftDetection": {},
"f:force": {},
"f:interval": {},
"f:parallelism": {},
"f:path": {},
"f:refreshBeforeApply": {},
"f:retryStrategy": {},
"f:runnerTerminationGracePeriodSeconds": {},
"f:serviceAccountName": {},
"f:sourceRef": {
".": {},
"f:kind": {},
"f:name": {},
"f:namespace": {}
},
"f:storeReadablePlan": {},
"f:upgradeOnInit": {},
"f:vars": {},
"f:varsFrom": {},
"f:workspace": {}
}
},
"manager": "curl",
"operation": "Update",
"time": "2026-03-23T09:39:49Z"
}
],
"name": "test-vm-provision",
"namespace": "flux-system",
"resourceVersion": "...",
"uid": "..."
},
"spec": {
"alwaysCleanupRunnerPod": true,
"approvePlan": "auto",
"destroyResourcesOnDeletion": true,
"disableDriftDetection": false,
"force": false,
"interval": "10m",
"parallelism": 0,
"path": "./terraform/proxmox/provision",
"refreshBeforeApply": false,
"retryStrategy": "StaticInterval",
"runnerTerminationGracePeriodSeconds": 30,
"serviceAccountName": "tf-runner",
"sourceRef": {
"kind": "GitRepository",
"name": "opencsp-modules",
"namespace": "flux-system"
},
"storeReadablePlan": "none",
"upgradeOnInit": true,
"vars": [
{
"name": "vm_name",
"value": "test-vm3"
},
...
{
"name": "storage_pool",
"value": "local-lvm"
},
{
"name": "snippet_storage_pool",
"value": "local"
}
],
"varsFrom": [
{
"kind": "Secret",
"name": "terraform-secrets"
}
],
"workspace": "default"
},
"status": {
"observedGeneration": -1
}
* Connection #0 to host {HOST IP} left intact
아래 명령어로 생성된 확인할 수 있는데,
k get terraform test-vm-provision -n flux-system -w
보면 apply 에러남

좀 더 자세한 내용을 보면
k describe terraform test-vm-provision -n flux-system
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
-/+ destroy and then create replacement
OpenTofu will perform the following actions:
# module.vm.local_file.cloud_init_file will be created
+ resource "local_file" "cloud_init_file" {
+ content = (sensitive value)
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "../vm/generated/user_data_test-vm3.yaml"
+ id = (known after apply)
}
# module.vm.null_resource.cloud_init_snippet is tainted, so it must be replaced
-/+ resource "null_resource" "cloud_init_snippet" {
~ id = "..." -> (known after apply)
# (1 unchanged attribute hidden)
}
# module.vm.proxmox_vm_qemu.node will be created
+ resource "proxmox_vm_qemu" "node" {
+ additional_wait = 5
+ agent = 1
+ automatic_reboot = true
+ balloon = 0
+ bios = "seabios"
+ boot = (known after apply)
+ bootdisk = "scsi0"
+ cicustom = "user=local:snippets/user_data_test-vm3.yaml"
+ ciupgrade = false
+ clone = "ubuntu-2404-template"
+ clone_wait = 10
+ cores = 1
+ cpu_type = "host"
+ default_ipv4_address = (known after apply)
+ default_ipv6_address = (known after apply)
+ define_connection_info = true
+ desc = "Managed by Terraform."
+ force_create = false
+ full_clone = true
+ hotplug = "network,disk,usb"
+ id = (known after apply)
+ ipconfig0 = "ip={IP}/24,gw={GW}"
+ kvm = true
+ linked_vmid = (known after apply)
+ memory = 2048
+ name = "test-vm3"
+ onboot = false
+ os_type = "cloud-init"
+ protection = false
+ reboot_required = (known after apply)
+ scsihw = "virtio-scsi-pci"
+ skip_ipv4 = false
+ skip_ipv6 = false
+ sockets = 1
+ ssh_host = (known after apply)
+ ssh_port = (known after apply)
+ tablet = true
+ tags = (known after apply)
+ target_node = "pve"
+ unused_disk = (known after apply)
+ vcpus = 0
+ vm_state = "running"
+ vmid = {ID}
+ disk {
+ backup = true
+ discard = true
+ id = (known after apply)
+ iops_r_burst = 0
+ iops_r_burst_length = 0
+ iops_r_concurrent = 0
+ iops_wr_burst = 0
+ iops_wr_burst_length = 0
+ iops_wr_concurrent = 0
+ iothread = true
+ linked_disk_id = (known after apply)
+ mbps_r_burst = 0
+ mbps_r_concurrent = 0
+ mbps_wr_burst = 0
+ mbps_wr_concurrent = 0
+ passthrough = false
+ size = "50G"
+ slot = "scsi0"
+ storage = "local-lvm"
+ type = "disk"
}
+ disk {
+ backup = true
+ id = (known after apply)
+ iops_r_burst = 0
+ iops_r_burst_length = 0
+ iops_r_concurrent = 0
+ iops_wr_burst = 0
+ iops_wr_burst_length = 0
+ iops_wr_concurrent = 0
+ linked_disk_id = (known after apply)
+ mbps_r_burst = 0
+ mbps_r_concurrent = 0
+ mbps_wr_burst = 0
+ mbps_wr_concurrent = 0
+ passthrough = false
+ size = (known after apply)
+ slot = "ide2"
+ storage = "local-lvm"
+ type = "cloudinit"
}
+ network {
+ bridge = "vmbr0"
+ firewall = false
+ id = 0
+ link_down = false
+ macaddr = (known after apply)
+ model = "virtio"
}
+ serial {
+ id = 0
+ type = "socket"
}
+ smbios (known after apply)
}
Plan: 3 to add, 0 to change, 1 to destroy.
Warning DriftDetected 54s tf-controller Drift detected.
Note: Objects have changed outside of OpenTofu
OpenTofu detected the following changes made outside of OpenTofu since the
last "tofu apply" which may have affected this plan:
# module.vm.local_file.cloud_init_file has been deleted
- resource "local_file" "cloud_init_file" {
- content = (sensitive value) -> null
id = "..."
# (9 unchanged attributes hidden)
}
Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.
에러 났으니까 실패라고 생각할 수 있는데,
api 요청으로 CR 생성 + tofu-controller가 제대로 인지하고 plan 까지 돌린 상황이라 원하던 테스트는 성공했다.
그리고 위에 에러나는 문제 원인은
내 모듈에서 cloud-init을 파일로 만들어서 ssh 전달하는 방식으로 Pre-Provisioning 하고 있는데, 현재 구성대로면 PVE 내부 VM에 있는 k3s 내부의 tofu-controller가 host(PVE)로 ssh 접근이 가능해야 한다.
근데 이건 좀 별로라
찾아보니까 PVE API로 파일을 전달할 수 있는 방법이 있음
그래서 추후에 해당 방식으로 Module과 Core를 수정하려고 한다.
수정하면 Secret에 들어가야할 내용도 적어지고, 방식도 통일되고 에러도 해결 될 듯.
추가 참고할 내용
테스트로 만들어진 CR 삭제
# finalizer 제거
curl -k -X PATCH \
"${PVE_K3S_API_SERVER}/apis/infra.contrib.fluxcd.io/v1alpha2/namespaces/flux-system/terraforms/test-vm-provision" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/merge-patch+json" \
-d '{"metadata":{"finalizers":[]}}'
# 그 다음 삭제
curl -k -X DELETE \
"${PVE_K3S_API_SERVER}/apis/infra.contrib.fluxcd.io/v1alpha2/namespaces/flux-system/terraforms/test-vm-provision" \
-H "Authorization: Bearer ${TOKEN}"