Scaffold terraform-provider-tomswallapi #1

Merged
benvin merged 2 commits from benvin/scaffold-provider into main 2026-07-20 07:40:46 +10:00
22 changed files with 1908 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
terraform-provider-tomswallapi
*.zip
.terraform*
*.tfstate*
+24
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+40
View File
@@ -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
+19
View File
@@ -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
+61
View File
@@ -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
+73 -1
View File
@@ -1,3 +1,75 @@
# terraform-provider-tomswallapi
Terraform provider for tomswallapi: manage fleet-global firewall policy (zones, address groups, portgroups, rules, policies, fabrics) and per-device bindings.
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.
+90
View File
@@ -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
}
+30
View File
@@ -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
)
+91
View File
@@ -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=
+90
View File
@@ -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 &notFoundError{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
}
+66
View File
@@ -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
}
+83
View File
@@ -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
}
+183
View File
@@ -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
}
+155
View File
@@ -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),
}
}
+168
View File
@@ -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
}
+142
View File
@@ -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),
}
}
+141
View File
@@ -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),
}
}
+224
View File
@@ -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
}
+157
View File
@@ -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
}
+31
View File
@@ -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)
}
}