From 3913b3920d8fc3cebbb6a5596ce1dc6f9d42113e Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 19 Jul 2026 22:26:07 +1000 Subject: [PATCH] Scaffold terraform-provider-tomswallapi Terraform provider (plugin-framework) for the tomswall fleet control plane. Resources: zone, address_group (static/dns/asn, with computed resolved prefixes), portgroup, fabric, device, binding (device:zone), and rule. Each resource does full CRUD against the tomswallapi HTTP API with bearer-token auth and ImportState support; rules recreate on update since the rules API is create/delete only. Includes Makefile with make patch|minor|major release tags, Woodpecker pre-commit/build/test/release pipelines (release publishes to the artifactapi terraform registry), README, and a worked example. --- .gitignore | 4 + .pre-commit-config.yaml | 24 +++ .woodpecker/build.yml | 18 ++ .woodpecker/pre-commit.yaml | 18 ++ .woodpecker/release.yml | 40 ++++ .woodpecker/test.yml | 19 ++ Makefile | 61 ++++++ README.md | 75 +++++++ examples/main.tf | 90 ++++++++ go.mod | 30 +++ go.sum | 91 ++++++++ internal/provider/client.go | 90 ++++++++ internal/provider/helpers.go | 66 ++++++ internal/provider/provider.go | 83 ++++++++ internal/provider/resource_address_group.go | 183 ++++++++++++++++ internal/provider/resource_binding.go | 155 ++++++++++++++ internal/provider/resource_device.go | 168 +++++++++++++++ internal/provider/resource_fabric.go | 142 +++++++++++++ internal/provider/resource_portgroup.go | 141 ++++++++++++ internal/provider/resource_rule.go | 224 ++++++++++++++++++++ internal/provider/resource_zone.go | 157 ++++++++++++++ main.go | 31 +++ 22 files changed, 1910 insertions(+) create mode 100644 .gitignore 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 README.md create mode 100644 examples/main.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/provider/client.go create mode 100644 internal/provider/helpers.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/resource_address_group.go create mode 100644 internal/provider/resource_binding.go create mode 100644 internal/provider/resource_device.go create mode 100644 internal/provider/resource_fabric.go create mode 100644 internal/provider/resource_portgroup.go create mode 100644 internal/provider/resource_rule.go create mode 100644 internal/provider/resource_zone.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8304000 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +terraform-provider-tomswallapi +*.zip +.terraform* +*.tfstate* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d17912 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +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 + - id: check-merge-conflict + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -l -d + language: system + types: [go] + pass_filenames: true + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false 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..af7a739 --- /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-tomswallapi_$${VERSION}_linux_amd64.zip" + curl -f -X PUT \ + "https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/terraform-unkin/files/unkin/tomswallapi/$${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..aac1064 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - make lint + - 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..334e1d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: build install test lint fmt clean tidy package patch minor major + +BINARY := terraform-provider-tomswallapi +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/tomswallapi/$(INSTALL_VERSION)/$(OS_ARCH) +ZIP := $(BINARY)_$(INSTALL_VERSION)_$(OS_ARCH).zip + +GO_VERSION_REQUIRED := 1.25 +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 new file mode 100644 index 0000000..219026b --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# terraform-provider-tomswallapi + +Terraform/OpenTofu provider for [tomswallapi](https://git.unkin.net/unkin/tomswallapi), +the fleet control plane for tomswall. Declare the fleet-global firewall model — +zones, address groups, portgroups, rules, and fabrics — plus per-device +zone→interface bindings, as HCL. + +## Provider configuration + +```hcl +terraform { + required_providers { + tomswallapi = { + source = "git.unkin.net/unkin/tomswallapi" + } + } +} + +provider "tomswallapi" { + endpoint = "https://tomswallapi.k8s.syd1.au.unkin.net" + # token defaults to the TOMSWALLAPI_WRITE_TOKEN environment variable +} +``` + +## Resources + +| resource | key | notes | +|---|---|---| +| `tomswallapi_zone` | name | fleet-global segment; `subnets` may be empty for edge zones | +| `tomswallapi_address_group` | name | `type` = static/dns/asn; asn `resolved`/`resolved_at` are computed | +| `tomswallapi_portgroup` | name | reusable proto+ports | +| `tomswallapi_fabric` | name | `enforce_on_routers` toggles defense-in-depth | +| `tomswallapi_device` | name | `class` = router/firewall, `fabric`, `resolver`, `settings` | +| `tomswallapi_binding` | device:zone | zone→interface map (import as `device:zone`) | +| `tomswallapi_rule` | id | shorewall-style `source`/`dest` element lists | + +## Example + +See [`examples/`](examples/). A minimal A→cloudflare rule: + +```hcl +resource "tomswallapi_zone" "loc" { name = "loc" subnets = ["10.1.0.0/24"] } +resource "tomswallapi_zone" "net" { name = "net" } # edge zone, no subnets + +resource "tomswallapi_address_group" "cloudflare" { + name = "cloudflare" + type = "asn" + members = ["13335"] + refresh = "24h" +} + +resource "tomswallapi_rule" "cf_https" { + action = "accept" + source = ["loc"] + dest = ["net:+asn_cloudflare"] + proto = "tcp" + ports = ["443"] +} +``` + +## Development + +```sh +make build # build the provider binary +make install # install into ~/.terraform.d/plugins for local testing +make test # go test +make lint # go vet +``` + +## Releases + +`make patch|minor|major` tags and pushes the next `v*`, which triggers the +Woodpecker release pipeline to package the provider and publish it to the +artifactapi terraform registry (`terraform-unkin`), installable via the bare +`source = "git.unkin.net/unkin/tomswallapi"` address. diff --git a/examples/main.tf b/examples/main.tf new file mode 100644 index 0000000..2da76b3 --- /dev/null +++ b/examples/main.tf @@ -0,0 +1,90 @@ +terraform { + required_providers { + tomswallapi = { + source = "git.unkin.net/unkin/tomswallapi" + } + } +} + +provider "tomswallapi" { + endpoint = "https://tomswallapi.k8s.syd1.au.unkin.net" + # token from TOMSWALLAPI_WRITE_TOKEN +} + +# --- Fabric + zones --------------------------------------------------------- + +resource "tomswallapi_fabric" "core" { + name = "core" + enforce_on_routers = true +} + +resource "tomswallapi_zone" "zone_a" { + name = "zone-a" + subnets = ["10.1.0.0/24"] +} + +resource "tomswallapi_zone" "zone_b" { + name = "zone-b" + subnets = ["10.4.0.0/24"] +} + +# Internet-facing zone: no subnets, so rules must pair it with a selector. +resource "tomswallapi_zone" "net" { + name = "net" +} + +# --- Address group (ASN) ---------------------------------------------------- + +resource "tomswallapi_address_group" "cloudflare" { + name = "cloudflare" + type = "asn" + members = ["13335", "209242"] + refresh = "24h" +} + +# --- Portgroup -------------------------------------------------------------- + +resource "tomswallapi_portgroup" "https" { + name = "https" + proto = "tcp" + ports = ["443"] +} + +# --- Devices + bindings ----------------------------------------------------- + +resource "tomswallapi_device" "fw_a" { + name = "fw-a" + class = "firewall" + fabric = tomswallapi_fabric.core.name +} + +resource "tomswallapi_device" "rt1" { + name = "rt1" + class = "router" + fabric = tomswallapi_fabric.core.name +} + +resource "tomswallapi_binding" "fw_a_zone_a" { + device = tomswallapi_device.fw_a.name + zone = tomswallapi_zone.zone_a.name + interfaces = ["eth1"] +} + +# --- Rules (declared once, compiled to every device on the path) ------------ + +# zone-a can reach zone-b on ssh, through every hop. +resource "tomswallapi_rule" "ssh_a_to_b" { + action = "accept" + source = [tomswallapi_zone.zone_a.name] + dest = [tomswallapi_zone.zone_b.name] + proto = "tcp" + ports = ["22"] +} + +# zone-a to cloudflare's prefixes on https (edge zone gated by the asn set). +resource "tomswallapi_rule" "a_to_cloudflare" { + action = "accept" + source = [tomswallapi_zone.zone_a.name] + dest = ["net:+asn_cloudflare"] + portgroup = tomswallapi_portgroup.https.name +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..11b31a0 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.unkin.net/unkin/terraform-provider-tomswallapi + +go 1.25 + +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..115dca0 --- /dev/null +++ b/internal/provider/client.go @@ -0,0 +1,90 @@ +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) post(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPost, 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 +} + +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/helpers.go b/internal/provider/helpers.go new file mode 100644 index 0000000..e17ba69 --- /dev/null +++ b/internal/provider/helpers.go @@ -0,0 +1,66 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// configureClient extracts the shared *apiClient from provider data, used by +// every resource's Configure. +func configureClient(req resource.ConfigureRequest, resp *resource.ConfigureResponse) *apiClient { + if req.ProviderData == nil { + return nil + } + client, ok := req.ProviderData.(*apiClient) + if !ok { + resp.Diagnostics.AddError("unexpected provider data type", fmt.Sprintf("got %T", req.ProviderData)) + return nil + } + return client +} + +// listToStrings converts a Terraform list into a []string. A null/unknown list +// yields nil. +func listToStrings(ctx context.Context, l types.List, diags *diag.Diagnostics) []string { + if l.IsNull() || l.IsUnknown() { + return nil + } + var out []string + diags.Append(l.ElementsAs(ctx, &out, false)...) + return out +} + +// stringsToList converts a []string into a Terraform list of strings, rendering +// nil as an empty list so state stays consistent. +func stringsToList(ctx context.Context, s []string, diags *diag.Diagnostics) types.List { + if s == nil { + s = []string{} + } + l, d := types.ListValueFrom(ctx, types.StringType, s) + diags.Append(d...) + return l +} + +// mapToStrings converts a Terraform map into a map[string]string. +func mapToStrings(ctx context.Context, m types.Map, diags *diag.Diagnostics) map[string]string { + if m.IsNull() || m.IsUnknown() { + return nil + } + out := map[string]string{} + diags.Append(m.ElementsAs(ctx, &out, false)...) + return out +} + +// stringsToMap converts a map[string]string into a Terraform map. +func stringsToMap(ctx context.Context, m map[string]string, diags *diag.Diagnostics) types.Map { + if m == nil { + m = map[string]string{} + } + v, d := types.MapValueFrom(ctx, types.StringType, m) + diags.Append(d...) + return v +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..7f314b2 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,83 @@ +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 = &tomswallProvider{} + +type tomswallProvider struct { + version string +} + +type tomswallProviderModel 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 &tomswallProvider{version: version} } +} + +func (p *tomswallProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "tomswallapi" + resp.Version = p.version +} + +func (p *tomswallProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manage the tomswall fleet control plane: fleet-global zones, address groups, portgroups, rules, policies, and fabrics, plus per-device zone->interface bindings.", + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{ + Description: "The tomswallapi base URL (e.g. https://tomswallapi.k8s.syd1.au.unkin.net).", + Required: true, + }, + "token": schema.StringAttribute{ + Description: "Write token (bearer) for mutating operations. Defaults to the TOMSWALLAPI_WRITE_TOKEN environment variable.", + Optional: true, + Sensitive: true, + }, + }, + } +} + +func (p *tomswallProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config tomswallProviderModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + token := config.Token.ValueString() + if token == "" { + token = os.Getenv("TOMSWALLAPI_WRITE_TOKEN") + } + + client := newAPIClient(config.Endpoint.ValueString(), token) + resp.DataSourceData = client + resp.ResourceData = client +} + +func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewZoneResource, + NewFabricResource, + NewPortGroupResource, + NewAddressGroupResource, + NewDeviceResource, + NewBindingResource, + NewRuleResource, + } +} + +func (p *tomswallProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return nil +} diff --git a/internal/provider/resource_address_group.go b/internal/provider/resource_address_group.go new file mode 100644 index 0000000..59eb06a --- /dev/null +++ b/internal/provider/resource_address_group.go @@ -0,0 +1,183 @@ +package provider + +import ( + "context" + + "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 = &addressGroupResource{} + _ resource.ResourceWithImportState = &addressGroupResource{} +) + +type addressGroupResource struct{ client *apiClient } + +type addressGroupModel struct { + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Members types.List `tfsdk:"members"` + Refresh types.String `tfsdk:"refresh"` + Description types.String `tfsdk:"description"` + Resolved types.List `tfsdk:"resolved"` + ResolvedAt types.String `tfsdk:"resolved_at"` +} + +type addressGroupAPI struct { + Name string `json:"name"` + Type string `json:"type"` + Members []string `json:"members"` + Refresh string `json:"refresh,omitempty"` + Description string `json:"description,omitempty"` + Resolved []string `json:"resolved,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` +} + +func NewAddressGroupResource() resource.Resource { return &addressGroupResource{} } + +func (r *addressGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_address_group" +} + +func (r *addressGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "An address group, materialized as an nftables named set. Population source is static (CIDRs), dns (FQDNs resolved on-device), or asn (ASNs expanded centrally).", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Group name (globally unique). ASN groups conventionally use an asn_ prefix.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "type": schema.StringAttribute{ + Description: "Population source: static, dns, or asn.", + Required: true, + }, + "members": schema.ListAttribute{ + Description: "For static: CIDRs/IPs. For dns: FQDNs. For asn: ASN numbers.", + Required: true, + ElementType: types.StringType, + }, + "refresh": schema.StringAttribute{ + Description: "asn: prefix cache TTL (e.g. 24h). dns: honor_ttl.", + Optional: true, + }, + "description": schema.StringAttribute{ + Optional: true, + }, + "resolved": schema.ListAttribute{ + Description: "Server-managed: concrete prefixes the ASN expander last produced.", + Computed: true, + ElementType: types.StringType, + }, + "resolved_at": schema.StringAttribute{ + Description: "Server-managed: timestamp of the last ASN expansion.", + Computed: true, + }, + }, + } +} + +func (r *addressGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *addressGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan addressGroupModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *addressGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan addressGroupModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *addressGroupResource) upsert(ctx context.Context, plan addressGroupModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := addressGroupAPI{ + Name: plan.Name.ValueString(), + Type: plan.Type.ValueString(), + Members: listToStrings(ctx, plan.Members, diags), + Refresh: plan.Refresh.ValueString(), + Description: plan.Description.ValueString(), + } + if diags.HasError() { + return + } + var out addressGroupAPI + if err := r.client.put(ctx, "/api/v1/address-groups/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert address_group failed", err.Error()) + return + } + diags.Append(state.Set(ctx, r.toModel(ctx, out, plan, diags))...) +} + +func (r *addressGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state addressGroupModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out addressGroupAPI + if err := r.client.get(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read address_group failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *addressGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state addressGroupModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/address-groups/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete address_group failed", err.Error()) + } +} + +func (r *addressGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +// toModel maps the API response back to state. Optional string fields fall back +// to the prior plan/state value so that omitempty responses don't churn state. +func (r *addressGroupResource) toModel(ctx context.Context, api addressGroupAPI, prior addressGroupModel, diags *diag.Diagnostics) addressGroupModel { + m := addressGroupModel{ + Name: types.StringValue(api.Name), + Type: types.StringValue(api.Type), + Members: stringsToList(ctx, api.Members, diags), + Refresh: optionalString(api.Refresh, prior.Refresh), + Description: optionalString(api.Description, prior.Description), + Resolved: stringsToList(ctx, api.Resolved, diags), + ResolvedAt: types.StringValue(api.ResolvedAt), + } + return m +} + +// optionalString returns a value for an optional attribute: the API value when +// present, else the prior planned value (preserving null when both are empty). +func optionalString(apiVal string, prior types.String) types.String { + if apiVal != "" { + return types.StringValue(apiVal) + } + return prior +} diff --git a/internal/provider/resource_binding.go b/internal/provider/resource_binding.go new file mode 100644 index 0000000..cb8db0d --- /dev/null +++ b/internal/provider/resource_binding.go @@ -0,0 +1,155 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "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 = &bindingResource{} + _ resource.ResourceWithImportState = &bindingResource{} +) + +type bindingResource struct{ client *apiClient } + +type bindingModel struct { + Device types.String `tfsdk:"device"` + Zone types.String `tfsdk:"zone"` + Interfaces types.List `tfsdk:"interfaces"` +} + +type bindingAPI struct { + Device string `json:"device"` + Zone string `json:"zone"` + Interfaces []string `json:"interfaces"` +} + +func NewBindingResource() resource.Resource { return &bindingResource{} } + +func (r *bindingResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_binding" +} + +func (r *bindingResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Binds a global zone to a device's local interface(s) — the only legitimately host-specific object in the model.", + Attributes: map[string]schema.Attribute{ + "device": schema.StringAttribute{ + Description: "Device name.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "zone": schema.StringAttribute{ + Description: "Global zone name.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "interfaces": schema.ListAttribute{ + Description: "Local interface names this zone attaches to on the device, e.g. [\"eth1\"].", + Required: true, + ElementType: types.StringType, + }, + }, + } +} + +func (r *bindingResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *bindingResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan bindingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *bindingResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan bindingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *bindingResource) bindingPath(device, zone string) string { + return "/api/v1/devices/" + pathEscape(device) + "/bindings/" + pathEscape(zone) +} + +func (r *bindingResource) upsert(ctx context.Context, plan bindingModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := bindingAPI{ + Device: plan.Device.ValueString(), + Zone: plan.Zone.ValueString(), + Interfaces: listToStrings(ctx, plan.Interfaces, diags), + } + if diags.HasError() { + return + } + var out bindingAPI + if err := r.client.put(ctx, r.bindingPath(body.Device, body.Zone), body, &out); err != nil { + diags.AddError("upsert binding failed", err.Error()) + return + } + diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...) +} + +func (r *bindingResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state bindingModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out bindingAPI + if err := r.client.get(ctx, r.bindingPath(state.Device.ValueString(), state.Zone.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read binding failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...) +} + +func (r *bindingResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state bindingModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, r.bindingPath(state.Device.ValueString(), state.Zone.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete binding failed", err.Error()) + } +} + +// ImportState accepts "device:zone". +func (r *bindingResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + device, zone, ok := strings.Cut(req.ID, ":") + if !ok || device == "" || zone == "" { + resp.Diagnostics.AddError("invalid import ID", fmt.Sprintf("expected device:zone, got %q", req.ID)) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("device"), device)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("zone"), zone)...) +} + +func (r *bindingResource) toModel(ctx context.Context, api bindingAPI, diags *diag.Diagnostics) bindingModel { + return bindingModel{ + Device: types.StringValue(api.Device), + Zone: types.StringValue(api.Zone), + Interfaces: stringsToList(ctx, api.Interfaces, diags), + } +} diff --git a/internal/provider/resource_device.go b/internal/provider/resource_device.go new file mode 100644 index 0000000..2d08281 --- /dev/null +++ b/internal/provider/resource_device.go @@ -0,0 +1,168 @@ +package provider + +import ( + "context" + + "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 = &deviceResource{} + _ resource.ResourceWithImportState = &deviceResource{} +) + +type deviceResource struct{ client *apiClient } + +type deviceModel struct { + Name types.String `tfsdk:"name"` + Class types.String `tfsdk:"class"` + Fabric types.String `tfsdk:"fabric"` + Resolver types.List `tfsdk:"resolver"` + Settings types.Map `tfsdk:"settings"` +} + +type deviceAPI struct { + Name string `json:"name"` + Class string `json:"class"` + Fabric string `json:"fabric,omitempty"` + Resolver []string `json:"resolver,omitempty"` + Settings map[string]string `json:"settings,omitempty"` +} + +func NewDeviceResource() resource.Resource { return &deviceResource{} } + +func (r *deviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_device" +} + +func (r *deviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A fleet member. Its class (router/firewall) and fabric determine how it enforces intents; bindings map global zones to its local interfaces.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Device name (globally unique).", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "class": schema.StringAttribute{ + Description: "Device class: router or firewall.", + Required: true, + }, + "fabric": schema.StringAttribute{ + Description: "Routing domain (fabric) this device belongs to.", + Optional: true, + }, + "resolver": schema.ListAttribute{ + Description: "Per-device DNS resolver override for dns address groups. Falls back to the fleet default, then system resolvers.", + Optional: true, + ElementType: types.StringType, + }, + "settings": schema.MapAttribute{ + Description: "Per-device settings overrides (e.g. table_name, log_level, address_family).", + Optional: true, + ElementType: types.StringType, + }, + }, + } +} + +func (r *deviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *deviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan deviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *deviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan deviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *deviceResource) upsert(ctx context.Context, plan deviceModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := deviceAPI{ + Name: plan.Name.ValueString(), + Class: plan.Class.ValueString(), + Fabric: plan.Fabric.ValueString(), + Resolver: listToStrings(ctx, plan.Resolver, diags), + Settings: mapToStrings(ctx, plan.Settings, diags), + } + if diags.HasError() { + return + } + var out deviceAPI + if err := r.client.put(ctx, "/api/v1/devices/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert device failed", err.Error()) + return + } + diags.Append(state.Set(ctx, r.toModel(ctx, out, plan, diags))...) +} + +func (r *deviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state deviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out deviceAPI + if err := r.client.get(ctx, "/api/v1/devices/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read device failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *deviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state deviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/devices/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete device failed", err.Error()) + } +} + +func (r *deviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func (r *deviceResource) toModel(ctx context.Context, api deviceAPI, prior deviceModel, diags *diag.Diagnostics) deviceModel { + m := deviceModel{ + Name: types.StringValue(api.Name), + Class: types.StringValue(api.Class), + Fabric: optionalString(api.Fabric, prior.Fabric), + } + // Preserve null for optional collections the user left unset. + if prior.Resolver.IsNull() && len(api.Resolver) == 0 { + m.Resolver = types.ListNull(types.StringType) + } else { + m.Resolver = stringsToList(ctx, api.Resolver, diags) + } + if prior.Settings.IsNull() && len(api.Settings) == 0 { + m.Settings = types.MapNull(types.StringType) + } else { + m.Settings = stringsToMap(ctx, api.Settings, diags) + } + return m +} diff --git a/internal/provider/resource_fabric.go b/internal/provider/resource_fabric.go new file mode 100644 index 0000000..62b3745 --- /dev/null +++ b/internal/provider/resource_fabric.go @@ -0,0 +1,142 @@ +package provider + +import ( + "context" + + "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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 = &fabricResource{} + _ resource.ResourceWithImportState = &fabricResource{} +) + +type fabricResource struct{ client *apiClient } + +type fabricModel struct { + Name types.String `tfsdk:"name"` + EnforceOnRouters types.Bool `tfsdk:"enforce_on_routers"` + Description types.String `tfsdk:"description"` +} + +type fabricAPI struct { + Name string `json:"name"` + EnforceOnRouters bool `json:"enforce_on_routers"` + Description string `json:"description,omitempty"` +} + +func NewFabricResource() resource.Resource { return &fabricResource{} } + +func (r *fabricResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_fabric" +} + +func (r *fabricResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A routing domain. enforce_on_routers toggles defense-in-depth (every router carries the intent) versus transparent transit (only boundary firewalls enforce).", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Fabric name (globally unique).", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "enforce_on_routers": schema.BoolAttribute{ + Description: "When true, routers in this fabric enforce intents too (defense-in-depth).", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + }, + } +} + +func (r *fabricResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *fabricResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan fabricModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *fabricResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan fabricModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *fabricResource) upsert(ctx context.Context, plan fabricModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := fabricAPI{ + Name: plan.Name.ValueString(), + EnforceOnRouters: plan.EnforceOnRouters.ValueBool(), + Description: plan.Description.ValueString(), + } + var out fabricAPI + if err := r.client.put(ctx, "/api/v1/fabrics/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert fabric failed", err.Error()) + return + } + diags.Append(state.Set(ctx, fabricToModel(out))...) +} + +func (r *fabricResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state fabricModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out fabricAPI + if err := r.client.get(ctx, "/api/v1/fabrics/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read fabric failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, fabricToModel(out))...) +} + +func (r *fabricResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state fabricModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/fabrics/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete fabric failed", err.Error()) + } +} + +func (r *fabricResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func fabricToModel(api fabricAPI) fabricModel { + return fabricModel{ + Name: types.StringValue(api.Name), + EnforceOnRouters: types.BoolValue(api.EnforceOnRouters), + Description: types.StringValue(api.Description), + } +} diff --git a/internal/provider/resource_portgroup.go b/internal/provider/resource_portgroup.go new file mode 100644 index 0000000..feb7c8a --- /dev/null +++ b/internal/provider/resource_portgroup.go @@ -0,0 +1,141 @@ +package provider + +import ( + "context" + + "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 = &portGroupResource{} + _ resource.ResourceWithImportState = &portGroupResource{} +) + +type portGroupResource struct{ client *apiClient } + +type portGroupModel struct { + Name types.String `tfsdk:"name"` + Proto types.String `tfsdk:"proto"` + Ports types.List `tfsdk:"ports"` +} + +type portGroupAPI struct { + Name string `json:"name"` + Proto string `json:"proto"` + Ports []string `json:"ports"` +} + +func NewPortGroupResource() resource.Resource { return &portGroupResource{} } + +func (r *portGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_portgroup" +} + +func (r *portGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A reusable proto+ports combination referenced by rules.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Portgroup name (globally unique).", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "proto": schema.StringAttribute{ + Description: "Protocol, e.g. tcp or udp.", + Required: true, + }, + "ports": schema.ListAttribute{ + Description: "Ports or port ranges, e.g. [\"80\", \"443\", \"1024-65535\"].", + Required: true, + ElementType: types.StringType, + }, + }, + } +} + +func (r *portGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *portGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan portGroupModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *portGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan portGroupModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *portGroupResource) upsert(ctx context.Context, plan portGroupModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := portGroupAPI{ + Name: plan.Name.ValueString(), + Proto: plan.Proto.ValueString(), + Ports: listToStrings(ctx, plan.Ports, diags), + } + if diags.HasError() { + return + } + var out portGroupAPI + if err := r.client.put(ctx, "/api/v1/portgroups/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert portgroup failed", err.Error()) + return + } + diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...) +} + +func (r *portGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state portGroupModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out portGroupAPI + if err := r.client.get(ctx, "/api/v1/portgroups/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read portgroup failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...) +} + +func (r *portGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state portGroupModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/portgroups/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete portgroup failed", err.Error()) + } +} + +func (r *portGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func (r *portGroupResource) toModel(ctx context.Context, api portGroupAPI, diags *diag.Diagnostics) portGroupModel { + return portGroupModel{ + Name: types.StringValue(api.Name), + Proto: types.StringValue(api.Proto), + Ports: stringsToList(ctx, api.Ports, diags), + } +} diff --git a/internal/provider/resource_rule.go b/internal/provider/resource_rule.go new file mode 100644 index 0000000..f20707e --- /dev/null +++ b/internal/provider/resource_rule.go @@ -0,0 +1,224 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &ruleResource{} + _ resource.ResourceWithImportState = &ruleResource{} +) + +type ruleResource struct{ client *apiClient } + +type ruleModel struct { + ID types.Int64 `tfsdk:"id"` + Priority types.Int64 `tfsdk:"priority"` + Action types.String `tfsdk:"action"` + Source types.List `tfsdk:"source"` + Dest types.List `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + PortGroup types.String `tfsdk:"portgroup"` + Ports types.List `tfsdk:"ports"` + Log types.String `tfsdk:"log"` + Comment types.String `tfsdk:"comment"` +} + +type ruleAPI struct { + ID int64 `json:"id,omitempty"` + Priority int `json:"priority"` + Action string `json:"action"` + Source []string `json:"source"` + Dest []string `json:"dest"` + Proto string `json:"proto,omitempty"` + PortGroup string `json:"portgroup,omitempty"` + Ports []string `json:"ports,omitempty"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewRuleResource() resource.Resource { return &ruleResource{} } + +func (r *ruleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_rule" +} + +func (r *ruleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A fleet-global firewall intent. Source and dest are shorewall-style element lists (bare zone, or zone:+ipset / zone:&fqdn); a selector must always be paired with a zone.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Description: "Server-assigned rule id.", + Computed: true, + PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}, + }, + "priority": schema.Int64Attribute{ + Description: "Evaluation priority; lower is evaluated first.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(0), + }, + "action": schema.StringAttribute{ + Description: "accept, drop, reject, etc.", + Required: true, + }, + "source": schema.ListAttribute{ + Description: "Source element list, e.g. [\"loc\", \"net:+asn_cloudflare\"].", + Required: true, + ElementType: types.StringType, + }, + "dest": schema.ListAttribute{ + Description: "Dest element list.", + Required: true, + ElementType: types.StringType, + }, + "proto": schema.StringAttribute{ + Description: "Protocol (used when portgroup is not set).", + Optional: true, + }, + "portgroup": schema.StringAttribute{ + Description: "Named portgroup supplying proto+ports.", + Optional: true, + }, + "ports": schema.ListAttribute{ + Description: "Explicit ports (used when portgroup is not set).", + Optional: true, + ElementType: types.StringType, + }, + "log": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *ruleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *ruleResource) body(ctx context.Context, plan ruleModel, diags *diag.Diagnostics) ruleAPI { + return ruleAPI{ + Priority: int(plan.Priority.ValueInt64()), + Action: plan.Action.ValueString(), + Source: listToStrings(ctx, plan.Source, diags), + Dest: listToStrings(ctx, plan.Dest, diags), + Proto: plan.Proto.ValueString(), + PortGroup: plan.PortGroup.ValueString(), + Ports: listToStrings(ctx, plan.Ports, diags), + Log: plan.Log.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *ruleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ruleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out ruleAPI + if err := r.client.post(ctx, "/api/v1/rules", body, &out); err != nil { + resp.Diagnostics.AddError("create rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +// Update: the rules API is create/delete only (no in-place PUT), so replace by +// deleting the old id and creating the new definition, keeping it in one apply. +func (r *ruleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state ruleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out ruleAPI + if err := r.client.post(ctx, "/api/v1/rules", body, &out); err != nil { + resp.Diagnostics.AddError("recreate rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *ruleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ruleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out ruleAPI + if err := r.client.get(ctx, "/api/v1/rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *ruleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ruleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete rule failed", err.Error()) + } +} + +func (r *ruleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *ruleResource) toModel(ctx context.Context, api ruleAPI, prior ruleModel, diags *diag.Diagnostics) ruleModel { + m := ruleModel{ + ID: types.Int64Value(api.ID), + Priority: types.Int64Value(int64(api.Priority)), + Action: types.StringValue(api.Action), + Source: stringsToList(ctx, api.Source, diags), + Dest: stringsToList(ctx, api.Dest, diags), + Proto: optionalString(api.Proto, prior.Proto), + PortGroup: optionalString(api.PortGroup, prior.PortGroup), + Log: optionalString(api.Log, prior.Log), + Comment: optionalString(api.Comment, prior.Comment), + } + // ports is optional; preserve null when unset and the API returns none. + if prior.Ports.IsNull() && len(api.Ports) == 0 { + m.Ports = types.ListNull(types.StringType) + } else { + m.Ports = stringsToList(ctx, api.Ports, diags) + } + return m +} diff --git a/internal/provider/resource_zone.go b/internal/provider/resource_zone.go new file mode 100644 index 0000000..a4ed48f --- /dev/null +++ b/internal/provider/resource_zone.go @@ -0,0 +1,157 @@ +package provider + +import ( + "context" + + "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/stringdefault" + "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 = &zoneResource{} + _ resource.ResourceWithImportState = &zoneResource{} +) + +type zoneResource struct{ client *apiClient } + +type zoneModel struct { + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Subnets types.List `tfsdk:"subnets"` + Parent types.String `tfsdk:"parent"` +} + +type zoneAPI struct { + Name string `json:"name"` + Type string `json:"type"` + Subnets []string `json:"subnets"` + Parent string `json:"parent,omitempty"` +} + +func NewZoneResource() resource.Resource { return &zoneResource{} } + +func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_zone" +} + +func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A fleet-global network segment (a named set of subnets). Zones are the topological anchors of the firewall model.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Zone name (globally unique).", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "type": schema.StringAttribute{ + Description: "Zone type: ip, ip6, or firewall.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("ip"), + }, + "subnets": schema.ListAttribute{ + Description: "CIDRs belonging to this zone. May be empty for a no-subnet zone such as an internet-facing 'net'.", + Optional: true, + ElementType: types.StringType, + }, + "parent": schema.StringAttribute{ + Description: "Parent zone name for subzone nesting; the child's subnets must be within the parent's.", + Optional: true, + }, + }, + } +} + +func (r *zoneResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan zoneModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan zoneModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.upsert(ctx, plan, &resp.Diagnostics, &resp.State) +} + +func (r *zoneResource) upsert(ctx context.Context, plan zoneModel, diags *diag.Diagnostics, state *tfsdk.State) { + body := zoneAPI{ + Name: plan.Name.ValueString(), + Type: plan.Type.ValueString(), + Subnets: listToStrings(ctx, plan.Subnets, diags), + Parent: plan.Parent.ValueString(), + } + if diags.HasError() { + return + } + var out zoneAPI + if err := r.client.put(ctx, "/api/v1/zones/"+pathEscape(body.Name), body, &out); err != nil { + diags.AddError("upsert zone failed", err.Error()) + return + } + diags.Append(state.Set(ctx, r.toModel(ctx, out, diags))...) +} + +func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state zoneModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out zoneAPI + if err := r.client.get(ctx, "/api/v1/zones/"+pathEscape(state.Name.ValueString()), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read zone failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, &resp.Diagnostics))...) +} + +func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state zoneModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/zones/"+pathEscape(state.Name.ValueString())); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete zone failed", err.Error()) + } +} + +func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} + +func (r *zoneResource) toModel(ctx context.Context, api zoneAPI, diags *diag.Diagnostics) zoneModel { + m := zoneModel{ + Name: types.StringValue(api.Name), + Type: types.StringValue(api.Type), + Subnets: stringsToList(ctx, api.Subnets, diags), + } + if api.Parent == "" { + m.Parent = types.StringNull() + } else { + m.Parent = types.StringValue(api.Parent) + } + return m +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..841d06f --- /dev/null +++ b/main.go @@ -0,0 +1,31 @@ +// Command terraform-provider-tomswallapi is the Terraform/OpenTofu provider for +// tomswallapi: it manages the fleet-global firewall model (zones, address groups, +// portgroups, rules, policies, fabrics) and per-device zone->interface bindings. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "git.unkin.net/unkin/terraform-provider-tomswallapi/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/tomswallapi", + Debug: debug, + } + + if err := providerserver.Serve(context.Background(), provider.New(version), opts); err != nil { + log.Fatal(err) + } +} -- 2.47.3