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
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,45 @@
|
||||
# terraform-provider-kea
|
||||
|
||||
Terraform provider for the kea-operator KeaAPI: manage Kea DHCP subnets and PXE client classes.
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user