hhctl CLI and Terraform provider for Hammerhead

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:02:15 -06:00
commit bf340cdab4
19 files changed
+2442

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
hhctl
*.exe
.terraform*
*.tfstate*
+124
View File
@@ -0,0 +1,124 @@
# hhctl
A command-line tool and a Terraform provider for [Hammerhead](../hammerhead). Both talk only to Hammerhead's
public HTTP API with an API key, so they work against any server you can reach.
```
client/ Go API client (the only thing that speaks HTTP)
cmd/hhctl/ the CLI
cmd/terraform-provider-hammerhead/
internal/provider/ Terraform provider (terraform-plugin-framework)
internal/fakehh/ in-memory fake of the API, for tests
examples/ Terraform configuration and a shell script
```
Needs a Hammerhead with API-key support for server scope (branch `feature/api-tokens-hhctl`; see
`hammerhead/docs/api-automation.md` for the audit of what the API could and could not do before).
## Credentials
| Key | Made with | Can |
|---|---|---|
| Organization key | Hammerhead dashboard, or `POST /api/orgs/{org}/apikeys` | sites, ports, domains and alert channels of its organization; list edges it may use |
| Server key | `POST /api/admin/apikeys` as a signed-in server admin | everything above in any organization, plus edges, server alert channels and creating organizations |
The secret is shown once when the key is created. Keep it out of version control; pass it in the environment.
```sh
export HAMMERHEAD_URL=https://hammerhead.example.com
export HAMMERHEAD_TOKEN=hhk_...
export HAMMERHEAD_ORG=... # optional with an organization key
```
## CLI
```sh
go install git.veillax.com/Veillax/hhctl/cmd/hhctl@latest
hhctl orgs list
hhctl edges create --name fra --tunnel 203.0.113.5:51820 # server key; prints the edge's token
hhctl sites create --name home --tag lab # prints the agent's token
hhctl domains add --name example.com --edge fra
hhctl ports create --name web --proto https --edge fra --port 443 \
--hostname app.example.com --target home:192.168.1.30:8080
hhctl ports create -f port.json # any option, as JSON
hhctl ports list
hhctl alerts channel-create --name ops --kind email --target ops@example.com
hhctl alerts channels
hhctl ports delete <id>
```
Edges and sites can be given by name or ID. `-o json` prints the API's JSON (and the secret created, once) instead of a
table. Commands: `orgs`, `edges`, `sites`, `ports`, `domains` (`list`, `create`/`add`, `delete`) and `alerts`
(`list`, `channels`, `channel-create`, `channel-delete`, `channel-test`; `--server` for server-level ones).
See `hhctl -h`, and `examples/bootstrap.sh`.
## Terraform provider
Resources: `hammerhead_site`, `hammerhead_port`, `hammerhead_domain`, `hammerhead_edge`, `hammerhead_alert_channel`.
Data source: `hammerhead_edge` (by name).
```hcl
terraform {
required_providers {
hammerhead = { source = "veillax/hammerhead" }
}
}
provider "hammerhead" {} # url, token and org come from HAMMERHEAD_URL / _TOKEN / _ORG
resource "hammerhead_site" "home" {
name = "home"
tags = ["lab"]
}
resource "hammerhead_port" "web" {
name = "web"
proto = "https"
edge_id = data.hammerhead_edge.fra.id
port = 443
hostname = "app.example.com"
target {
site_id = hammerhead_site.home.id
host = "192.168.1.30"
port = 8080
}
}
```
A full example is in `examples/main.tf`. Notes:
- `hammerhead_site.token` and `hammerhead_edge.token` are sensitive outputs: hand them to the Bamboo / Blacktip program.
- Ports are replaced wholesale by the API. `password` and `pin` are sent only when they change, because changing
them signs every visitor out. Hammerhead never returns them, so out-of-band changes show up only as a
removed password.
- The server does not return an alert channel's webhook URL or Sandbar key, so those are kept as configured and
drift in them is not detected.
- Domains cannot be edited in place; any change replaces them.
- Deleting an edge or a site in Hammerhead also deletes the ports that depend on it; let Terraform order the
destroy via references as in the example.
- Import: `terraform import hammerhead_site.home <id>` (same for port, domain, edge); alert channels take `<id>` or
`server/<id>`, and need `target` set in configuration after import.
For local use, build the provider and point Terraform at it with a `dev_overrides` block in `~/.terraformrc`:
```sh
go build -o ~/go/bin/terraform-provider-hammerhead ./cmd/terraform-provider-hammerhead
```
```hcl
provider_installation {
dev_overrides { "veillax/hammerhead" = "/home/you/go/bin" }
direct {}
}
```
## Tests
```sh
go test ./...
```
The tests run against `internal/fakehh`, a small in-memory imitation of the API, and drive the provider's
CRUD methods directly (no `terraform` binary needed). The fake was checked by hand against a real Hammerhead
for the CLI flows; there are no automated acceptance tests (`terraform-plugin-testing`) yet.
+109
View File
@@ -0,0 +1,109 @@
// Package client talks to Hammerhead's public HTTP API with an API key.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client is a Hammerhead API client. Org is the default organization for org-scoped calls.
type Client struct {
BaseURL string
Token string
Org string
HTTP *http.Client
}
// New returns a client for the server at baseURL authenticating with an hhk_ API key.
func New(baseURL, token, org string) *Client {
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, Org: org, HTTP: &http.Client{Timeout: 30 * time.Second}}
}
// APIError is an error response from the server.
type APIError struct {
Status int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("hammerhead: %s (HTTP %d)", e.Message, e.Status)
}
// IsNotFound reports whether err is a 404 from the server.
func IsNotFound(err error) bool {
e, ok := err.(*APIError)
return ok && e.Status == http.StatusNotFound
}
func (c *Client) do(method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return err
}
if resp.StatusCode >= 300 {
var e struct {
Error string `json:"error"`
}
msg := strings.TrimSpace(string(raw))
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
msg = e.Error
}
return &APIError{resp.StatusCode, msg}
}
if out != nil && len(raw) > 0 {
return json.Unmarshal(raw, out)
}
return nil
}
func (c *Client) orgPath(format string, args ...any) (string, error) {
if c.Org == "" {
return "", fmt.Errorf("no organization set (use --org, HAMMERHEAD_ORG, or an organization key)")
}
return "/api/orgs/" + url.PathEscape(c.Org) + fmt.Sprintf(format, args...), nil
}
func esc(id string) string { return url.PathEscape(id) }
// ResolveOrg fills in Org when the key can see exactly one organization.
func (c *Client) ResolveOrg() error {
if c.Org != "" {
return nil
}
orgs, err := c.Orgs()
if err != nil {
return err
}
if len(orgs) != 1 {
return fmt.Errorf("the key can reach %d organizations; choose one with --org", len(orgs))
}
c.Org = orgs[0].ID
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package client_test
import (
"testing"
"git.veillax.com/Veillax/hhctl/client"
"git.veillax.com/Veillax/hhctl/internal/fakehh"
)
func TestResourcesRoundTrip(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
c := client.New(fs.URL, fakehh.ServerKey, "")
if err := c.ResolveOrg(); err != nil || c.Org != fakehh.OrgID {
t.Fatalf("resolve org: %q %v", c.Org, err)
}
edge, err := c.CreateEdge(client.Edge{Name: "fra", TunnelAddr: "1.2.3.4:51820"})
if err != nil || edge.ID == "" || edge.Token == "" {
t.Fatalf("create edge: %+v %v", edge, err)
}
site, err := c.CreateSite("home", []string{"lab"})
if err != nil || site.Token == "" {
t.Fatalf("create site: %+v %v", site, err)
}
got, err := c.GetSite(site.ID)
if err != nil || got.Name != "home" || len(got.Tags) != 1 {
t.Fatalf("get site: %+v %v", got, err)
}
if err := c.UpdateSite(site.ID, "house", nil); err != nil {
t.Fatal(err)
}
if got, _ = c.GetSite(site.ID); got.Name != "house" {
t.Fatalf("rename: %+v", got)
}
port, err := c.CreatePort(client.Port{Name: "web", Proto: "https", EdgeID: edge.ID, Port: 443, Hostname: "a.example.com", Enabled: true,
Targets: []client.Target{{SiteID: site.ID, Host: "10.0.0.2", Port: 80}}})
if err != nil || port.ID == "" {
t.Fatalf("create port: %+v %v", port, err)
}
port.Enabled = false
if _, err := c.UpdatePort(*port); err != nil {
t.Fatal(err)
}
if p, _ := c.GetPort(port.ID); p.Enabled || p.Targets[0].Host != "10.0.0.2" {
t.Fatalf("update port: %+v", p)
}
dom, err := c.CreateDomain(client.Domain{Name: "example.com", EdgeID: edge.ID})
if err != nil {
t.Fatal(err)
}
id, err := c.CreateChannel(false, client.Channel{Name: "ops", Kind: "email", Target: "ops@example.com", Enabled: true})
if err != nil || id == "" {
t.Fatalf("channel id: %q %v", id, err)
}
al, err := c.Alerts(false)
if err != nil || len(al.Channels) != 1 {
t.Fatalf("alerts: %+v %v", al, err)
}
for _, del := range []func() error{
func() error { return c.DeleteChannel(false, id) },
func() error { return c.DeleteDomain(dom.ID) },
func() error { return c.DeletePort(port.ID) },
func() error { return c.DeleteSite(site.ID) },
func() error { return c.DeleteEdge(edge.ID) },
} {
if err := del(); err != nil {
t.Fatal(err)
}
}
if _, err := c.GetSite(site.ID); !client.IsNotFound(err) {
t.Fatalf("want not found, got %v", err)
}
}
func TestBadKeyAndOrgScope(t *testing.T) {
fs := fakehh.New()
defer fs.Close()
if _, err := client.New(fs.URL, "nope", fakehh.OrgID).Sites(); err == nil {
t.Fatal("bad key accepted")
}
c := client.New(fs.URL, fakehh.OrgKey, "")
if _, err := c.Sites(); err == nil {
t.Fatal("expected an error without an org")
}
if _, err := c.CreateEdge(client.Edge{Name: "x", TunnelAddr: "a:1"}); err == nil {
t.Fatal("org key must not create edges")
}
}
+287
View File
@@ -0,0 +1,287 @@
package client
import (
"fmt"
"net/http"
)
func notFound(what string) error { return &APIError{http.StatusNotFound, "no such " + what} }
// ---- organizations ----
func (c *Client) Orgs() ([]Org, error) {
var out []Org
return out, c.do("GET", "/api/orgs", nil, &out)
}
// CreateOrg needs a server key.
func (c *Client) CreateOrg(name string, allowLocal bool) (*Org, error) {
var out Org
err := c.do("POST", "/api/orgs", map[string]any{"name": name, "allowLocal": allowLocal}, &out)
return &out, err
}
func (c *Client) DeleteOrg(id string) error { return c.do("DELETE", "/api/orgs/"+esc(id), nil, nil) }
func (c *Client) State() (*State, error) {
p, err := c.orgPath("/state")
if err != nil {
return nil, err
}
var out State
return &out, c.do("GET", p, nil, &out)
}
// ---- edges ----
func (c *Client) Edges() ([]Edge, error) {
var out []Edge
return out, c.do("GET", "/api/edges", nil, &out)
}
func (c *Client) GetEdge(id string) (*Edge, error) {
edges, err := c.Edges()
if err != nil {
return nil, err
}
for i := range edges {
if edges[i].ID == id {
return &edges[i], nil
}
}
return nil, notFound("edge")
}
// CreateEdge needs a server key. The result carries the edge's token.
func (c *Client) CreateEdge(e Edge) (*Edge, error) {
var out Edge
err := c.do("POST", "/api/edges", e, &out)
return &out, err
}
func (c *Client) UpdateEdge(e Edge) (*Edge, error) {
var out Edge
err := c.do("PUT", "/api/edges/"+esc(e.ID), e, &out)
return &out, err
}
func (c *Client) DeleteEdge(id string) error { return c.do("DELETE", "/api/edges/"+esc(id), nil, nil) }
// ---- sites ----
func (c *Client) Sites() ([]Site, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Sites, nil
}
func (c *Client) GetSite(id string) (*Site, error) {
sites, err := c.Sites()
if err != nil {
return nil, err
}
for i := range sites {
if sites[i].ID == id {
return &sites[i], nil
}
}
return nil, notFound("site")
}
// CreateSite returns the site including its agent token.
func (c *Client) CreateSite(name string, tags []string) (*Site, error) {
p, err := c.orgPath("/sites")
if err != nil {
return nil, err
}
var out Site
if err := c.do("POST", p, map[string]string{"name": name}, &out); err != nil {
return nil, err
}
if len(tags) > 0 {
if err := c.UpdateSite(out.ID, name, tags); err != nil {
return &out, err
}
out.Tags = tags
}
return &out, nil
}
func (c *Client) UpdateSite(id, name string, tags []string) error {
p, err := c.orgPath("/sites/%s", esc(id))
if err != nil {
return err
}
if tags == nil {
tags = []string{}
}
return c.do("PUT", p, map[string]any{"name": name, "tags": tags}, nil)
}
func (c *Client) DeleteSite(id string) error {
p, err := c.orgPath("/sites/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- domains ----
func (c *Client) Domains() ([]Domain, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Domains, nil
}
func (c *Client) GetDomain(id string) (*Domain, error) {
ds, err := c.Domains()
if err != nil {
return nil, err
}
for i := range ds {
if ds[i].ID == id {
return &ds[i], nil
}
}
return nil, notFound("domain")
}
func (c *Client) CreateDomain(d Domain) (*Domain, error) {
p, err := c.orgPath("/domains")
if err != nil {
return nil, err
}
var out Domain
return &out, c.do("POST", p, d, &out)
}
func (c *Client) DeleteDomain(id string) error {
p, err := c.orgPath("/domains/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- ports ----
func (c *Client) Ports() ([]Port, error) {
st, err := c.State()
if err != nil {
return nil, err
}
return st.Mappings, nil
}
func (c *Client) GetPort(id string) (*Port, error) {
ps, err := c.Ports()
if err != nil {
return nil, err
}
for i := range ps {
if ps[i].ID == id {
return &ps[i], nil
}
}
return nil, notFound("port")
}
func (c *Client) CreatePort(pt Port) (*Port, error) {
p, err := c.orgPath("/mappings")
if err != nil {
return nil, err
}
var out Port
return &out, c.do("POST", p, pt, &out)
}
// UpdatePort replaces the port; every field is overwritten.
func (c *Client) UpdatePort(pt Port) (*Port, error) {
p, err := c.orgPath("/mappings/%s", esc(pt.ID))
if err != nil {
return nil, err
}
var out Port
return &out, c.do("PUT", p, pt, &out)
}
func (c *Client) DeletePort(id string) error {
p, err := c.orgPath("/mappings/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
// ---- alert channels (the organization's, or with server=true the server-level ones) ----
func (c *Client) alertPath(server bool, format string, args ...any) (string, error) {
if server {
return "/api/admin/alerts" + fmt.Sprintf(format, args...), nil
}
return c.orgPath("/alerts"+format, args...)
}
func (c *Client) Alerts(server bool) (*Alerts, error) {
p, err := c.alertPath(server, "")
if err != nil {
return nil, err
}
var out Alerts
return &out, c.do("GET", p, nil, &out)
}
func (c *Client) CreateChannel(server bool, ch Channel) (string, error) {
p, err := c.alertPath(server, "/channels")
if err != nil {
return "", err
}
var out struct {
ID string `json:"id"`
}
err = c.do("POST", p, ch, &out)
return out.ID, err
}
func (c *Client) UpdateChannel(server bool, ch Channel) error {
p, err := c.alertPath(server, "/channels/%s", esc(ch.ID))
if err != nil {
return err
}
return c.do("PUT", p, ch, nil)
}
func (c *Client) DeleteChannel(server bool, id string) error {
p, err := c.alertPath(server, "/channels/%s", esc(id))
if err != nil {
return err
}
return c.do("DELETE", p, nil, nil)
}
func (c *Client) TestChannel(server bool, id string) error {
p, err := c.alertPath(server, "/channels/%s/test", esc(id))
if err != nil {
return err
}
return c.do("POST", p, nil, nil)
}
// CreateOrgKey returns the key's id and its secret token (shown once).
func (c *Client) CreateOrgKey(name string) (id, key string, err error) {
p, err := c.orgPath("/apikeys")
if err != nil {
return
}
var out struct {
ID string `json:"id"`
Key string `json:"key"`
}
err = c.do("POST", p, map[string]string{"name": name}, &out)
return out.ID, out.Key, err
}
+134
View File
@@ -0,0 +1,134 @@
package client
import "time"
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
AllowLocal bool `json:"allowLocal"`
}
type Edge struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
IPs []string `json:"ips,omitempty"`
Online bool `json:"online,omitempty"`
TunnelAddr string `json:"tunnelAddr,omitempty"`
Token string `json:"token,omitempty"`
OrgIDs []string `json:"orgIds"`
Version string `json:"version,omitempty"`
}
type Site struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
Connected []string `json:"connected"`
LastSeen time.Time `json:"lastSeen"`
Local bool `json:"local"`
EdgeID string `json:"edgeId,omitempty"`
Tags []string `json:"tags,omitempty"`
TunnelIP string `json:"tunnelIp,omitempty"`
Version string `json:"version,omitempty"`
}
type Domain struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
EdgeID string `json:"edgeId"`
IP string `json:"ip"`
Note string `json:"note"`
}
type Target struct {
SiteID string `json:"siteId"`
Host string `json:"host"`
Port int `json:"port"`
}
type Auth struct {
SSO bool `json:"sso"`
Password bool `json:"password"`
Pin bool `json:"pin"`
Email bool `json:"email"`
AllowedRoles []string `json:"allowedRoles"`
AllowedUsers []string `json:"allowedUsers"`
AllowedMails []string `json:"allowedMails"`
SessionHours int `json:"sessionHours"`
}
type Rule struct {
Action string `json:"action"`
Match string `json:"match"`
Value string `json:"value"`
}
// Port is what the dashboard calls a port (the server's "mapping"): a public port or hostname
// on an edge, forwarded to targets. NewPassword and NewPin are write-only: nil keeps, "" removes.
type Port struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Proto string `json:"proto"`
EdgeID string `json:"edgeId"`
BindIP string `json:"bindIp"`
Port int `json:"port"`
PortEnd int `json:"portEnd,omitempty"`
Hostname string `json:"hostname,omitempty"`
Targets []Target `json:"targets"`
Enabled bool `json:"enabled"`
Tags []string `json:"tags,omitempty"`
ProxyProtocol string `json:"proxyProtocol,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
RedirectPermanent bool `json:"redirectPermanent,omitempty"`
RedirectKeepPath bool `json:"redirectKeepPath,omitempty"`
Auth Auth `json:"auth"`
AuthEpoch int64 `json:"authEpoch,omitempty"`
Rules []Rule `json:"rules"`
RedirectHTTP bool `json:"redirectHttp"`
BackendHTTPS bool `json:"backendHttps"`
RewriteHost string `json:"rewriteHost"`
HealthPath string `json:"healthPath"`
HealthSeconds int `json:"healthSeconds"`
NewPassword *string `json:"newPassword,omitempty"`
NewPin *string `json:"newPin,omitempty"`
}
type Channel struct {
ID string `json:"id,omitempty"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"` // email, webhook or sandbar
Target string `json:"target"`
Format string `json:"format,omitempty"`
Token string `json:"token,omitempty"`
Policy string `json:"policy,omitempty"`
AllowPrivate bool `json:"allowPrivate"`
Enabled bool `json:"enabled"`
}
type AlertRow struct {
Key string `json:"key"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Fired bool `json:"fired"`
}
// Alerts is the alerts overview of an organization (or, with the server flag, of the server).
type Alerts struct {
Channels []Channel `json:"channels"`
Active []AlertRow `json:"active"`
Settings map[string]int `json:"settings"`
}
type State struct {
Edges []Edge `json:"edges"`
Sites []Site `json:"sites"`
Domains []Domain `json:"domains"`
Mappings []Port `json:"mappings"`
}
+26
View File
@@ -0,0 +1,26 @@
// Command terraform-provider-hammerhead is the Terraform provider for Hammerhead.
package main
import (
"context"
"flag"
"log"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"git.veillax.com/Veillax/hhctl/internal/provider"
)
var version = "dev"
func main() {
debug := flag.Bool("debug", false, "run with support for debuggers")
flag.Parse()
err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{
Address: "registry.terraform.io/veillax/hammerhead",
Debug: *debug,
})
if err != nil {
log.Fatal(err)
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Stand up an edge, a site, a domain and a web port with hhctl.
# Needs HAMMERHEAD_URL and HAMMERHEAD_TOKEN (a server key) in the environment.
set -eu
hhctl edges create --name fra --tunnel 203.0.113.5:51820
hhctl sites create --name home --tag lab
hhctl domains add --name example.com --edge fra
hhctl ports create --name web --proto https --edge fra --port 443 \
--hostname app.example.com --target home:192.168.1.30:8080
hhctl ports list
+81
View File
@@ -0,0 +1,81 @@
terraform {
required_providers {
hammerhead = { source = "veillax/hammerhead" }
}
}
# HAMMERHEAD_URL, HAMMERHEAD_TOKEN (a server key, for edges) and HAMMERHEAD_ORG come from the environment.
provider "hammerhead" {}
resource "hammerhead_edge" "fra" {
name = "fra"
tunnel_addr = "203.0.113.5:51820"
}
resource "hammerhead_site" "home" {
name = "home"
tags = ["lab"]
}
resource "hammerhead_domain" "example" {
name = "example.com"
edge_id = hammerhead_edge.fra.id
}
resource "hammerhead_port" "web" {
name = "web"
proto = "https"
edge_id = hammerhead_edge.fra.id
port = 443
hostname = "app.${hammerhead_domain.example.name}"
redirect_http = true
health_path = "/healthz"
auth_sso = true
target {
site_id = hammerhead_site.home.id
host = "192.168.1.30"
port = 8080
}
rule {
action = "allow"
match = "path"
value = "/public"
}
}
resource "hammerhead_port" "minecraft" {
name = "minecraft"
proto = "tcp"
edge_id = hammerhead_edge.fra.id
port = 25565
target {
site_id = hammerhead_site.home.id
host = "192.168.1.31"
port = 25565
}
}
variable "ops_webhook" {
type = string
sensitive = true
}
resource "hammerhead_alert_channel" "ops" {
name = "ops"
kind = "webhook"
format = "slack"
target = var.ops_webhook
}
# Hand these to the Bamboo and Blacktip programs.
output "site_token" {
value = hammerhead_site.home.token
sensitive = true
}
output "edge_token" {
value = hammerhead_edge.fra.token
sensitive = true
}
+30
View File
@@ -0,0 +1,30 @@
module git.veillax.com/Veillax/hhctl
go 1.27.3
require github.com/hashicorp/terraform-plugin-framework v1.19.0
require (
github.com/fatih/color v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-plugin v1.7.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect
github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/oklog/run v1.1.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.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+99
View File
@@ -0,0 +1,99 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/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.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
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.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA=
github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc=
github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8=
github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8=
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE=
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.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=
+206
View File
@@ -0,0 +1,206 @@
// Package fakehh is a small in-memory imitation of the parts of Hammerhead's API that hhctl uses,
// for tests. It checks the bearer key and keeps resources in maps; it does not validate like the real server.
package fakehh
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
const (
OrgID = "org1"
OrgKey = "hhk_org_secret"
ServerKey = "hhk_srv_secret"
)
type Server struct {
*httptest.Server
mu sync.Mutex
n int
Edges map[string]map[string]any
Sites map[string]map[string]any
Domains map[string]map[string]any
Mappings map[string]map[string]any
Channels map[string]map[string]any
}
func New() *Server {
s := &Server{Edges: map[string]map[string]any{}, Sites: map[string]map[string]any{}, Domains: map[string]map[string]any{},
Mappings: map[string]map[string]any{}, Channels: map[string]map[string]any{}}
s.Server = httptest.NewServer(http.HandlerFunc(s.serve))
return s
}
func (s *Server) id(prefix string) string { s.n++; return fmt.Sprintf("%s%d", prefix, s.n) }
func reply(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func list(m map[string]map[string]any) []map[string]any {
out := []map[string]any{}
for _, v := range m {
out = append(out, v)
}
return out
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if key != OrgKey && key != ServerKey {
reply(w, 401, map[string]string{"error": "Sign in to continue."})
return
}
server := key == ServerKey
var body map[string]any
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
path := strings.Trim(r.URL.Path, "/")
seg := strings.Split(path, "/") // api, ...
m := r.Method
ok := map[string]bool{"ok": true}
switch {
case !server && (strings.HasPrefix(path, "api/admin") || m != "GET" && strings.HasPrefix(path, "api/edges") || m == "POST" && path == "api/orgs"):
reply(w, 401, map[string]string{"error": "Sign in to continue."}) // the real server refuses org keys here
case path == "api/orgs" && m == "GET":
reply(w, 200, []map[string]any{{"id": OrgID, "name": "Acme"}})
case path == "api/orgs" && m == "POST" && server:
reply(w, 200, map[string]any{"id": "org2", "name": body["name"]})
case path == "api/edges" && m == "GET":
reply(w, 200, list(s.Edges))
case path == "api/edges" && m == "POST" && server:
id := s.id("e")
body["id"], body["token"], body["ips"], body["online"] = id, "edge-token-"+id, []string{}, false
s.Edges[id] = body
reply(w, 200, body)
case len(seg) == 3 && seg[1] == "edges" && server:
e := s.Edges[seg[2]]
switch {
case e == nil:
reply(w, 404, map[string]string{"error": "No such edge"})
case m == "PUT":
for k, v := range body {
e[k] = v
}
reply(w, 200, e)
case m == "DELETE":
delete(s.Edges, seg[2])
reply(w, 200, ok)
}
case len(seg) >= 3 && seg[1] == "orgs" && seg[2] == OrgID || len(seg) >= 3 && seg[1] == "orgs" && server:
s.org(w, m, seg[3:], body, server)
case len(seg) >= 3 && seg[1] == "admin" && seg[2] == "alerts" && server:
s.alerts(w, m, seg[3:], body)
default:
reply(w, 404, map[string]string{"error": "not found"})
}
}
func (s *Server) org(w http.ResponseWriter, m string, seg []string, body map[string]any, server bool) {
ok := map[string]bool{"ok": true}
if len(seg) == 0 {
reply(w, 404, map[string]string{"error": "not found"})
return
}
switch seg[0] {
case "state":
reply(w, 200, map[string]any{"edges": list(s.Edges), "sites": list(s.Sites), "domains": list(s.Domains), "mappings": list(s.Mappings)})
case "sites", "domains", "mappings":
var store map[string]map[string]any
var prefix string
switch seg[0] {
case "sites":
store, prefix = s.Sites, "s"
case "domains":
store, prefix = s.Domains, "d"
default:
store, prefix = s.Mappings, "m"
}
if len(seg) == 1 && m == "POST" {
id := s.id(prefix)
body["id"], body["orgId"] = id, OrgID
if seg[0] == "sites" {
body["token"], body["connected"] = "site-token-"+id, []string{}
}
store[id] = body
reply(w, 200, body)
return
}
if len(seg) == 2 {
cur := store[seg[1]]
switch {
case cur == nil:
reply(w, 404, map[string]string{"error": "No such " + seg[0]})
case m == "PUT":
for k, v := range body {
cur[k] = v
}
if seg[0] == "sites" {
reply(w, 200, ok)
} else {
reply(w, 200, cur)
}
case m == "DELETE":
delete(store, seg[1])
reply(w, 200, ok)
}
return
}
reply(w, 404, map[string]string{"error": "not found"})
case "alerts":
s.alerts(w, m, seg[1:], body)
case "apikeys":
reply(w, 200, map[string]string{"id": "k1", "key": "hhk_k1_secret"})
default:
reply(w, 404, map[string]string{"error": "not found"})
}
}
func (s *Server) alerts(w http.ResponseWriter, m string, seg []string, body map[string]any) {
ok := map[string]bool{"ok": true}
switch {
case len(seg) == 0 && m == "GET":
ch := list(s.Channels)
for _, c := range ch { // like the real server, secrets are not sent back
if c["kind"] != "email" {
c["target"], c["token"] = "", ""
}
}
reply(w, 200, map[string]any{"channels": ch, "active": []any{}, "settings": map[string]int{"debounceMin": 2, "repeatHours": 0}})
case len(seg) == 1 && seg[0] == "channels" && m == "POST":
id := s.id("c")
body["id"] = id
s.Channels[id] = body
reply(w, 200, map[string]any{"ok": true, "id": id})
case len(seg) >= 2 && seg[0] == "channels":
cur := s.Channels[seg[1]]
switch {
case cur == nil:
reply(w, 404, map[string]string{"error": "No such channel"})
case len(seg) == 3 && seg[2] == "test":
reply(w, 200, ok)
case m == "PUT":
for k, v := range body {
if v != "" {
cur[k] = v
}
}
reply(w, 200, ok)
case m == "DELETE":
delete(s.Channels, seg[1])
reply(w, 200, ok)
}
default:
reply(w, 404, map[string]string{"error": "not found"})
}
}
+151
View File
@@ -0,0 +1,151 @@
package provider
import (
"context"
"strings"
"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/boolplanmodifier"
"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/types"
"git.veillax.com/Veillax/hhctl/client"
)
var (
_ resource.Resource = &channelResource{}
_ resource.ResourceWithConfigure = &channelResource{}
_ resource.ResourceWithImportState = &channelResource{}
)
func NewAlertChannel() resource.Resource { return &channelResource{} }
type channelResource struct{ c *client.Client }
type channelModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Kind types.String `tfsdk:"kind"`
Target types.String `tfsdk:"target"`
Format types.String `tfsdk:"format"`
Token types.String `tfsdk:"token"`
Policy types.String `tfsdk:"policy"`
AllowPrivate types.Bool `tfsdk:"allow_private"`
Enabled types.Bool `tfsdk:"enabled"`
Server types.Bool `tfsdk:"server"`
}
func (r *channelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_alert_channel"
}
func (r *channelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
opt := func(d string) schema.StringAttribute {
return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), Description: d}
}
resp.Schema = schema.Schema{
Description: "Somewhere alerts are sent: an email address, a webhook or a Sandbar server.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true},
"kind": schema.StringAttribute{Required: true, Description: "email, webhook or sandbar."},
"target": schema.StringAttribute{Required: true, Sensitive: true, Description: "Address(es), webhook URL or Sandbar address. Webhook URLs often carry tokens, so Hammerhead never returns them."},
"format": opt("Webhook body: json, slack, discord or ntfy."),
"token": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Sandbar API key."},
"policy": opt("Sandbar escalation policy ID; empty sends the alert without paging anyone."),
"allow_private": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false),
Description: "Allow a webhook target on a private address (organization admins may need allow-local)."},
"enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)},
"server": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false),
PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()},
Description: "A server-level channel (for edges) instead of the organization's. Needs a server key."},
},
}
}
func (r *channelResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
r.c = clientFrom(req.ProviderData)
}
func (m *channelModel) toAPI() client.Channel {
return client.Channel{ID: m.ID.ValueString(), Name: m.Name.ValueString(), Kind: m.Kind.ValueString(), Target: m.Target.ValueString(),
Format: m.Format.ValueString(), Token: m.Token.ValueString(), Policy: m.Policy.ValueString(),
AllowPrivate: m.AllowPrivate.ValueBool(), Enabled: m.Enabled.ValueBool()}
}
func (r *channelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan channelModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
id, err := r.c.CreateChannel(plan.Server.ValueBool(), plan.toAPI())
if err != nil {
resp.Diagnostics.AddError("Creating alert channel", err.Error())
return
}
plan.ID = types.StringValue(id)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *channelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state channelModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
a, err := r.c.Alerts(state.Server.ValueBool())
if err != nil {
resp.Diagnostics.AddError("Reading alert channels", err.Error())
return
}
for _, ch := range a.Channels {
if ch.ID != state.ID.ValueString() {
continue
}
state.Name, state.Kind, state.Format, state.Policy = types.StringValue(ch.Name), types.StringValue(ch.Kind), types.StringValue(ch.Format), types.StringValue(ch.Policy)
state.AllowPrivate, state.Enabled = types.BoolValue(ch.AllowPrivate), types.BoolValue(ch.Enabled)
if ch.Kind == "email" { // the only target the server sends back; URLs and keys stay as configured
state.Target = types.StringValue(ch.Target)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
return
}
resp.State.RemoveResource(ctx)
}
func (r *channelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state channelModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
plan.ID = state.ID
if err := r.c.UpdateChannel(plan.Server.ValueBool(), plan.toAPI()); err != nil {
resp.Diagnostics.AddError("Updating alert channel", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *channelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state channelModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.DeleteChannel(state.Server.ValueBool(), state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Deleting alert channel", err.Error())
}
}
// ImportState takes "<id>" for an organization channel or "server/<id>" for a server-level one. The target and
// token are not readable from Hammerhead, so set them in the configuration and apply once after importing.
func (r *channelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
id, server := strings.CutPrefix(req.ID, "server/")
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server"), server)...)
}
+107
View File
@@ -0,0 +1,107 @@
package provider
import (
"context"
"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/types"
"git.veillax.com/Veillax/hhctl/client"
)
var (
_ resource.Resource = &domainResource{}
_ resource.ResourceWithConfigure = &domainResource{}
_ resource.ResourceWithImportState = &domainResource{}
)
func NewDomain() resource.Resource { return &domainResource{} }
type domainResource struct{ c *client.Client }
type domainModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
EdgeID types.String `tfsdk:"edge_id"`
IP types.String `tfsdk:"ip"`
Note types.String `tfsdk:"note"`
}
func (r *domainResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_domain"
}
func (r *domainResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
replace := []planmodifier.String{stringplanmodifier.RequiresReplace()} // the API has no domain update
opt := func(d string) schema.StringAttribute {
return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), PlanModifiers: replace, Description: d}
}
resp.Schema = schema.Schema{
Description: "A base domain the organization owns. Web hostnames of ports must sit under one. Changing any field replaces the domain.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true, PlanModifiers: replace, Description: "Domain, like example.com."},
"edge_id": opt("The edge the domain's DNS points at."),
"ip": opt("The edge's public IP the domain points at."),
"note": opt("Free-form note."),
},
}
}
func (r *domainResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
r.c = clientFrom(req.ProviderData)
}
func (r *domainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan domainModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
d, err := r.c.CreateDomain(client.Domain{Name: plan.Name.ValueString(), EdgeID: plan.EdgeID.ValueString(), IP: plan.IP.ValueString(), Note: plan.Note.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Adding domain", err.Error())
return
}
plan.ID = types.StringValue(d.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *domainResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state domainModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
d, err := r.c.GetDomain(state.ID.ValueString())
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading domain", err.Error())
return
}
state.Name, state.EdgeID, state.IP, state.Note = types.StringValue(d.Name), types.StringValue(d.EdgeID), types.StringValue(d.IP), types.StringValue(d.Note)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *domainResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
resp.Diagnostics.AddError("Domains can't be updated", "Every field forces a new domain; this is a provider bug.")
}
func (r *domainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state domainModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.DeleteDomain(state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Deleting domain", err.Error())
}
}
func (r *domainResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
importByID(ctx, req, resp)
}
+187
View File
@@ -0,0 +1,187 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/datasource"
dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"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/setdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"git.veillax.com/Veillax/hhctl/client"
)
var (
_ resource.Resource = &edgeResource{}
_ resource.ResourceWithConfigure = &edgeResource{}
_ resource.ResourceWithImportState = &edgeResource{}
_ datasource.DataSource = &edgeData{}
_ datasource.DataSourceWithConfigure = &edgeData{}
)
func NewEdge() resource.Resource { return &edgeResource{} }
type edgeResource struct{ c *client.Client }
type edgeModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
TunnelAddr types.String `tfsdk:"tunnel_addr"`
OrgIDs types.Set `tfsdk:"org_ids"`
Token types.String `tfsdk:"token"`
IPs types.List `tfsdk:"ips"`
}
func (r *edgeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_edge"
}
func (r *edgeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "An edge: a public Blacktip node. Needs a server API key.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true},
"tunnel_addr": schema.StringAttribute{Required: true, Description: "The edge's public WireGuard endpoint, host:port (UDP)."},
"org_ids": schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: setdefault.StaticValue(setOf(nil)),
Description: "Organizations allowed to use the edge; empty means all."},
"token": schema.StringAttribute{Computed: true, Sensitive: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Description: "The token to give the Blacktip program."},
"ips": schema.ListAttribute{Computed: true, ElementType: types.StringType, Description: "Addresses the edge reported it can bind."},
},
}
}
func (r *edgeResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
r.c = clientFrom(req.ProviderData)
}
func ipsList(ctx context.Context, ips []string) types.List {
if ips == nil {
ips = []string{}
}
l, _ := types.ListValueFrom(ctx, types.StringType, ips)
return l
}
func (r *edgeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan edgeModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
e, err := r.c.CreateEdge(client.Edge{Name: plan.Name.ValueString(), TunnelAddr: plan.TunnelAddr.ValueString(), OrgIDs: stringsOf(ctx, plan.OrgIDs)})
if err != nil {
resp.Diagnostics.AddError("Creating edge", err.Error())
return
}
plan.ID, plan.Token, plan.IPs = types.StringValue(e.ID), types.StringValue(e.Token), ipsList(ctx, e.IPs)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *edgeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state edgeModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
e, err := r.c.GetEdge(state.ID.ValueString())
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading edge", err.Error())
return
}
state.Name, state.TunnelAddr, state.OrgIDs, state.IPs = types.StringValue(e.Name), types.StringValue(e.TunnelAddr), setOf(e.OrgIDs), ipsList(ctx, e.IPs)
if e.Token != "" {
state.Token = types.StringValue(e.Token)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *edgeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state edgeModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
e, err := r.c.UpdateEdge(client.Edge{ID: state.ID.ValueString(), Name: plan.Name.ValueString(), TunnelAddr: plan.TunnelAddr.ValueString(), OrgIDs: stringsOf(ctx, plan.OrgIDs)})
if err != nil {
resp.Diagnostics.AddError("Updating edge", err.Error())
return
}
plan.ID, plan.Token, plan.IPs = state.ID, state.Token, ipsList(ctx, e.IPs)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *edgeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state edgeModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.DeleteEdge(state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Deleting edge", err.Error())
}
}
func (r *edgeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
importByID(ctx, req, resp)
}
// ---- data source: look an edge up by name, so ports can refer to one created elsewhere ----
func NewEdgeData() datasource.DataSource { return &edgeData{} }
type edgeData struct{ c *client.Client }
type edgeDataModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
IPs types.List `tfsdk:"ips"`
Online types.Bool `tfsdk:"online"`
}
func (d *edgeData) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_edge"
}
func (d *edgeData) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = dsschema.Schema{
Description: "An existing edge, found by name. Works with an organization key.",
Attributes: map[string]dsschema.Attribute{
"id": dsschema.StringAttribute{Computed: true},
"name": dsschema.StringAttribute{Required: true},
"ips": dsschema.ListAttribute{Computed: true, ElementType: types.StringType},
"online": dsschema.BoolAttribute{Computed: true},
},
}
}
func (d *edgeData) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) {
d.c = clientFrom(req.ProviderData)
}
func (d *edgeData) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var m edgeDataModel
if resp.Diagnostics.Append(req.Config.Get(ctx, &m)...); resp.Diagnostics.HasError() {
return
}
edges, err := d.c.Edges()
if err != nil {
resp.Diagnostics.AddError("Listing edges", err.Error())
return
}
for _, e := range edges {
if e.Name == m.Name.ValueString() {
m.ID, m.IPs, m.Online = types.StringValue(e.ID), ipsList(ctx, e.IPs), types.BoolValue(e.Online)
resp.Diagnostics.Append(resp.State.Set(ctx, &m)...)
return
}
}
resp.Diagnostics.AddError("Edge not found", "No edge named "+m.Name.ValueString()+" is visible to this key.")
}
+319
View File
@@ -0,0 +1,319 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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/defaults"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"git.veillax.com/Veillax/hhctl/client"
)
var (
_ resource.Resource = &portResource{}
_ resource.ResourceWithConfigure = &portResource{}
_ resource.ResourceWithImportState = &portResource{}
)
func NewPort() resource.Resource { return &portResource{} }
type portResource struct{ c *client.Client }
type targetModel struct {
SiteID types.String `tfsdk:"site_id"`
Host types.String `tfsdk:"host"`
Port types.Int64 `tfsdk:"port"`
}
type ruleModel struct {
Action types.String `tfsdk:"action"`
Match types.String `tfsdk:"match"`
Value types.String `tfsdk:"value"`
}
var (
targetType = types.ObjectType{AttrTypes: map[string]attr.Type{"site_id": types.StringType, "host": types.StringType, "port": types.Int64Type}}
ruleType = types.ObjectType{AttrTypes: map[string]attr.Type{"action": types.StringType, "match": types.StringType, "value": types.StringType}}
)
type portModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Proto types.String `tfsdk:"proto"`
EdgeID types.String `tfsdk:"edge_id"`
BindIP types.String `tfsdk:"bind_ip"`
Port types.Int64 `tfsdk:"port"`
PortEnd types.Int64 `tfsdk:"port_end"`
Hostname types.String `tfsdk:"hostname"`
Enabled types.Bool `tfsdk:"enabled"`
Tags types.Set `tfsdk:"tags"`
ProxyProtocol types.String `tfsdk:"proxy_protocol"`
RedirectTo types.String `tfsdk:"redirect_to"`
RedirectPermanent types.Bool `tfsdk:"redirect_permanent"`
RedirectKeepPath types.Bool `tfsdk:"redirect_keep_path"`
RedirectHTTP types.Bool `tfsdk:"redirect_http"`
BackendHTTPS types.Bool `tfsdk:"backend_https"`
RewriteHost types.String `tfsdk:"rewrite_host"`
HealthPath types.String `tfsdk:"health_path"`
HealthSeconds types.Int64 `tfsdk:"health_seconds"`
Targets types.List `tfsdk:"target"`
Rules types.List `tfsdk:"rule"`
AuthSSO types.Bool `tfsdk:"auth_sso"`
AuthEmail types.Bool `tfsdk:"auth_email"`
AuthRoles types.Set `tfsdk:"auth_allowed_roles"`
AuthUsers types.Set `tfsdk:"auth_allowed_users"`
AuthMails types.Set `tfsdk:"auth_allowed_mails"`
AuthSessionHours types.Int64 `tfsdk:"auth_session_hours"`
Password types.String `tfsdk:"password"`
Pin types.String `tfsdk:"pin"`
}
func (r *portResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_port"
}
func emptySet() defaults.Set { return setdefault.StaticValue(setOf(nil)) }
func (r *portResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
str := func(desc string) schema.StringAttribute {
return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), Description: desc}
}
flag := func(desc string) schema.BoolAttribute {
return schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), Description: desc}
}
num := func(desc string) schema.Int64Attribute {
return schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), Description: desc}
}
set := func(desc string) schema.SetAttribute {
return schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: emptySet(), Description: desc}
}
resp.Schema = schema.Schema{
Description: "A port: a public TCP/UDP port, or an HTTP(S)/TLS hostname, on an edge, forwarded to targets at a site.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true},
"proto": schema.StringAttribute{Required: true, Description: "tcp, udp, http, https or tls."},
"edge_id": schema.StringAttribute{Required: true, Description: "The edge that listens."},
"bind_ip": schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString("0.0.0.0"), Description: "Address on the edge to listen on; 0.0.0.0 is every address."},
"port": schema.Int64Attribute{Required: true, Description: "Public port."},
"port_end": num("Last port of a tcp/udp range (0 = just port)."),
"hostname": str("Hostname for http, https and tls; a wildcard like *.example.com is allowed."),
"enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)},
"tags": set("Labels for grouping and filtering in the dashboard."),
"proxy_protocol": str("v1 or v2: prefix tcp/tls connections with a PROXY protocol header."),
"redirect_to": str("Answer with a redirect to this URL instead of forwarding (http/https)."),
"redirect_permanent": flag("Redirect with 301 instead of 302."),
"redirect_keep_path": flag("Append the visitor's path to the redirect."),
"redirect_http": flag("https: also answer on port 80 with a redirect."),
"backend_https": flag("Talk TLS (unverified) to the targets."),
"rewrite_host": str("Host header sent to the target; empty keeps the original."),
"health_path": str("http(s): GET this path for health checks; empty is a TCP connect check."),
"health_seconds": num("Seconds between health checks; 0 is the default (30), -1 turns them off."),
"auth_sso": flag("Visitors sign in with a Hammerhead account."),
"auth_email": flag("Visitors get a one-time code by email."),
"auth_allowed_roles": set("Roles allowed to sign in with SSO."),
"auth_allowed_users": set("User IDs allowed to sign in with SSO."),
"auth_allowed_mails": set("Addresses allowed for email codes; \"*@example.com\" is fine."),
"auth_session_hours": num("How long a visitor stays signed in; 0 is the default (24)."),
"password": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Shared password visitors must enter (at least 6 characters)."},
"pin": schema.StringAttribute{Optional: true, Sensitive: true, Description: "4-12 digit PIN visitors must enter."},
},
Blocks: map[string]schema.Block{
"target": schema.ListNestedBlock{
Description: "A backend to forward to. UDP uses only the first.",
NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{
"site_id": schema.StringAttribute{Required: true},
"host": schema.StringAttribute{Required: true, Description: "Address of the service as seen from the site."},
"port": schema.Int64Attribute{Required: true},
}},
},
"rule": schema.ListNestedBlock{
Description: "Access rule evaluated before authentication, top to bottom.",
NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{
"action": schema.StringAttribute{Required: true, Description: "allow (skip auth) or deny."},
"match": schema.StringAttribute{Required: true, Description: "path (prefix) or ip (address or CIDR)."},
"value": schema.StringAttribute{Required: true},
}},
},
},
}
}
func (r *portResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
r.c = clientFrom(req.ProviderData)
}
// toAPI builds the request. old is the prior state (nil on create); passwords are only re-sent when they change,
// because every change signs all visitors out.
func (m *portModel) toAPI(ctx context.Context, old *portModel) (client.Port, diag.Diagnostics) {
p := client.Port{
ID: m.ID.ValueString(), Name: m.Name.ValueString(), Proto: m.Proto.ValueString(), EdgeID: m.EdgeID.ValueString(),
BindIP: m.BindIP.ValueString(), Port: int(m.Port.ValueInt64()), PortEnd: int(m.PortEnd.ValueInt64()),
Hostname: m.Hostname.ValueString(), Enabled: m.Enabled.ValueBool(), Tags: stringsOf(ctx, m.Tags),
ProxyProtocol: m.ProxyProtocol.ValueString(), RedirectTo: m.RedirectTo.ValueString(),
RedirectPermanent: m.RedirectPermanent.ValueBool(), RedirectKeepPath: m.RedirectKeepPath.ValueBool(),
RedirectHTTP: m.RedirectHTTP.ValueBool(), BackendHTTPS: m.BackendHTTPS.ValueBool(),
RewriteHost: m.RewriteHost.ValueString(), HealthPath: m.HealthPath.ValueString(), HealthSeconds: int(m.HealthSeconds.ValueInt64()),
Targets: []client.Target{}, Rules: []client.Rule{},
Auth: client.Auth{SSO: m.AuthSSO.ValueBool(), Email: m.AuthEmail.ValueBool(), SessionHours: int(m.AuthSessionHours.ValueInt64()),
AllowedRoles: stringsOf(ctx, m.AuthRoles), AllowedUsers: stringsOf(ctx, m.AuthUsers), AllowedMails: stringsOf(ctx, m.AuthMails),
Password: !m.Password.IsNull(), Pin: !m.Pin.IsNull()},
}
var ts []targetModel
if d := m.Targets.ElementsAs(ctx, &ts, false); d.HasError() {
return p, d
}
for _, t := range ts {
p.Targets = append(p.Targets, client.Target{SiteID: t.SiteID.ValueString(), Host: t.Host.ValueString(), Port: int(t.Port.ValueInt64())})
}
var rs []ruleModel
if d := m.Rules.ElementsAs(ctx, &rs, false); d.HasError() {
return p, d
}
for _, x := range rs {
p.Rules = append(p.Rules, client.Rule{Action: x.Action.ValueString(), Match: x.Match.ValueString(), Value: x.Value.ValueString()})
}
p.NewPassword = secretChange(m.Password, old, func(o *portModel) types.String { return o.Password })
p.NewPin = secretChange(m.Pin, old, func(o *portModel) types.String { return o.Pin })
return p, nil
}
// secretChange returns what to send in newPassword/newPin: nil keeps the stored secret.
func secretChange(want types.String, old *portModel, get func(*portModel) types.String) *string {
var was types.String = types.StringNull()
if old != nil {
was = get(old)
}
switch {
case want.IsNull() && was.IsNull():
return nil
case want.IsNull():
e := ""
return &e
case want.Equal(was):
return nil
}
v := want.ValueString()
return &v
}
// fromAPI refreshes m from the server's view; secrets the server never returns stay as they are.
func (m *portModel) fromAPI(ctx context.Context, p *client.Port) (diags diag.Diagnostics) {
m.ID, m.Name, m.Proto, m.EdgeID = types.StringValue(p.ID), types.StringValue(p.Name), types.StringValue(p.Proto), types.StringValue(p.EdgeID)
m.BindIP, m.Port, m.PortEnd = types.StringValue(p.BindIP), types.Int64Value(int64(p.Port)), types.Int64Value(int64(p.PortEnd))
m.Hostname, m.Enabled, m.Tags = types.StringValue(p.Hostname), types.BoolValue(p.Enabled), setOf(p.Tags)
m.ProxyProtocol, m.RedirectTo = types.StringValue(p.ProxyProtocol), types.StringValue(p.RedirectTo)
m.RedirectPermanent, m.RedirectKeepPath = types.BoolValue(p.RedirectPermanent), types.BoolValue(p.RedirectKeepPath)
m.RedirectHTTP, m.BackendHTTPS = types.BoolValue(p.RedirectHTTP), types.BoolValue(p.BackendHTTPS)
m.RewriteHost, m.HealthPath, m.HealthSeconds = types.StringValue(p.RewriteHost), types.StringValue(p.HealthPath), types.Int64Value(int64(p.HealthSeconds))
m.AuthSSO, m.AuthEmail, m.AuthSessionHours = types.BoolValue(p.Auth.SSO), types.BoolValue(p.Auth.Email), types.Int64Value(int64(p.Auth.SessionHours))
m.AuthRoles, m.AuthUsers, m.AuthMails = setOf(p.Auth.AllowedRoles), setOf(p.Auth.AllowedUsers), setOf(p.Auth.AllowedMails)
if !p.Auth.Password {
m.Password = types.StringNull()
}
if !p.Auth.Pin {
m.Pin = types.StringNull()
}
ts := make([]targetModel, 0, len(p.Targets))
for _, t := range p.Targets {
ts = append(ts, targetModel{types.StringValue(t.SiteID), types.StringValue(t.Host), types.Int64Value(int64(t.Port))})
}
rs := make([]ruleModel, 0, len(p.Rules))
for _, x := range p.Rules {
rs = append(rs, ruleModel{types.StringValue(x.Action), types.StringValue(x.Match), types.StringValue(x.Value)})
}
var d diag.Diagnostics
m.Targets, d = types.ListValueFrom(ctx, targetType, ts)
diags.Append(d...)
m.Rules, d = types.ListValueFrom(ctx, ruleType, rs)
diags.Append(d...)
return diags
}
func (r *portResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan portModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
body, d := plan.toAPI(ctx, nil)
if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() {
return
}
got, err := r.c.CreatePort(body)
if err != nil {
resp.Diagnostics.AddError("Creating port", err.Error())
return
}
plan.ID = types.StringValue(got.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *portResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state portModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
p, err := r.c.GetPort(state.ID.ValueString())
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading port", err.Error())
return
}
if resp.Diagnostics.Append(state.fromAPI(ctx, p)...); resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state portModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
plan.ID = state.ID
body, d := plan.toAPI(ctx, &state)
if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() {
return
}
if _, err := r.c.UpdatePort(body); err != nil {
resp.Diagnostics.AddError("Updating port", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *portResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state portModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.DeletePort(state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Deleting port", err.Error())
}
}
func (r *portResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
importByID(ctx, req, resp)
}
func importByID(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...)
}
+92
View File
@@ -0,0 +1,92 @@
// Package provider is the Terraform provider for Hammerhead.
package provider
import (
"context"
"os"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/path"
"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"
"git.veillax.com/Veillax/hhctl/client"
)
type hammerheadProvider struct{ version string }
// New returns a provider factory.
func New(version string) func() provider.Provider {
return func() provider.Provider { return &hammerheadProvider{version} }
}
type providerModel struct {
URL types.String `tfsdk:"url"`
Token types.String `tfsdk:"token"`
Org types.String `tfsdk:"org"`
}
func (p *hammerheadProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "hammerhead"
resp.Version = p.version
}
func (p *hammerheadProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manage a Hammerhead control plane: sites, ports, domains, edges and alert channels.",
Attributes: map[string]schema.Attribute{
"url": schema.StringAttribute{Optional: true, Description: "Hammerhead address, e.g. https://hammerhead.example.com. Defaults to $HAMMERHEAD_URL."},
"token": schema.StringAttribute{Optional: true, Sensitive: true, Description: "API key (hhk_...). An organization key manages that organization; edges and server alert channels need a server key. Defaults to $HAMMERHEAD_TOKEN."},
"org": schema.StringAttribute{Optional: true, Description: "Organization ID. Optional with an organization key. Defaults to $HAMMERHEAD_ORG."},
},
}
}
func orDefault(v types.String, env string) string {
if !v.IsNull() && !v.IsUnknown() {
return v.ValueString()
}
return os.Getenv(env)
}
func (p *hammerheadProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var cfg providerModel
resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...)
if resp.Diagnostics.HasError() {
return
}
for _, v := range []struct {
name string
val types.String
}{{"url", cfg.URL}, {"token", cfg.Token}, {"org", cfg.Org}} {
if v.val.IsUnknown() {
resp.Diagnostics.AddAttributeError(path.Root(v.name), "Unknown "+v.name, "The value must be known when the provider is configured.")
}
}
if resp.Diagnostics.HasError() {
return
}
url, token := orDefault(cfg.URL, "HAMMERHEAD_URL"), orDefault(cfg.Token, "HAMMERHEAD_TOKEN")
if url == "" || token == "" {
resp.Diagnostics.AddError("Missing configuration", "Set url and token in the provider block, or HAMMERHEAD_URL and HAMMERHEAD_TOKEN.")
return
}
c := client.New(url, token, orDefault(cfg.Org, "HAMMERHEAD_ORG"))
resp.ResourceData, resp.DataSourceData = c, c
}
func (p *hammerheadProvider) Resources(context.Context) []func() resource.Resource {
return []func() resource.Resource{NewSite, NewPort, NewDomain, NewEdge, NewAlertChannel}
}
func (p *hammerheadProvider) DataSources(context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{NewEdgeData}
}
// clientFrom pulls the configured client out of ProviderData (nil before Configure ran).
func clientFrom(data any) *client.Client {
c, _ := data.(*client.Client)
return c
}
+251
View File
@@ -0,0 +1,251 @@
package provider
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"git.veillax.com/Veillax/hhctl/client"
"git.veillax.com/Veillax/hhctl/internal/fakehh"
)
func TestProviderSchemasAreValid(t *testing.T) {
srv, err := providerserver.NewProtocol6WithError(New("test")())()
if err != nil {
t.Fatal(err)
}
resp, err := srv.GetProviderSchema(context.Background(), &tfprotov6.GetProviderSchemaRequest{})
if err != nil {
t.Fatal(err)
}
for _, d := range resp.Diagnostics {
t.Errorf("%s: %s", d.Summary, d.Detail)
}
for _, name := range []string{"hammerhead_site", "hammerhead_port", "hammerhead_domain", "hammerhead_edge", "hammerhead_alert_channel"} {
if resp.ResourceSchemas[name] == nil {
t.Errorf("missing resource %s", name)
}
}
if resp.DataSourceSchemas["hammerhead_edge"] == nil {
t.Error("missing data source")
}
}
// harness drives a resource's CRUD methods the way Terraform does, without the terraform binary.
type harness struct {
t *testing.T
res resource.Resource
sch resource.SchemaResponse
st tfsdk.State
}
func newHarness(t *testing.T, res resource.Resource, c *client.Client) *harness {
h := &harness{t: t, res: res}
res.Schema(context.Background(), resource.SchemaRequest{}, &h.sch)
res.(resource.ResourceWithConfigure).Configure(context.Background(), resource.ConfigureRequest{ProviderData: c}, &resource.ConfigureResponse{})
h.st = tfsdk.State{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)}
return h
}
func (h *harness) plan(model any) tfsdk.Plan {
p := tfsdk.Plan{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)}
if d := p.Set(context.Background(), model); d.HasError() {
h.t.Fatalf("plan: %v", d)
}
return p
}
func (h *harness) create(model any) {
h.t.Helper()
resp := resource.CreateResponse{State: h.st}
h.res.Create(context.Background(), resource.CreateRequest{Plan: h.plan(model)}, &resp)
if resp.Diagnostics.HasError() {
h.t.Fatalf("create: %v", resp.Diagnostics)
}
h.st = resp.State
}
func (h *harness) update(model any) {
h.t.Helper()
resp := resource.UpdateResponse{State: h.st}
h.res.Update(context.Background(), resource.UpdateRequest{Plan: h.plan(model), State: h.st}, &resp)
if resp.Diagnostics.HasError() {
h.t.Fatalf("update: %v", resp.Diagnostics)
}
h.st = resp.State
}
func (h *harness) read(model any) (gone bool) {
h.t.Helper()
resp := resource.ReadResponse{State: h.st}
h.res.Read(context.Background(), resource.ReadRequest{State: h.st}, &resp)
if resp.Diagnostics.HasError() {
h.t.Fatalf("read: %v", resp.Diagnostics)
}
if resp.State.Raw.IsNull() {
return true
}
h.st = resp.State
if d := h.st.Get(context.Background(), model); d.HasError() {
h.t.Fatalf("get: %v", d)
}
return false
}
func (h *harness) delete() {
h.t.Helper()
resp := resource.DeleteResponse{}
h.res.Delete(context.Background(), resource.DeleteRequest{State: h.st}, &resp)
if resp.Diagnostics.HasError() {
h.t.Fatalf("delete: %v", resp.Diagnostics)
}
}
func fixture(t *testing.T) (*fakehh.Server, *client.Client) {
fs := fakehh.New()
t.Cleanup(fs.Close)
c := client.New(fs.URL, fakehh.ServerKey, fakehh.OrgID)
return fs, c
}
func TestSiteLifecycle(t *testing.T) {
fs, c := fixture(t)
h := newHarness(t, NewSite(), c)
m := siteModel{Name: types.StringValue("home"), Tags: setOf([]string{"lab"}), ID: types.StringUnknown(), Token: types.StringUnknown()}
h.create(m)
var got siteModel
h.read(&got)
if got.ID.ValueString() == "" || got.Token.ValueString() == "" || got.Name.ValueString() != "home" || len(got.Tags.Elements()) != 1 {
t.Fatalf("after create: %+v", got)
}
got.Name, got.Tags = types.StringValue("house"), setOf(nil)
h.update(got)
var again siteModel
h.read(&again)
if again.Name.ValueString() != "house" || len(again.Tags.Elements()) != 0 || again.Token != got.Token {
t.Fatalf("after update: %+v", again)
}
h.delete()
if len(fs.Sites) != 0 || !h.read(&again) {
t.Fatal("site still exists")
}
}
func basePort(edge, site string) portModel {
return portModel{
ID: types.StringUnknown(), Name: types.StringValue("web"), Proto: types.StringValue("https"), EdgeID: types.StringValue(edge),
BindIP: types.StringValue("0.0.0.0"), Port: types.Int64Value(443), PortEnd: types.Int64Value(0), Hostname: types.StringValue("a.example.com"),
Enabled: types.BoolValue(true), Tags: setOf(nil), ProxyProtocol: types.StringValue(""), RedirectTo: types.StringValue(""),
RedirectPermanent: types.BoolValue(false), RedirectKeepPath: types.BoolValue(false), RedirectHTTP: types.BoolValue(true), BackendHTTPS: types.BoolValue(false),
RewriteHost: types.StringValue(""), HealthPath: types.StringValue("/up"), HealthSeconds: types.Int64Value(0),
Targets: mustList(targetType, []targetModel{{types.StringValue(site), types.StringValue("10.0.0.2"), types.Int64Value(80)}}),
Rules: mustList(ruleType, []ruleModel{{types.StringValue("deny"), types.StringValue("ip"), types.StringValue("10.0.0.0/8")}}),
AuthSSO: types.BoolValue(false), AuthEmail: types.BoolValue(false), AuthRoles: setOf(nil), AuthUsers: setOf(nil), AuthMails: setOf(nil),
AuthSessionHours: types.Int64Value(0), Password: types.StringNull(), Pin: types.StringNull(),
}
}
func mustList(typ types.ObjectType, v any) types.List {
l, d := types.ListValueFrom(context.Background(), typ, v)
if d.HasError() {
panic(d)
}
return l
}
func TestPortLifecycle(t *testing.T) {
fs, c := fixture(t)
h := newHarness(t, NewPort(), c)
h.create(basePort("e1", "s1"))
var got portModel
h.read(&got)
if got.ID.ValueString() == "" || got.Port.ValueInt64() != 443 || got.HealthPath.ValueString() != "/up" || !got.RedirectHTTP.ValueBool() {
t.Fatalf("after create: %+v", got)
}
var ts []targetModel
got.Targets.ElementsAs(context.Background(), &ts, false)
if len(ts) != 1 || ts[0].Host.ValueString() != "10.0.0.2" || ts[0].SiteID.ValueString() != "s1" {
t.Fatalf("targets: %+v", ts)
}
if _, has := fs.Mappings[got.ID.ValueString()]["newPassword"]; has {
t.Fatal("no password was configured, none should be sent")
}
// setting a password sends it once, and flags auth.password so the server keeps it
got.Password = types.StringValue("hunter22")
h.update(got)
stored := fs.Mappings[got.ID.ValueString()]
if stored["newPassword"] != "hunter22" || stored["auth"].(map[string]any)["password"] != true {
t.Fatalf("password not sent: %v", stored)
}
delete(stored, "newPassword")
// an unchanged password is not re-sent (that would sign every visitor out)
got.Enabled = types.BoolValue(false)
h.update(got)
if _, has := stored["newPassword"]; has {
t.Fatal("unchanged password was re-sent")
}
if stored["enabled"] != false {
t.Fatalf("update not applied: %v", stored)
}
// removing it from the configuration removes it on the server
got.Password = types.StringNull()
h.update(got)
if v, has := stored["newPassword"]; !has || v != "" {
t.Fatalf("password not cleared: %v", stored)
}
h.delete()
if len(fs.Mappings) != 0 {
t.Fatal("port still exists")
}
}
func TestDomainEdgeAndChannelLifecycle(t *testing.T) {
fs, c := fixture(t)
e := newHarness(t, NewEdge(), c)
e.create(edgeModel{ID: types.StringUnknown(), Name: types.StringValue("fra"), TunnelAddr: types.StringValue("1.2.3.4:51820"), OrgIDs: setOf(nil), Token: types.StringUnknown(), IPs: types.ListUnknown(types.StringType)})
var edge edgeModel
e.read(&edge)
if edge.Token.ValueString() == "" {
t.Fatalf("edge token missing: %+v", edge)
}
d := newHarness(t, NewDomain(), c)
d.create(domainModel{ID: types.StringUnknown(), Name: types.StringValue("example.com"), EdgeID: edge.ID, IP: types.StringValue(""), Note: types.StringValue("")})
var dom domainModel
d.read(&dom)
if dom.Name.ValueString() != "example.com" || dom.EdgeID != edge.ID {
t.Fatalf("domain: %+v", dom)
}
ch := newHarness(t, NewAlertChannel(), c)
ch.create(channelModel{ID: types.StringUnknown(), Name: types.StringValue("hook"), Kind: types.StringValue("webhook"), Target: types.StringValue("https://hooks.example.com/secret"),
Format: types.StringValue("json"), Token: types.StringNull(), Policy: types.StringValue(""), AllowPrivate: types.BoolValue(false), Enabled: types.BoolValue(true), Server: types.BoolValue(false)})
var got channelModel
ch.read(&got)
if got.Target.ValueString() != "https://hooks.example.com/secret" {
t.Fatalf("a webhook URL the server never returns must stay as configured, got %q", got.Target.ValueString())
}
ch.delete()
d.delete()
e.delete()
if len(fs.Channels)+len(fs.Domains)+len(fs.Edges) != 0 {
t.Fatal("resources left behind")
}
if !ch.read(&got) {
t.Fatal("deleted channel should read as gone")
}
}
+132
View File
@@ -0,0 +1,132 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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/setdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"git.veillax.com/Veillax/hhctl/client"
)
var (
_ resource.Resource = &siteResource{}
_ resource.ResourceWithConfigure = &siteResource{}
_ resource.ResourceWithImportState = &siteResource{}
)
func NewSite() resource.Resource { return &siteResource{} }
type siteResource struct{ c *client.Client }
type siteModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Tags types.Set `tfsdk:"tags"`
Token types.String `tfsdk:"token"`
}
func (r *siteResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_site"
}
func (r *siteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A site: a private network that runs the Bamboo agent and holds the real services.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true},
"tags": schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})),
Description: "Labels for grouping and filtering in the dashboard."},
"token": schema.StringAttribute{Computed: true, Sensitive: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Description: "The token to give the Bamboo agent."},
},
}
}
func (r *siteResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
r.c = clientFrom(req.ProviderData)
}
func stringsOf(ctx context.Context, s types.Set) []string {
out := []string{}
if !s.IsNull() && !s.IsUnknown() {
s.ElementsAs(ctx, &out, false)
}
return out
}
func setOf(v []string) types.Set {
elems := make([]attr.Value, 0, len(v))
for _, s := range v {
elems = append(elems, types.StringValue(s))
}
return types.SetValueMust(types.StringType, elems)
}
func (r *siteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan siteModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
s, err := r.c.CreateSite(plan.Name.ValueString(), stringsOf(ctx, plan.Tags))
if s != nil && s.ID != "" {
plan.ID, plan.Token = types.StringValue(s.ID), types.StringValue(s.Token)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) // keep it in state even if tagging failed
}
if err != nil {
resp.Diagnostics.AddError("Creating site", err.Error())
}
}
func (r *siteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state siteModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
s, err := r.c.GetSite(state.ID.ValueString())
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Reading site", err.Error())
return
}
state.Name, state.Tags = types.StringValue(s.Name), setOf(s.Tags)
if s.Token != "" {
state.Token = types.StringValue(s.Token)
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *siteResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan siteModel
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.UpdateSite(plan.ID.ValueString(), plan.Name.ValueString(), stringsOf(ctx, plan.Tags)); err != nil {
resp.Diagnostics.AddError("Updating site", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *siteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state siteModel
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
return
}
if err := r.c.DeleteSite(state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Deleting site", err.Error())
}
}
func (r *siteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
importByID(ctx, req, resp)
}