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,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)...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user