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

+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")
}
}