From 7c851b8df515fee13010b5d37d5c21876e559394 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 2 Aug 2026 19:40:49 +1000 Subject: [PATCH] Add terraform-provider-kea Terraform/OpenTofu provider wrapping the kea-operator KeaAPI, modelled on terraform-provider-encapi. - add kea_subnet and kea_clientclass resources with full CRUD over the PUT/GET/DELETE /api/v1/{subnets,clientclasses}/{name} contract - add provider config (endpoint + bearer token, KEA_API_TOKEN fallback); 404 on read removes the resource from state - add unit tests against httptest mock servers (client, wire round-trip, type conversions, schemas) - add Makefile (patch|minor|major + package) and .woodpecker CI mirroring terraform-provider-encapi; tag release PUTs the zip to the artifactapi terraform-unkin registry under unkin/kea Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT --- .pre-commit-config.yaml | 15 ++ .woodpecker/build.yml | 18 ++ .woodpecker/pre-commit.yaml | 18 ++ .woodpecker/release.yml | 40 ++++ .woodpecker/test.yml | 33 ++++ Makefile | 61 ++++++ README.md | 44 ++++- examples/main.tf | 42 ++++ examples/resources/kea_clientclass/main.tf | 17 ++ examples/resources/kea_subnet/main.tf | 24 +++ go.mod | 30 +++ go.sum | 91 +++++++++ internal/provider/client.go | 91 +++++++++ internal/provider/client_test.go | 86 ++++++++ internal/provider/conversions.go | 125 ++++++++++++ internal/provider/conversions_test.go | 123 ++++++++++++ internal/provider/models.go | 38 ++++ internal/provider/provider.go | 80 ++++++++ internal/provider/provider_test.go | 93 +++++++++ internal/provider/resource_clientclass.go | 181 +++++++++++++++++ internal/provider/resource_subnet.go | 219 +++++++++++++++++++++ main.go | 30 +++ 22 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yml create mode 100644 .woodpecker/test.yml create mode 100644 Makefile create mode 100644 examples/main.tf create mode 100644 examples/resources/kea_clientclass/main.tf create mode 100644 examples/resources/kea_subnet/main.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/provider/client.go create mode 100644 internal/provider/client_test.go create mode 100644 internal/provider/conversions.go create mode 100644 internal/provider/conversions_test.go create mode 100644 internal/provider/models.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go create mode 100644 internal/provider/resource_clientclass.go create mode 100644 internal/provider/resource_subnet.go create mode 100644 main.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-mod-tidy diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.25 + commands: + - make build + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..6d1a35b --- /dev/null +++ b/.woodpecker/release.yml @@ -0,0 +1,40 @@ +when: + - event: tag + +steps: + - name: package + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make package VERSION=${CI_COMMIT_TAG} + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: upload + image: git.unkin.net/unkin/almalinux9-base:20260606 + commands: + - | + VERSION=$$(echo ${CI_COMMIT_TAG} | sed 's/^v//') + FILE="terraform-provider-kea_$${VERSION}_linux_amd64.zip" + curl -f -X PUT \ + "https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/terraform-unkin/files/unkin/kea/$${FILE}" \ + -H "Content-Type: application/zip" \ + --data-binary @"$${FILE}" + depends_on: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..8e81e45 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golang:1.25 + commands: + - make lint + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - make test + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff22d8b --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: build install test lint fmt clean tidy package patch minor major check-go + +BINARY := terraform-provider-kea +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +OS_ARCH := linux_amd64 +INSTALL_VERSION := $(shell echo $(VERSION) | sed 's/^v//') +INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/kea/$(INSTALL_VERSION)/$(OS_ARCH) +ZIP := $(BINARY)_$(INSTALL_VERSION)_$(OS_ARCH).zip + +GO_VERSION_REQUIRED := 1.23 +GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') + +check-go: + @if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \ + echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \ + fi + +build: check-go tidy + go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) + +install: build + mkdir -p $(INSTALL_DIR) + cp $(BINARY) $(INSTALL_DIR)/ + +test: check-go + go test -race -count=1 ./... + +lint: check-go + go vet ./... + +fmt: check-go + gofmt -w . + +package: build + cp $(BINARY) $(BINARY)_v$(INSTALL_VERSION) + python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); z.write(sys.argv[2]); z.close()" $(ZIP) $(BINARY)_v$(INSTALL_VERSION) + rm $(BINARY)_v$(INSTALL_VERSION) + +clean: + rm -f $(BINARY) *.zip + +tidy: + go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index 0e661a0..07ad990 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ # terraform-provider-kea -Terraform provider for the kea-operator KeaAPI: manage Kea DHCP subnets and PXE client classes. \ No newline at end of file +Terraform / OpenTofu provider for the [kea-operator](https://git.unkin.net/unkin/kea-operator) +KeaAPI: it manages Kea DHCP subnets and PXE client classes as KeaSubnet / +KeaClientClass custom resources over the KeaAPI HTTP service. + +## Provider configuration + +```hcl +terraform { + required_providers { + kea = { + source = "git.unkin.net/unkin/kea" + version = "0.0.1" + } + } +} + +provider "kea" { + endpoint = "https://keaapi.k8s.syd1.au.unkin.net" + # token = "..." # defaults to $KEA_API_TOKEN +} +``` + +Both reads and writes require the bearer token. Set it out-of-band via the +`KEA_API_TOKEN` environment variable (managed in Vault) rather than in HCL. + +## Resources + +| Resource | Purpose | +|--------------------|----------------------------------------------------------------| +| `kea_subnet` | A DHCP subnet: pools, routers, DNS, PXE/boot options. | +| `kea_clientclass` | A PXE client class matching by test expr or architecture. | + +The `name` attribute is the stable id (the CR name) and forces replacement when +changed. `option_data` is a nested block of DHCP options (`name`, `code`, +`space`, and required `data`). + +## Releases + +Tagging `vX.Y.Z` builds `terraform-provider-kea_X.Y.Z_linux_amd64.zip` and +uploads it to the ArtifactAPI Terraform registry (`terraform-unkin` remote, +namespace `unkin/kea`), which serves it as a GPG-signed provider registry. + +See `examples/` for full usage. diff --git a/examples/main.tf b/examples/main.tf new file mode 100644 index 0000000..27818d2 --- /dev/null +++ b/examples/main.tf @@ -0,0 +1,42 @@ +terraform { + required_providers { + kea = { + source = "git.unkin.net/unkin/kea" + version = "0.0.1" + } + } +} + +provider "kea" { + endpoint = "https://keaapi.k8s.syd1.au.unkin.net" + # token defaults to the KEA_API_TOKEN environment variable +} + +resource "kea_clientclass" "pxeclients" { + name = "pxeclients" + test = "substring(option[60].hex,0,9) == 'PXEClient'" + + next_server = "10.0.0.1" + boot_file_name = "ipxe.efi" +} + +resource "kea_subnet" "lab" { + name = "lab" + cluster_ref = "primary" + subnet = "10.0.0.0/24" + + pools = ["10.0.0.100-10.0.0.200"] + routers = ["10.0.0.1"] + dns_servers = ["10.0.0.53"] + domain_name = "lab.unkin.net" + client_classes = [kea_clientclass.pxeclients.name] + valid_lifetime = 3600 + + option_data = [ + { + name = "tftp-server-name" + code = 66 + data = "10.0.0.1" + }, + ] +} diff --git a/examples/resources/kea_clientclass/main.tf b/examples/resources/kea_clientclass/main.tf new file mode 100644 index 0000000..d32a4ee --- /dev/null +++ b/examples/resources/kea_clientclass/main.tf @@ -0,0 +1,17 @@ +resource "kea_clientclass" "pxeclients" { + name = "pxeclients" + + # Match legacy BIOS PXE clients by vendor-class-identifier. + test = "substring(option[60].hex,0,9) == 'PXEClient'" + + next_server = "10.0.0.1" + boot_file_name = "ipxe.efi" +} + +resource "kea_clientclass" "efi_x64" { + name = "efi-x64" + + # Match by client architecture type instead of a test expression. + arch_hex = ["0007", "0009"] + boot_file_name = "ipxe.efi" +} diff --git a/examples/resources/kea_subnet/main.tf b/examples/resources/kea_subnet/main.tf new file mode 100644 index 0000000..e5cbcc9 --- /dev/null +++ b/examples/resources/kea_subnet/main.tf @@ -0,0 +1,24 @@ +resource "kea_subnet" "lab" { + name = "lab" + cluster_ref = "primary" + subnet = "10.0.0.0/24" + + pools = ["10.0.0.100-10.0.0.200"] + routers = ["10.0.0.1"] + dns_servers = ["10.0.0.53"] + domain_name = "lab.unkin.net" + valid_lifetime = 3600 + + # PXE boot options + next_server = "10.0.0.1" + boot_file_name = "pxelinux.0" + client_classes = ["pxeclients"] + + option_data = [ + { + name = "tftp-server-name" + code = 66 + data = "10.0.0.1" + }, + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2f990ec --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.unkin.net/unkin/terraform-provider-kea + +go 1.25.9 + +require github.com/hashicorp/terraform-plugin-framework v1.15.0 + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.27.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.5 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.72.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..167a85a --- /dev/null +++ b/go.sum @@ -0,0 +1,91 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.15.0 h1:LQ2rsOfmDLxcn5EeIwdXFtr03FVsNktbbBci8cOKdb4= +github.com/hashicorp/terraform-plugin-framework v1.15.0/go.mod h1:hxrNI/GY32KPISpWqlCoTLM9JZsGH3CyYlir09bD/fI= +github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE= +github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= +github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= +github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/client.go b/internal/provider/client.go new file mode 100644 index 0000000..8f86725 --- /dev/null +++ b/internal/provider/client.go @@ -0,0 +1,91 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type apiClient struct { + baseURL string + token string + httpClient *http.Client +} + +func newAPIClient(baseURL, token string) *apiClient { + return &apiClient{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{}, + } +} + +func (c *apiClient) get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) +} + +func (c *apiClient) put(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPut, path, body, out) +} + +func (c *apiClient) del(ctx context.Context, path string) error { + return c.do(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return ¬FoundError{path: path} + } + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("api error %d: %s", resp.StatusCode, string(b)) + } + if out != nil && resp.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + +// pathEscape escapes a path segment for use as the {name} URL id. +func pathEscape(s string) string { return url.PathEscape(s) } + +type notFoundError struct{ path string } + +func (e *notFoundError) Error() string { return fmt.Sprintf("not found: %s", e.path) } + +func isNotFound(err error) bool { + _, ok := err.(*notFoundError) + return ok +} diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go new file mode 100644 index 0000000..0778712 --- /dev/null +++ b/internal/provider/client_test.go @@ -0,0 +1,86 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientSendsBearerToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"name":"pxe","subnet":"10.0.0.0/24"}`)) + })) + defer srv.Close() + + c := newAPIClient(srv.URL, "tok") + var out subnetAPI + if err := c.put(context.Background(), "/api/v1/subnets/pxe", subnetAPI{Name: "pxe", Subnet: "10.0.0.0/24"}, &out); err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth = %q, want Bearer tok", gotAuth) + } +} + +func TestClientNoTokenNoHeader(t *testing.T) { + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadAuth = r.Header["Authorization"] + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := newAPIClient(srv.URL, "") + if err := c.get(context.Background(), "/api/v1/subnets/x", &subnetAPI{}); err != nil { + t.Fatal(err) + } + if hadAuth { + t.Error("no Authorization header expected when token empty") + } +} + +func TestClientNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + + err := newAPIClient(srv.URL, "").get(context.Background(), "/api/v1/subnets/ghost", &subnetAPI{}) + if !isNotFound(err) { + t.Errorf("err = %v, want notFound", err) + } +} + +func TestClientAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"subnet is required"}`)) + })) + defer srv.Close() + + err := newAPIClient(srv.URL, "t").put(context.Background(), "/api/v1/subnets/h", subnetAPI{}, &subnetAPI{}) + if err == nil { + t.Fatal("expected api error") + } + if isNotFound(err) { + t.Errorf("400 should not be a notFound error, got %v", err) + } +} + +func TestClientDeleteNoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + if err := newAPIClient(srv.URL, "t").del(context.Background(), "/api/v1/clientclasses/pxe"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/provider/conversions.go b/internal/provider/conversions.go new file mode 100644 index 0000000..e4904e6 --- /dev/null +++ b/internal/provider/conversions.go @@ -0,0 +1,125 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// optionDataSchema is the shared option_data nested-list attribute used by both +// resources. Each element is a DHCP option (name/code/space + required data). +func optionDataSchema(desc string) schema.ListNestedAttribute { + return schema.ListNestedAttribute{ + Description: desc, + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{Description: "Option name.", Optional: true}, + "code": schema.Int64Attribute{Description: "Numeric option code.", Optional: true}, + "space": schema.StringAttribute{Description: "Option space.", Optional: true}, + "data": schema.StringAttribute{Description: "Option value.", Required: true}, + }, + }, + } +} + +// optionDataModel is the nested-attribute form of a DHCP option value. +type optionDataModel struct { + Name types.String `tfsdk:"name"` + Code types.Int64 `tfsdk:"code"` + Space types.String `tfsdk:"space"` + Data types.String `tfsdk:"data"` +} + +func optionDataObjectType() types.ObjectType { + return types.ObjectType{AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "code": types.Int64Type, + "space": types.StringType, + "data": types.StringType, + }} +} + +// stringOrNull maps an omitempty API string to a Terraform value: empty -> null. +func stringOrNull(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} + +// int64OrNull maps an omitempty API int to a Terraform value: zero -> null. +func int64OrNull(i int) types.Int64 { + if i == 0 { + return types.Int64Null() + } + return types.Int64Value(int64(i)) +} + +// listToStrings reads a Terraform list attribute into a Go slice. A null or +// unknown list yields nil, so it round-trips against the API's omitempty fields. +func listToStrings(ctx context.Context, l types.List, diags *diag.Diagnostics) []string { + if l.IsNull() || l.IsUnknown() { + return nil + } + out := make([]string, 0, len(l.Elements())) + diags.Append(l.ElementsAs(ctx, &out, false)...) + return out +} + +// stringsToList renders an API slice as a Terraform list. An empty/nil slice +// becomes a null list so it compares equal to an omitted config attribute. +func stringsToList(s []string) types.List { + if len(s) == 0 { + return types.ListNull(types.StringType) + } + elems := make([]attr.Value, 0, len(s)) + for _, v := range s { + elems = append(elems, types.StringValue(v)) + } + return types.ListValueMust(types.StringType, elems) +} + +// optionDataToAPI reads the option_data nested list into wire structs. +func optionDataToAPI(ctx context.Context, l types.List, diags *diag.Diagnostics) []optionDataAPI { + if l.IsNull() || l.IsUnknown() { + return nil + } + var models []optionDataModel + diags.Append(l.ElementsAs(ctx, &models, false)...) + if diags.HasError() { + return nil + } + out := make([]optionDataAPI, 0, len(models)) + for _, m := range models { + out = append(out, optionDataAPI{ + Name: m.Name.ValueString(), + Code: int(m.Code.ValueInt64()), + Space: m.Space.ValueString(), + Data: m.Data.ValueString(), + }) + } + return out +} + +// optionDataToList renders wire structs back into a nested list. Empty -> null. +func optionDataToList(in []optionDataAPI) types.List { + t := optionDataObjectType() + if len(in) == 0 { + return types.ListNull(t) + } + elems := make([]attr.Value, 0, len(in)) + for _, o := range in { + obj := types.ObjectValueMust(t.AttrTypes, map[string]attr.Value{ + "name": stringOrNull(o.Name), + "code": int64OrNull(o.Code), + "space": stringOrNull(o.Space), + "data": types.StringValue(o.Data), + }) + elems = append(elems, obj) + } + return types.ListValueMust(t, elems) +} diff --git a/internal/provider/conversions_test.go b/internal/provider/conversions_test.go new file mode 100644 index 0000000..3fd62df --- /dev/null +++ b/internal/provider/conversions_test.go @@ -0,0 +1,123 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestStringOrNull(t *testing.T) { + if !stringOrNull("").IsNull() { + t.Error("empty string should map to null") + } + if v := stringOrNull("x"); v.ValueString() != "x" { + t.Errorf("got %q, want x", v.ValueString()) + } +} + +func TestInt64OrNull(t *testing.T) { + if !int64OrNull(0).IsNull() { + t.Error("zero should map to null") + } + if v := int64OrNull(42); v.ValueInt64() != 42 { + t.Errorf("got %d, want 42", v.ValueInt64()) + } +} + +func TestStringsListRoundTrip(t *testing.T) { + if !stringsToList(nil).IsNull() { + t.Error("nil slice should map to null list") + } + if !stringsToList([]string{}).IsNull() { + t.Error("empty slice should map to null list") + } + l := stringsToList([]string{"a", "b"}) + var diags diag.Diagnostics + got := listToStrings(context.Background(), l, &diags) + if diags.HasError() { + t.Fatalf("diags: %v", diags) + } + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("round trip = %v", got) + } + // a null list reads back as a nil slice, matching the API's omitempty fields + if listToStrings(context.Background(), types.ListNull(types.StringType), &diags) != nil { + t.Error("null list should read back as nil slice") + } +} + +func TestOptionDataRoundTrip(t *testing.T) { + in := []optionDataAPI{ + {Name: "tftp-server-name", Code: 66, Space: "dhcp4", Data: "10.0.0.1"}, + {Data: "just-data"}, + } + list := optionDataToList(in) + var diags diag.Diagnostics + got := optionDataToAPI(context.Background(), list, &diags) + if diags.HasError() { + t.Fatalf("diags: %v", diags) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0] != in[0] { + t.Errorf("elem0 = %+v, want %+v", got[0], in[0]) + } + // zero code/empty name+space round-trip cleanly (null <-> omitempty zero) + if got[1] != in[1] { + t.Errorf("elem1 = %+v, want %+v", got[1], in[1]) + } + if !optionDataToList(nil).IsNull() { + t.Error("empty option_data should map to null list") + } +} + +func TestSubnetAPIToModel(t *testing.T) { + api := subnetAPI{ + Name: "pxe", + Subnet: "10.0.0.0/24", + ID: 0, // omitted -> null + Pools: []string{"10.0.0.100-10.0.0.200"}, + Routers: []string{"10.0.0.1"}, + ClientClasses: []string{"pxeclients"}, + ValidLifetime: 3600, + OptionData: []optionDataAPI{{Code: 67, Data: "pxelinux.0"}}, + } + m := subnetAPIToModel(api) + if m.Name.ValueString() != "pxe" || m.Subnet.ValueString() != "10.0.0.0/24" { + t.Errorf("identity fields wrong: %+v", m) + } + if !m.SubnetID.IsNull() { + t.Error("id 0 should be null") + } + if m.ValidLifetime.ValueInt64() != 3600 { + t.Errorf("valid_lifetime = %d", m.ValidLifetime.ValueInt64()) + } + if !m.DNSServers.IsNull() { + t.Error("unset dns_servers should be null") + } + if m.Pools.IsNull() { + t.Error("pools should be populated") + } +} + +func TestClientClassAPIToModel(t *testing.T) { + api := clientClassAPI{ + Name: "pxeclients", + Test: "substring(option[60].hex,0,9) == 'PXEClient'", + ArchHex: []string{"0007", "0009"}, + BootFileName: "ipxe.efi", + } + m := clientClassAPIToModel(api) + if m.Name.ValueString() != "pxeclients" || m.Test.ValueString() == "" { + t.Errorf("fields wrong: %+v", m) + } + if m.ArchHex.IsNull() { + t.Error("arch_hex should be populated") + } + if !m.NextServer.IsNull() { + t.Error("unset next_server should be null") + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go new file mode 100644 index 0000000..b7d4df8 --- /dev/null +++ b/internal/provider/models.go @@ -0,0 +1,38 @@ +package provider + +// API wire types, mirroring git.unkin.net/unkin/kea-operator internal/keaapi. +// They are duplicated here to keep the provider's dependency surface small. + +type optionDataAPI struct { + Name string `json:"name,omitempty"` + Code int `json:"code,omitempty"` + Space string `json:"space,omitempty"` + Data string `json:"data"` +} + +type subnetAPI struct { + Name string `json:"name"` + ClusterRef string `json:"cluster_ref,omitempty"` + Subnet string `json:"subnet"` + ID int `json:"id,omitempty"` + Pools []string `json:"pools,omitempty"` + Routers []string `json:"routers,omitempty"` + DNSServers []string `json:"dns_servers,omitempty"` + DomainName string `json:"domain_name,omitempty"` + NextServer string `json:"next_server,omitempty"` + BootFileName string `json:"boot_file_name,omitempty"` + ClientClasses []string `json:"client_classes,omitempty"` + ValidLifetime int `json:"valid_lifetime,omitempty"` + OptionData []optionDataAPI `json:"option_data,omitempty"` +} + +type clientClassAPI struct { + Name string `json:"name"` + ClusterRef string `json:"cluster_ref,omitempty"` + Test string `json:"test,omitempty"` + ArchHex []string `json:"arch_hex,omitempty"` + BootFileName string `json:"boot_file_name,omitempty"` + NextServer string `json:"next_server,omitempty"` + ServerHostname string `json:"server_hostname,omitempty"` + OptionData []optionDataAPI `json:"option_data,omitempty"` +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..599f31d --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,80 @@ +package provider + +import ( + "context" + "os" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ provider.Provider = &keaProvider{} + +type keaProvider struct { + version string +} + +type keaProviderModel struct { + Endpoint types.String `tfsdk:"endpoint"` + Token types.String `tfsdk:"token"` +} + +// New returns the provider constructor. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &keaProvider{version: version} + } +} + +func (p *keaProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "kea" + resp.Version = p.version +} + +func (p *keaProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manage Kea DHCP configuration through the kea-operator KeaAPI: subnets and PXE client classes.", + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{ + Description: "The KeaAPI server base URL (e.g. https://keaapi.k8s.syd1.au.unkin.net).", + Required: true, + }, + "token": schema.StringAttribute{ + Description: "Bearer token for the KeaAPI. Both reads and writes require it. Defaults to the KEA_API_TOKEN environment variable.", + Optional: true, + Sensitive: true, + }, + }, + } +} + +func (p *keaProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config keaProviderModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + token := config.Token.ValueString() + if token == "" { + token = os.Getenv("KEA_API_TOKEN") + } + + client := newAPIClient(config.Endpoint.ValueString(), token) + resp.DataSourceData = client + resp.ResourceData = client +} + +func (p *keaProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewSubnetResource, + NewClientClassResource, + } +} + +func (p *keaProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return nil +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..5bd5572 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,93 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func TestProviderSchema(t *testing.T) { + p := New("test")() + resp := &provider.SchemaResponse{} + p.Schema(context.Background(), provider.SchemaRequest{}, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema diagnostics: %v", resp.Diagnostics) + } + if _, ok := resp.Schema.Attributes["endpoint"]; !ok { + t.Error("missing endpoint attribute") + } + if _, ok := resp.Schema.Attributes["token"]; !ok { + t.Error("missing token attribute") + } +} + +func TestProviderMetadata(t *testing.T) { + p := New("1.2.3")() + resp := &provider.MetadataResponse{} + p.Metadata(context.Background(), provider.MetadataRequest{}, resp) + if resp.TypeName != "kea" || resp.Version != "1.2.3" { + t.Errorf("metadata = %+v", resp) + } +} + +func TestProviderRegistersResources(t *testing.T) { + p := New("test")() + if len(p.Resources(context.Background())) != 2 { + t.Error("expected 2 resources (subnet, clientclass)") + } +} + +// TestResourceSchemas validates each resource's schema compiles cleanly and +// carries the expected metadata type name. +func TestResourceSchemas(t *testing.T) { + for _, ctor := range []func() resource.Resource{NewSubnetResource, NewClientClassResource} { + resp := &resource.SchemaResponse{} + ctor().Schema(context.Background(), resource.SchemaRequest{}, resp) + if resp.Diagnostics.HasError() { + t.Errorf("resource schema error: %v", resp.Diagnostics) + } + } +} + +// TestSubnetWireRoundTrip drives a full subnet PUT through a mock server that +// echoes what it decoded, proving the JSON contract (name from path id, pools, +// option_data) matches the KeaAPI models end to end. +func TestSubnetWireRoundTrip(t *testing.T) { + var got subnetAPI + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("method = %s, want PUT", r.Method) + } + if r.URL.Path != "/api/v1/subnets/pxe" { + t.Errorf("path = %s", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&got) + got.Name = "pxe" // server authoritative from path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(got) + })) + defer srv.Close() + + in := subnetAPI{ + Name: "pxe", + Subnet: "10.0.0.0/24", + Pools: []string{"10.0.0.100-10.0.0.200"}, + Routers: []string{"10.0.0.1"}, + OptionData: []optionDataAPI{{Code: 67, Space: "dhcp4", Data: "pxelinux.0"}}, + } + var out subnetAPI + if err := newAPIClient(srv.URL, "t").put(context.Background(), "/api/v1/subnets/pxe", in, &out); err != nil { + t.Fatal(err) + } + if out.Subnet != in.Subnet || len(out.Pools) != 1 || len(out.OptionData) != 1 { + t.Errorf("round trip mismatch: %+v", out) + } + if out.OptionData[0].Code != 67 || out.OptionData[0].Data != "pxelinux.0" { + t.Errorf("option_data lost: %+v", out.OptionData) + } +} diff --git a/internal/provider/resource_clientclass.go b/internal/provider/resource_clientclass.go new file mode 100644 index 0000000..55ce5eb --- /dev/null +++ b/internal/provider/resource_clientclass.go @@ -0,0 +1,181 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &clientClassResource{} + _ resource.ResourceWithImportState = &clientClassResource{} +) + +type clientClassResource struct { + client *apiClient +} + +type clientClassResourceModel struct { + Name types.String `tfsdk:"name"` + ClusterRef types.String `tfsdk:"cluster_ref"` + Test types.String `tfsdk:"test"` + ArchHex types.List `tfsdk:"arch_hex"` + BootFileName types.String `tfsdk:"boot_file_name"` + NextServer types.String `tfsdk:"next_server"` + ServerHostname types.String `tfsdk:"server_hostname"` + OptionData types.List `tfsdk:"option_data"` +} + +func NewClientClassResource() resource.Resource { return &clientClassResource{} } + +func (r *clientClassResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_clientclass" +} + +func (r *clientClassResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A Kea PXE client class (KeaClientClass CR) matching clients by test expression or architecture, with boot options.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Stable resource id (the KeaClientClass CR name).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cluster_ref": schema.StringAttribute{ + Description: "Name of the KeaCluster this class belongs to.", + Optional: true, + }, + "test": schema.StringAttribute{ + Description: "Kea test expression selecting matching clients. One of test or arch_hex is required.", + Optional: true, + }, + "arch_hex": schema.ListAttribute{ + Description: "Client architecture types (hex) to match. One of test or arch_hex is required.", + ElementType: types.StringType, + Optional: true, + }, + "boot_file_name": schema.StringAttribute{ + Description: "PXE boot file name handed to matching clients.", + Optional: true, + }, + "next_server": schema.StringAttribute{ + Description: "PXE next-server (siaddr / TFTP server) address.", + Optional: true, + }, + "server_hostname": schema.StringAttribute{ + Description: "PXE server hostname (sname).", + Optional: true, + }, + "option_data": optionDataSchema("Extra DHCP options applied to matching clients."), + }, + } +} + +func (r *clientClassResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*apiClient) + if !ok { + resp.Diagnostics.AddError("unexpected provider data type", fmt.Sprintf("got %T", req.ProviderData)) + return + } + r.client = client +} + +func (r *clientClassResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan clientClassResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *clientClassResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan clientClassResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *clientClassResource) upsert(ctx context.Context, plan clientClassResourceModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := clientClassAPI{ + Name: plan.Name.ValueString(), + ClusterRef: plan.ClusterRef.ValueString(), + Test: plan.Test.ValueString(), + ArchHex: listToStrings(ctx, plan.ArchHex, diags), + BootFileName: plan.BootFileName.ValueString(), + NextServer: plan.NextServer.ValueString(), + ServerHostname: plan.ServerHostname.ValueString(), + OptionData: optionDataToAPI(ctx, plan.OptionData, diags), + } + if diags.HasError() { + return + } + var out clientClassAPI + if err := r.client.put(ctx, "/api/v1/clientclasses/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert clientclass failed", err.Error()) + return + } + diags.Append(state.Set(ctx, clientClassAPIToModel(out))...) +} + +func (r *clientClassResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state clientClassResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out clientClassAPI + if err := r.client.get(ctx, "/api/v1/clientclasses/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read clientclass failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, clientClassAPIToModel(out))...) +} + +func (r *clientClassResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state clientClassResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/clientclasses/"+pathEscape(state.Name.ValueString())); err != nil { + resp.Diagnostics.AddError("delete clientclass failed", err.Error()) + return + } +} + +func (r *clientClassResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func clientClassAPIToModel(api clientClassAPI) clientClassResourceModel { + return clientClassResourceModel{ + Name: types.StringValue(api.Name), + ClusterRef: stringOrNull(api.ClusterRef), + Test: stringOrNull(api.Test), + ArchHex: stringsToList(api.ArchHex), + BootFileName: stringOrNull(api.BootFileName), + NextServer: stringOrNull(api.NextServer), + ServerHostname: stringOrNull(api.ServerHostname), + OptionData: optionDataToList(api.OptionData), + } +} diff --git a/internal/provider/resource_subnet.go b/internal/provider/resource_subnet.go new file mode 100644 index 0000000..a668187 --- /dev/null +++ b/internal/provider/resource_subnet.go @@ -0,0 +1,219 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &subnetResource{} + _ resource.ResourceWithImportState = &subnetResource{} +) + +type subnetResource struct { + client *apiClient +} + +type subnetResourceModel struct { + Name types.String `tfsdk:"name"` + ClusterRef types.String `tfsdk:"cluster_ref"` + Subnet types.String `tfsdk:"subnet"` + SubnetID types.Int64 `tfsdk:"subnet_id"` + Pools types.List `tfsdk:"pools"` + Routers types.List `tfsdk:"routers"` + DNSServers types.List `tfsdk:"dns_servers"` + DomainName types.String `tfsdk:"domain_name"` + NextServer types.String `tfsdk:"next_server"` + BootFileName types.String `tfsdk:"boot_file_name"` + ClientClasses types.List `tfsdk:"client_classes"` + ValidLifetime types.Int64 `tfsdk:"valid_lifetime"` + OptionData types.List `tfsdk:"option_data"` +} + +func NewSubnetResource() resource.Resource { return &subnetResource{} } + +func (r *subnetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_subnet" +} + +func (r *subnetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A Kea DHCP subnet (KeaSubnet CR): its pool(s), routers, DNS, and PXE/boot options.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Stable resource id (the KeaSubnet CR name).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cluster_ref": schema.StringAttribute{ + Description: "Name of the KeaCluster this subnet belongs to.", + Optional: true, + }, + "subnet": schema.StringAttribute{ + Description: "Subnet in CIDR notation, e.g. 10.0.0.0/24.", + Required: true, + }, + "subnet_id": schema.Int64Attribute{ + Description: "Numeric Kea subnet id. Omit to let Kea assign one.", + Optional: true, + }, + "pools": schema.ListAttribute{ + Description: "Address pools, e.g. [\"10.0.0.100-10.0.0.200\"].", + ElementType: types.StringType, + Optional: true, + }, + "routers": schema.ListAttribute{ + Description: "Default gateway addresses (DHCP option routers).", + ElementType: types.StringType, + Optional: true, + }, + "dns_servers": schema.ListAttribute{ + Description: "DNS server addresses (domain-name-servers option).", + ElementType: types.StringType, + Optional: true, + }, + "domain_name": schema.StringAttribute{ + Description: "Domain name handed to clients.", + Optional: true, + }, + "next_server": schema.StringAttribute{ + Description: "PXE next-server (siaddr / TFTP server) address.", + Optional: true, + }, + "boot_file_name": schema.StringAttribute{ + Description: "PXE boot file name.", + Optional: true, + }, + "client_classes": schema.ListAttribute{ + Description: "Client classes required to receive a lease from this subnet.", + ElementType: types.StringType, + Optional: true, + }, + "valid_lifetime": schema.Int64Attribute{ + Description: "Lease valid lifetime in seconds.", + Optional: true, + }, + "option_data": optionDataSchema("Extra DHCP options applied to this subnet."), + }, + } +} + +func (r *subnetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*apiClient) + if !ok { + resp.Diagnostics.AddError("unexpected provider data type", fmt.Sprintf("got %T", req.ProviderData)) + return + } + r.client = client +} + +func (r *subnetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan subnetResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *subnetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan subnetResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *subnetResource) upsert(ctx context.Context, plan subnetResourceModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := subnetAPI{ + Name: plan.Name.ValueString(), + ClusterRef: plan.ClusterRef.ValueString(), + Subnet: plan.Subnet.ValueString(), + ID: int(plan.SubnetID.ValueInt64()), + Pools: listToStrings(ctx, plan.Pools, diags), + Routers: listToStrings(ctx, plan.Routers, diags), + DNSServers: listToStrings(ctx, plan.DNSServers, diags), + DomainName: plan.DomainName.ValueString(), + NextServer: plan.NextServer.ValueString(), + BootFileName: plan.BootFileName.ValueString(), + ClientClasses: listToStrings(ctx, plan.ClientClasses, diags), + ValidLifetime: int(plan.ValidLifetime.ValueInt64()), + OptionData: optionDataToAPI(ctx, plan.OptionData, diags), + } + if diags.HasError() { + return + } + var out subnetAPI + if err := r.client.put(ctx, "/api/v1/subnets/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert subnet failed", err.Error()) + return + } + diags.Append(state.Set(ctx, subnetAPIToModel(out))...) +} + +func (r *subnetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state subnetResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out subnetAPI + if err := r.client.get(ctx, "/api/v1/subnets/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read subnet failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, subnetAPIToModel(out))...) +} + +func (r *subnetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state subnetResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/subnets/"+pathEscape(state.Name.ValueString())); err != nil { + resp.Diagnostics.AddError("delete subnet failed", err.Error()) + return + } +} + +func (r *subnetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func subnetAPIToModel(api subnetAPI) subnetResourceModel { + return subnetResourceModel{ + Name: types.StringValue(api.Name), + ClusterRef: stringOrNull(api.ClusterRef), + Subnet: types.StringValue(api.Subnet), + SubnetID: int64OrNull(api.ID), + Pools: stringsToList(api.Pools), + Routers: stringsToList(api.Routers), + DNSServers: stringsToList(api.DNSServers), + DomainName: stringOrNull(api.DomainName), + NextServer: stringOrNull(api.NextServer), + BootFileName: stringOrNull(api.BootFileName), + ClientClasses: stringsToList(api.ClientClasses), + ValidLifetime: int64OrNull(api.ValidLifetime), + OptionData: optionDataToList(api.OptionData), + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8973069 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +// Command terraform-provider-kea is the Terraform/OpenTofu provider for the +// kea-operator KeaAPI: it manages Kea DHCP subnets and PXE client classes. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "git.unkin.net/unkin/terraform-provider-kea/internal/provider" +) + +var version = "0.0.1" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "enable debug mode") + flag.Parse() + + opts := providerserver.ServeOpts{ + Address: "git.unkin.net/unkin/kea", + Debug: debug, + } + + if err := providerserver.Serve(context.Background(), provider.New(version), opts); err != nil { + log.Fatal(err) + } +}