hhctl CLI and Terraform provider for Hammerhead
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bf340cdab4
19 files changed
+2442
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user