Now works - more or less fully

This commit is contained in:
Joachim Friberg committed 2026-08-28 15:12:21 +02:00
1 parent fb8d468943
commit 92701aab92
13 files changed
+1938 -12

No files matched your search

@@ -0,0 +1,123 @@
// Summer_Time_Configuration.html has THREE DST modes (Disable, Recurring,
// NonRecurring), each with its own full set of fields, ALL present in the
// DOM simultaneously (JS shows/hides based on the selected mode) -- same
// shape of problem as PortConfiguration.html's duplicate Link Speed fields,
// except here several labels collide WITHIN one table (Month/Hours/Minutes/
// Offset each appear once in the NonRecurring block and once in the
// Recurring block). Rather than lean on generic xepage.go parsing and its
// document-order last-wins behavior, this reads/writes via hardcoded field
// names extracted directly with regex -- unambiguous regardless of DOM
// order.
//
// Only Disable and Recurring are supported; NonRecurring is rejected
// outright rather than guessed at (it uses a different, absolute-date field
// set: Month/Date/Year/Hours/Minutes, v_3_1_1..v_3_10_1).
//
// Confirmed live before this was wired into a resource: submitting mode +
// only the Recurring block's fields (omitting the NonRecurring block
// entirely) was accepted with no err_flag and no FILTER_MISSING -- unlike
// PortConfiguration.html, this page does NOT require hidden hold_all_fields.
package client
import (
"fmt"
"regexp"
"strconv"
)
const (
dstConfigPath = "/Summer_Time_Configuration.html"
dstModeField = "v_1_1_1"
dstStartWeekField = "v_3_12_1"
dstStartDayField = "v_3_13_1"
dstStartMonthField = "v_3_14_1"
dstStartHourField = "v_3_15_1"
dstStartMinuteField = "v_3_399_1"
dstEndWeekField = "v_3_16_1"
dstEndDayField = "v_3_17_1"
dstEndMonthField = "v_3_18_1"
dstEndHourField = "v_3_19_1"
dstEndMinuteField = "v_3_498_1"
dstOffsetField = "v_3_20_1"
dstOffsetAcronymField = "v_3_21_1"
DSTModeDisable = "Disable"
DSTModeRecurring = "Recurring"
)
type DSTConfig struct {
Mode string
StartWeek int
StartDay string
StartMonth string
StartHour int
StartMinute int
EndWeek int
EndDay string
EndMonth string
EndHour int
EndMinute int
OffsetMinutes int
OffsetAcronym string
}
func extractHiddenFieldValue(html, name string) string {
re := regexp.MustCompile(`(?i)NAME=` + regexp.QuoteMeta(name) + `\b[^>]*VALUE="([^"]*)"`)
m := re.FindStringSubmatch(html)
if m == nil {
return ""
}
return m[1]
}
func (c *Client) ReadDSTConfig() (DSTConfig, error) {
html, err := c.Fetch(dstConfigPath)
if err != nil {
return DSTConfig{}, err
}
atoi := func(name string) int {
n, _ := strconv.Atoi(extractHiddenFieldValue(html, name))
return n
}
return DSTConfig{
Mode: extractHiddenFieldValue(html, dstModeField),
StartWeek: atoi(dstStartWeekField),
StartDay: extractHiddenFieldValue(html, dstStartDayField),
StartMonth: extractHiddenFieldValue(html, dstStartMonthField),
StartHour: atoi(dstStartHourField),
StartMinute: atoi(dstStartMinuteField),
EndWeek: atoi(dstEndWeekField),
EndDay: extractHiddenFieldValue(html, dstEndDayField),
EndMonth: extractHiddenFieldValue(html, dstEndMonthField),
EndHour: atoi(dstEndHourField),
EndMinute: atoi(dstEndMinuteField),
OffsetMinutes: atoi(dstOffsetField),
OffsetAcronym: extractHiddenFieldValue(html, dstOffsetAcronymField),
}, nil
}
func (c *Client) SetDSTConfig(desired DSTConfig) error {
if desired.Mode != DSTModeDisable && desired.Mode != DSTModeRecurring {
return fmt.Errorf("unsupported Daylight Saving Time mode %q -- this resource only supports %q and %q (NonRecurring is not supported)", desired.Mode, DSTModeDisable, DSTModeRecurring)
}
fields := map[string]string{
dstModeField: desired.Mode,
dstStartWeekField: strconv.Itoa(desired.StartWeek),
dstStartDayField: desired.StartDay,
dstStartMonthField: desired.StartMonth,
dstStartHourField: strconv.Itoa(desired.StartHour),
dstStartMinuteField: strconv.Itoa(desired.StartMinute),
dstEndWeekField: strconv.Itoa(desired.EndWeek),
dstEndDayField: desired.EndDay,
dstEndMonthField: desired.EndMonth,
dstEndHourField: strconv.Itoa(desired.EndHour),
dstEndMinuteField: strconv.Itoa(desired.EndMinute),
dstOffsetField: strconv.Itoa(desired.OffsetMinutes),
dstOffsetAcronymField: desired.OffsetAcronym,
}
_, err := c.SubmitForm(dstConfigPath, fields)
return err
}
@@ -0,0 +1,243 @@
// Trunking (link aggregation / LAG) spans two pages with a genuinely
// different shape from everything else ported so far: TrunkConfig.html is a
// dynamic list of named entities (create/delete trunks, each gets a
// switch-assigned "TrunkN" id), and TrunkMembership.html assigns member
// ports to one of them. Every other resource so far is a singleton or a
// fixed for_each set (24 ports); this is the first with real create/delete
// of sub-entities.
//
// All three operations (Create, set members, Delete) were verified live
// before being wired into a Terraform resource, using a throwaway test
// trunk on ports 5 and 6 (never port 24), cleaned up afterward:
//
// - Create: POST TrunkConfig.html with v_1_1_1=Enable (the "Create"
// field), v_1_2_1=<name>, v_1_4_1=Add. The switch assigns the next
// "TrunkN" id automatically.
// - Set members: POST TrunkMembership.html with the selected trunk id in
// v_1_1_1, plus ALL 24 ports' v_2_3_1 (port number), v_2_3_2 (per-port
// LACP mode), v_2_3_3 (membership: the trunk id string if a member,
// "1" if not -- the exact sentinel the page's own JS uses for "off").
// Two gotchas confirmed live: (1) the page's other hidden context
// fields (v_1_2_1, v_1_2_2, v_1_3_1, v_5_1_1, v_6_1_1, v_2_1_2, v_2_1_3,
// v_2_1_4, v_3_1_1, v_3_2_1, v_3_3_1, v_3_4_1) must be resubmitted
// unchanged or the switch rejects with FILTER_MISSING; (2) v_2_3_1 is
// marked DISABLED in the raw HTML (a real browser wouldn't normally
// submit a disabled field), but the page's own JS
// (lagViewAutoGen.js's showvalues()) explicitly un-disables it right
// before submit -- omitting it also fails with FILTER_MISSING.
// - Delete: POST TrunkConfig.html with that trunk's row fields
// (v_1_5_1..v_1_5_6) resubmitted unchanged, plus v_1_5_7=Enable (the
// "Delete" trigger) and v_1_5_8=Delete (a fixed action-name constant).
// Omitting the row's current-value fields fails with "Error! Failed to
// remove LAG." Critically, including the page's top-level Create
// section (v_1_1_1/v_1_2_1/v_1_4_1) in the SAME request as a delete
// causes the switch to create ANOTHER trunk as a side effect instead --
// confirmed live (deleting "Trunk1" while resending v_1_4_1=Add
// produced a brand new empty "Trunk2"). So a delete request must omit
// those fields entirely.
//
// The per-row/per-trunk field name prefix ("1.<index>.1.") uses this
// trunk's zero-based position in ListTrunks()'s order, the same positional
// pattern used throughout this firmware (PortConfiguration, Port
// Mirroring, LLDP, Trunk Membership). Verified live only for the
// single-trunk case (index 0); multi-trunk indexing is inferred by pattern,
// not independently confirmed against a live switch with 2+ trunks.
package client
import (
"fmt"
"strconv"
"strings"
)
const (
trunkConfigPath = "/TrunkConfig.html"
trunkMembershipPath = "/TrunkMembership.html"
trunkSummaryTableID = "1_5"
trunkInterfaceLabel = "Interface"
trunkNameLabel = "Trunk Name"
trunkMembersLabel = "Trunk Members"
trunkAdminModeLabel = "Admin Mode"
trunkStaticCapLabel = "Static Capability"
trunkCreateField = "v_1_1_1"
trunkNameField = "v_1_2_1"
trunkAddActionField = "v_1_4_1"
trunkRowInterfaceField = "v_1_5_1"
trunkRowNameField = "v_1_5_2"
trunkRowMembersField = "v_1_5_5"
trunkRowAdminField = "v_1_5_3"
trunkRowStaticField = "v_1_5_4"
trunkRowModifyField = "v_1_5_6"
trunkRowDeleteField = "v_1_5_7"
trunkRowDeleteActionField = "v_1_5_8"
)
type Trunk struct {
ID string
Name string
Members []string
AdminMode bool
StaticCapability bool
}
// ListTrunks returns every configured trunk, in the switch's own display
// order -- that order also determines the zero-based row index used in
// per-row field names, needed by DeleteTrunk.
func (c *Client) ListTrunks() ([]Trunk, error) {
tables, err := c.FetchAllTables(trunkConfigPath)
if err != nil {
return nil, err
}
var summary *Table
for i := range tables {
if tables[i].ID == trunkSummaryTableID {
summary = &tables[i]
break
}
}
if summary == nil {
return nil, fmt.Errorf("table id %q not found on %s", trunkSummaryTableID, trunkConfigPath)
}
trunks := make([]Trunk, 0, len(summary.Rows))
for _, row := range summary.Rows {
var members []string
if m := row[trunkMembersLabel]; m != "" {
members = strings.Split(m, ",")
}
trunks = append(trunks, Trunk{
ID: row[trunkInterfaceLabel],
Name: row[trunkNameLabel],
Members: members,
AdminMode: row[trunkAdminModeLabel] == "Enable",
StaticCapability: row[trunkStaticCapLabel] == "Enable",
})
}
return trunks, nil
}
// FindTrunkByName returns the trunk with the given name, its zero-based
// row index (for DeleteTrunk), or (nil, -1, nil) if no trunk has that name.
func (c *Client) FindTrunkByName(name string) (*Trunk, int, error) {
trunks, err := c.ListTrunks()
if err != nil {
return nil, -1, err
}
for i, t := range trunks {
if t.Name == name {
found := t
return &found, i, nil
}
}
return nil, -1, nil
}
// CreateTrunk creates a new trunk with the given name (1-15 alphanumeric
// characters) and returns it once created, including its switch-assigned
// "TrunkN" id.
func (c *Client) CreateTrunk(name string) (Trunk, error) {
_, err := c.SubmitForm(trunkConfigPath, map[string]string{
trunkCreateField: "Enable",
trunkNameField: name,
trunkAddActionField: "Add",
})
if err != nil {
return Trunk{}, err
}
trunk, _, err := c.FindTrunkByName(name)
if err != nil {
return Trunk{}, err
}
if trunk == nil {
return Trunk{}, fmt.Errorf("trunk %q not found after creation", name)
}
return *trunk, nil
}
// DeleteTrunk removes the named trunk. See the package doc comment for the
// two confirmed-live gotchas (must resend the row's current values, must
// NOT include the page's Create-section fields).
func (c *Client) DeleteTrunk(name string) error {
trunk, index, err := c.FindTrunkByName(name)
if err != nil {
return err
}
if trunk == nil {
return nil // already gone
}
adminValue := "Disable"
if trunk.AdminMode {
adminValue = "Enable"
}
staticValue := "Disable"
if trunk.StaticCapability {
staticValue = "Enable"
}
prefix := fmt.Sprintf("1.%d.1.", index)
_, err = c.SubmitForm(trunkConfigPath, map[string]string{
prefix + trunkRowInterfaceField: trunk.ID,
prefix + trunkRowNameField: trunk.Name,
prefix + trunkRowMembersField: strings.Join(trunk.Members, ","),
prefix + trunkRowAdminField: adminValue,
prefix + trunkRowStaticField: staticValue,
prefix + trunkRowModifyField: "Disable",
prefix + trunkRowDeleteField: "Enable",
prefix + trunkRowDeleteActionField: "Delete",
})
return err
}
const trunkMembershipTrunkIDField = "v_1_1_1"
// trunkMembershipHiddenFields must be resubmitted unchanged on every
// SetTrunkMembers call or the switch rejects the write with FILTER_MISSING
// -- confirmed live.
var trunkMembershipHiddenFields = []string{
"v_1_2_1", "v_1_2_2", "v_1_3_1", "v_5_1_1", "v_6_1_1",
"v_2_1_2", "v_2_1_3", "v_2_1_4", "v_3_1_1", "v_3_2_1", "v_3_3_1", "v_3_4_1",
}
// SetTrunkMembers sets exactly which of the switch's 24 ports belong to
// trunkID (e.g. "Trunk1"), replacing any previous membership entirely.
func (c *Client) SetTrunkMembers(trunkID string, members map[string]bool) error {
// Select the trunk first (submit_flag=1, reload -- doesn't apply
// anything, just like PortConfiguration.html's port selector) so the
// hidden context fields we read back reflect this specific trunk, not
// whatever was last selected.
html, err := c.postForm(trunkMembershipPath, map[string]string{
"submit_flag": "1",
"submit_target": "TrunkMembership.html",
"err_flag": "0",
"err_msg": "",
"clazz_information": "TrunkMembership.html",
trunkMembershipTrunkIDField: trunkID,
})
if err != nil {
return err
}
fields := map[string]string{trunkMembershipTrunkIDField: trunkID}
for _, name := range trunkMembershipHiddenFields {
fields[name] = extractHiddenFieldValue(html, name)
}
for i := 0; i < 24; i++ {
port := strconv.Itoa(i + 1)
prefix := fmt.Sprintf("1.%d.24.", i)
fields[prefix+"v_2_3_1"] = port
fields[prefix+"v_2_3_2"] = "Enable"
if members[port] {
fields[prefix+"v_2_3_3"] = trunkID
} else {
fields[prefix+"v_2_3_3"] = "1"
}
}
_, err = c.SubmitForm(trunkMembershipPath, fields)
return err
}
@@ -184,5 +184,11 @@ func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resourc
NewGreenFeaturesResource,
NewPortResource,
NewSystemDescriptionResource,
NewTimeZoneResource,
NewLLDPConfigurationResource,
NewDaylightSavingTimeResource,
NewFlowControlResource,
NewPortMirroringResource,
NewTrunkResource,
}
}
@@ -0,0 +1,235 @@
// Port of Summer_Time_Configuration.html -- see client/dst_config.go's
// package doc comment for why this page needed dedicated handling. Only
// Disable and Recurring modes are supported; NonRecurring is rejected by
// the client with a clear error rather than guessed at.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
var (
_ resource.Resource = &daylightSavingTimeResource{}
_ resource.ResourceWithConfigure = &daylightSavingTimeResource{}
)
func NewDaylightSavingTimeResource() resource.Resource {
return &daylightSavingTimeResource{}
}
type daylightSavingTimeResource struct {
client *client.Client
}
type daylightSavingTimeResourceModel struct {
ID types.String `tfsdk:"id"`
Mode types.String `tfsdk:"mode"`
StartWeek types.Int64 `tfsdk:"start_week"`
StartDay types.String `tfsdk:"start_day"`
StartMonth types.String `tfsdk:"start_month"`
StartHour types.Int64 `tfsdk:"start_hour"`
StartMinute types.Int64 `tfsdk:"start_minute"`
EndWeek types.Int64 `tfsdk:"end_week"`
EndDay types.String `tfsdk:"end_day"`
EndMonth types.String `tfsdk:"end_month"`
EndHour types.Int64 `tfsdk:"end_hour"`
EndMinute types.Int64 `tfsdk:"end_minute"`
OffsetMinutes types.Int64 `tfsdk:"offset_minutes"`
OffsetAcronym types.String `tfsdk:"offset_acronym"`
}
func (r *daylightSavingTimeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_daylight_saving_time"
}
func (r *daylightSavingTimeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's Daylight Saving Time Configuration page (Summer_Time_Configuration.html). Singleton -- there is only ever one of these per switch. Only \"Disable\" and \"Recurring\" modes are supported (the page's NonRecurring mode uses a different absolute-date field set and is rejected). Delete sets mode to \"Disable\", leaving the recurring rule fields as last configured (harmless once disabled).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"daylight_saving_time\" -- this is a singleton resource.",
},
"mode": schema.StringAttribute{
Required: true,
Description: "\"Disable\" or \"Recurring\" only.",
},
"start_week": schema.Int64Attribute{
Required: true,
Description: "Week of the month DST starts (1-4, or 5 for \"last\").",
},
"start_day": schema.StringAttribute{
Required: true,
Description: "e.g. \"Sun\".",
},
"start_month": schema.StringAttribute{
Required: true,
Description: "e.g. \"Mar\".",
},
"start_hour": schema.Int64Attribute{
Required: true,
Description: "0-23.",
},
"start_minute": schema.Int64Attribute{
Required: true,
Description: "0-59.",
},
"end_week": schema.Int64Attribute{
Required: true,
Description: "Week of the month DST ends (1-4, or 5 for \"last\").",
},
"end_day": schema.StringAttribute{
Required: true,
Description: "e.g. \"Sun\".",
},
"end_month": schema.StringAttribute{
Required: true,
Description: "e.g. \"Oct\".",
},
"end_hour": schema.Int64Attribute{
Required: true,
Description: "0-23.",
},
"end_minute": schema.Int64Attribute{
Required: true,
Description: "0-59.",
},
"offset_minutes": schema.Int64Attribute{
Required: true,
Description: "Minutes to shift the clock during DST. Range 1-1440.",
},
"offset_acronym": schema.StringAttribute{
Required: true,
Description: "0-4 characters, e.g. \"CEST\". Can be an empty string.",
},
},
}
}
func (r *daylightSavingTimeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func dstConfigToModel(c client.DSTConfig) daylightSavingTimeResourceModel {
return daylightSavingTimeResourceModel{
ID: types.StringValue("daylight_saving_time"),
Mode: types.StringValue(c.Mode),
StartWeek: types.Int64Value(int64(c.StartWeek)),
StartDay: types.StringValue(c.StartDay),
StartMonth: types.StringValue(c.StartMonth),
StartHour: types.Int64Value(int64(c.StartHour)),
StartMinute: types.Int64Value(int64(c.StartMinute)),
EndWeek: types.Int64Value(int64(c.EndWeek)),
EndDay: types.StringValue(c.EndDay),
EndMonth: types.StringValue(c.EndMonth),
EndHour: types.Int64Value(int64(c.EndHour)),
EndMinute: types.Int64Value(int64(c.EndMinute)),
OffsetMinutes: types.Int64Value(int64(c.OffsetMinutes)),
OffsetAcronym: types.StringValue(c.OffsetAcronym),
}
}
func modelToDSTConfig(m daylightSavingTimeResourceModel) client.DSTConfig {
return client.DSTConfig{
Mode: m.Mode.ValueString(),
StartWeek: int(m.StartWeek.ValueInt64()),
StartDay: m.StartDay.ValueString(),
StartMonth: m.StartMonth.ValueString(),
StartHour: int(m.StartHour.ValueInt64()),
StartMinute: int(m.StartMinute.ValueInt64()),
EndWeek: int(m.EndWeek.ValueInt64()),
EndDay: m.EndDay.ValueString(),
EndMonth: m.EndMonth.ValueString(),
EndHour: int(m.EndHour.ValueInt64()),
EndMinute: int(m.EndMinute.ValueInt64()),
OffsetMinutes: int(m.OffsetMinutes.ValueInt64()),
OffsetAcronym: m.OffsetAcronym.ValueString(),
}
}
func (r *daylightSavingTimeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.SetDSTConfig(modelToDSTConfig(plan)); err != nil {
resp.Diagnostics.AddError("Unable to set Daylight Saving Time state", err.Error())
return
}
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *daylightSavingTimeResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *daylightSavingTimeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.SetDSTConfig(modelToDSTConfig(plan)); err != nil {
resp.Diagnostics.AddError("Unable to set Daylight Saving Time state", err.Error())
return
}
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete sets mode to Disable, leaving the recurring rule fields as last
// configured -- harmless once DST itself is disabled, and avoids having to
// invent a "correct" default rule for an arbitrary switch's timezone.
func (r *daylightSavingTimeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
desired := modelToDSTConfig(state)
desired.Mode = client.DSTModeDisable
if err := r.client.SetDSTConfig(desired); err != nil {
resp.Diagnostics.AddError("Unable to reset Daylight Saving Time state on delete", err.Error())
}
}
@@ -0,0 +1,167 @@
// Port of SwitchConfig.html -- single boolean field, same shape as
// resource_locator.go.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
flowControlPath = "/SwitchConfig.html"
flowControlCaption = "Flow Control Configuration"
flowControlEnableLabel = "Enable Flow Control"
)
var (
_ resource.Resource = &flowControlResource{}
_ resource.ResourceWithConfigure = &flowControlResource{}
)
func NewFlowControlResource() resource.Resource {
return &flowControlResource{}
}
type flowControlResource struct {
client *client.Client
}
type flowControlResourceModel struct {
ID types.String `tfsdk:"id"`
Enabled types.Bool `tfsdk:"enabled"`
}
func (r *flowControlResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_flow_control"
}
func (r *flowControlResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's global Flow Control setting (SwitchConfig.html). Singleton -- there is only ever one of these per switch. Delete resets it to Enable (the switch's factory default).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"flow_control\" -- this is a singleton resource.",
},
"enabled": schema.BoolAttribute{
Required: true,
Description: "Whether 802.3x flow control is enabled switch-wide.",
},
},
}
}
func (r *flowControlResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func (r *flowControlResource) readState() (bool, error) {
tables, err := r.client.FetchTables(flowControlPath)
if err != nil {
return false, err
}
table, ok := tables[flowControlCaption]
if !ok {
return false, fmt.Errorf("table captioned %q not found on %s", flowControlCaption, flowControlPath)
}
return table.Record[flowControlEnableLabel] == "Enable", nil
}
func (r *flowControlResource) setState(enabled bool) error {
tables, err := r.client.FetchTables(flowControlPath)
if err != nil {
return err
}
table, ok := tables[flowControlCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", flowControlCaption, flowControlPath)
}
field, ok := table.FieldNames[flowControlEnableLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", flowControlEnableLabel, flowControlCaption)
}
value := "Disable"
if enabled {
value = "Enable"
}
_, err = r.client.SubmitForm(flowControlPath, map[string]string{field: value})
return err
}
func (r *flowControlResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan flowControlResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set Flow Control state", err.Error())
return
}
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *flowControlResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *flowControlResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan flowControlResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set Flow Control state", err.Error())
return
}
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets to Enable -- the switch's factory default, harmless (flow
// control is a link-layer congestion mechanism, not a security setting).
func (r *flowControlResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(true); err != nil {
resp.Diagnostics.AddError("Unable to reset Flow Control state on delete", err.Error())
}
}
@@ -0,0 +1,273 @@
// Port of LLDPConfig.html's "Global Mode" table only. The page also has a
// per-port "Interface Mode" table (Transmit/Receive Enable etc, one row per
// port with per-port field names like "1.0.24.v_2_1_2") -- that's a
// separate, more involved write target (needs the same per-port-field-name
// handling as resource_port.go) and isn't built yet; only requested was
// "LLDP Configuration" which maps to the global settings.
package provider
import (
"context"
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
lldpConfigPath = "/LLDPConfig.html"
lldpGlobalModeCaption = "Global Mode"
lldpTransmitIntervalLabel = "Transmit Interval"
lldpTransmitHoldLabel = "Transmit Hold"
lldpReinitDelayLabel = "Re-Initialization Delay"
lldpNotificationIntervalLabel = "Notification Interval"
)
var (
_ resource.Resource = &lldpConfigurationResource{}
_ resource.ResourceWithConfigure = &lldpConfigurationResource{}
)
func NewLLDPConfigurationResource() resource.Resource {
return &lldpConfigurationResource{}
}
type lldpConfigurationResource struct {
client *client.Client
}
type lldpConfigurationResourceModel struct {
ID types.String `tfsdk:"id"`
TransmitInterval types.Int64 `tfsdk:"transmit_interval"`
TransmitHold types.Int64 `tfsdk:"transmit_hold"`
ReinitDelay types.Int64 `tfsdk:"reinit_delay"`
NotificationInterval types.Int64 `tfsdk:"notification_interval"`
}
func (r *lldpConfigurationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_lldp_configuration"
}
func (r *lldpConfigurationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's LLDP Global Mode settings (LLDPConfig.html). Singleton -- there is only ever one of these per switch. Does NOT cover the page's per-port Interface Mode table -- see the package doc comment. Delete resets all four fields to their documented factory defaults.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"lldp_configuration\" -- this is a singleton resource.",
},
"transmit_interval": schema.Int64Attribute{
Required: true,
Description: "Seconds between LLDP transmissions. Range 5-32768 (switch default: 30).",
},
"transmit_hold": schema.Int64Attribute{
Required: true,
Description: "Multiplier for how long a receiver holds LLDP info before aging it out. Range 2-10 (switch default: 4).",
},
"reinit_delay": schema.Int64Attribute{
Required: true,
Description: "Seconds to wait before re-initializing LLDP on a port after it's disabled. Range 1-10 (switch default: 2).",
},
"notification_interval": schema.Int64Attribute{
Required: true,
Description: "Minimum seconds between LLDP change notifications. Range 5-3600 (switch default: 5).",
},
},
}
}
func (r *lldpConfigurationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type lldpConfigurationState struct {
TransmitInterval int64
TransmitHold int64
ReinitDelay int64
NotificationInterval int64
}
func (r *lldpConfigurationResource) readState() (lldpConfigurationState, error) {
tables, err := r.client.FetchTables(lldpConfigPath)
if err != nil {
return lldpConfigurationState{}, err
}
table, ok := tables[lldpGlobalModeCaption]
if !ok {
return lldpConfigurationState{}, fmt.Errorf("table captioned %q not found on %s", lldpGlobalModeCaption, lldpConfigPath)
}
parse := func(label string) (int64, error) {
v, err := strconv.ParseInt(table.Record[label], 10, 64)
if err != nil {
return 0, fmt.Errorf("parsing %q (%q) as int: %w", label, table.Record[label], err)
}
return v, nil
}
transmitInterval, err := parse(lldpTransmitIntervalLabel)
if err != nil {
return lldpConfigurationState{}, err
}
transmitHold, err := parse(lldpTransmitHoldLabel)
if err != nil {
return lldpConfigurationState{}, err
}
reinitDelay, err := parse(lldpReinitDelayLabel)
if err != nil {
return lldpConfigurationState{}, err
}
notificationInterval, err := parse(lldpNotificationIntervalLabel)
if err != nil {
return lldpConfigurationState{}, err
}
return lldpConfigurationState{
TransmitInterval: transmitInterval,
TransmitHold: transmitHold,
ReinitDelay: reinitDelay,
NotificationInterval: notificationInterval,
}, nil
}
func (r *lldpConfigurationResource) setState(desired lldpConfigurationState) error {
tables, err := r.client.FetchTables(lldpConfigPath)
if err != nil {
return err
}
table, ok := tables[lldpGlobalModeCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", lldpGlobalModeCaption, lldpConfigPath)
}
field := func(label string) (string, error) {
f, ok := table.FieldNames[label]
if !ok {
return "", fmt.Errorf("field %q not found in table %q", label, lldpGlobalModeCaption)
}
return f, nil
}
transmitIntervalField, err := field(lldpTransmitIntervalLabel)
if err != nil {
return err
}
transmitHoldField, err := field(lldpTransmitHoldLabel)
if err != nil {
return err
}
reinitDelayField, err := field(lldpReinitDelayLabel)
if err != nil {
return err
}
notificationIntervalField, err := field(lldpNotificationIntervalLabel)
if err != nil {
return err
}
_, err = r.client.SubmitForm(lldpConfigPath, map[string]string{
transmitIntervalField: strconv.FormatInt(desired.TransmitInterval, 10),
transmitHoldField: strconv.FormatInt(desired.TransmitHold, 10),
reinitDelayField: strconv.FormatInt(desired.ReinitDelay, 10),
notificationIntervalField: strconv.FormatInt(desired.NotificationInterval, 10),
})
return err
}
func lldpConfigStateToModel(s lldpConfigurationState) lldpConfigurationResourceModel {
return lldpConfigurationResourceModel{
ID: types.StringValue("lldp_configuration"),
TransmitInterval: types.Int64Value(s.TransmitInterval),
TransmitHold: types.Int64Value(s.TransmitHold),
ReinitDelay: types.Int64Value(s.ReinitDelay),
NotificationInterval: types.Int64Value(s.NotificationInterval),
}
}
func (r *lldpConfigurationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan lldpConfigurationResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := lldpConfigurationState{
TransmitInterval: plan.TransmitInterval.ValueInt64(),
TransmitHold: plan.TransmitHold.ValueInt64(),
ReinitDelay: plan.ReinitDelay.ValueInt64(),
NotificationInterval: plan.NotificationInterval.ValueInt64(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set LLDP Configuration state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *lldpConfigurationResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *lldpConfigurationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan lldpConfigurationResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := lldpConfigurationState{
TransmitInterval: plan.TransmitInterval.ValueInt64(),
TransmitHold: plan.TransmitHold.ValueInt64(),
ReinitDelay: plan.ReinitDelay.ValueInt64(),
NotificationInterval: plan.NotificationInterval.ValueInt64(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set LLDP Configuration state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets all four fields to their documented factory defaults --
// safe, no traffic impact.
func (r *lldpConfigurationResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
defaults := lldpConfigurationState{TransmitInterval: 30, TransmitHold: 4, ReinitDelay: 2, NotificationInterval: 5}
if err := r.setState(defaults); err != nil {
resp.Diagnostics.AddError("Unable to reset LLDP Configuration state on delete", err.Error())
}
}
@@ -0,0 +1,210 @@
// Port of FDBConfig.html's "Port Mirroring Configuration" table only (the
// page's filename is misleading -- it's actually Port Mirroring, not FDB).
// The page also has a per-source-port "Direction" table (25 rows: ports
// 1-24 + CPU, per-port field names like "1.0.25.v_1_3_2") -- same shape of
// complexity as LLDPConfig.html's per-port Interface Mode table, and
// likewise deferred as a separate not-yet-built write target.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
portMirroringPath = "/FDBConfig.html"
portMirroringCaption = "Port Mirroring Configuration"
portMirroringEnableLabel = "Enable Mirroring"
portMirroringDestinationLabel = "Destination Port"
// portMirroringHiddenContextField (v_2_2_2, always "1") sits next to
// Destination Port in the DOM with no visible label -- same
// "FILTER_MISSING if omitted" pattern as PortConfiguration.html's
// hidden UnitIndex/mstid fields. Confirmed live: omitting it fails with
// "Error! Failed to Set 'Enable Mirroring' ... error occured
// FILTER_MISSING".
portMirroringHiddenContextField = "v_2_2_2"
)
var (
_ resource.Resource = &portMirroringResource{}
_ resource.ResourceWithConfigure = &portMirroringResource{}
)
func NewPortMirroringResource() resource.Resource {
return &portMirroringResource{}
}
type portMirroringResource struct {
client *client.Client
}
type portMirroringResourceModel struct {
ID types.String `tfsdk:"id"`
Enabled types.Bool `tfsdk:"enabled"`
DestinationPort types.String `tfsdk:"destination_port"`
}
func (r *portMirroringResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_port_mirroring"
}
func (r *portMirroringResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's global Port Mirroring settings (FDBConfig.html). Singleton -- there is only ever one of these per switch. Does NOT cover the page's per-source-port Direction table -- see the package doc comment. Delete disables mirroring and clears the destination port.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"port_mirroring\" -- this is a singleton resource.",
},
"enabled": schema.BoolAttribute{
Required: true,
Description: "Whether port mirroring is enabled.",
},
"destination_port": schema.StringAttribute{
Required: true,
Description: "\"None\", or a port number \"1\"..\"24\" to receive mirrored traffic.",
},
},
}
}
func (r *portMirroringResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type portMirroringState struct {
Enabled bool
DestinationPort string
}
func (r *portMirroringResource) readState() (portMirroringState, error) {
tables, err := r.client.FetchTables(portMirroringPath)
if err != nil {
return portMirroringState{}, err
}
table, ok := tables[portMirroringCaption]
if !ok {
return portMirroringState{}, fmt.Errorf("table captioned %q not found on %s", portMirroringCaption, portMirroringPath)
}
return portMirroringState{
Enabled: table.Record[portMirroringEnableLabel] == "Enable",
DestinationPort: table.Record[portMirroringDestinationLabel],
}, nil
}
func (r *portMirroringResource) setState(desired portMirroringState) error {
tables, err := r.client.FetchTables(portMirroringPath)
if err != nil {
return err
}
table, ok := tables[portMirroringCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", portMirroringCaption, portMirroringPath)
}
enableField, ok := table.FieldNames[portMirroringEnableLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", portMirroringEnableLabel, portMirroringCaption)
}
destField, ok := table.FieldNames[portMirroringDestinationLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", portMirroringDestinationLabel, portMirroringCaption)
}
value := "Disable"
if desired.Enabled {
value = "Enable"
}
_, err = r.client.SubmitForm(portMirroringPath, map[string]string{
enableField: value,
destField: desired.DestinationPort,
portMirroringHiddenContextField: "1",
})
return err
}
func portMirroringStateToModel(s portMirroringState) portMirroringResourceModel {
return portMirroringResourceModel{
ID: types.StringValue("port_mirroring"),
Enabled: types.BoolValue(s.Enabled),
DestinationPort: types.StringValue(s.DestinationPort),
}
}
func (r *portMirroringResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan portMirroringResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := portMirroringState{Enabled: plan.Enabled.ValueBool(), DestinationPort: plan.DestinationPort.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Port Mirroring state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portMirroringResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portMirroringResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan portMirroringResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := portMirroringState{Enabled: plan.Enabled.ValueBool(), DestinationPort: plan.DestinationPort.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Port Mirroring state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete disables mirroring and clears the destination port -- matches the
// switch's observed factory default, harmless.
func (r *portMirroringResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(portMirroringState{Enabled: false, DestinationPort: "None"}); err != nil {
resp.Diagnostics.AddError("Unable to reset Port Mirroring state on delete", err.Error())
}
}
@@ -0,0 +1,194 @@
// Port of Time_Zone_Configuration.html. Simple scalar page, both fields
// (Time Zone combo, Acronym textbox) are read-write, no duplicate labels.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
timeZonePath = "/Time_Zone_Configuration.html"
timeZoneCaption = "Time Zone Configuration"
timeZoneNameLabel = "Time Zone"
timeZoneAcronymLabel = "Acronym"
)
var (
_ resource.Resource = &timeZoneResource{}
_ resource.ResourceWithConfigure = &timeZoneResource{}
)
func NewTimeZoneResource() resource.Resource {
return &timeZoneResource{}
}
type timeZoneResource struct {
client *client.Client
}
type timeZoneResourceModel struct {
ID types.String `tfsdk:"id"`
Zone types.String `tfsdk:"zone"`
Acronym types.String `tfsdk:"acronym"`
}
func (r *timeZoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_time_zone"
}
func (r *timeZoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's Time Zone Configuration page. Singleton -- there is only ever one of these per switch. Delete resets to \"None\" (no offset) with an empty acronym.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"time_zone\" -- this is a singleton resource.",
},
"zone": schema.StringAttribute{
Required: true,
Description: "e.g. \"(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna\", or \"None\". Not enum-validated client-side -- the exact display strings the switch accepts are numerous (see _xe_Time_Zone_Configuration.js on the switch); invalid values are caught by reading back rather than guessed at up front.",
},
"acronym": schema.StringAttribute{
Required: true,
Description: "0-4 characters, e.g. \"CET\". Can be an empty string.",
},
},
}
}
func (r *timeZoneResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type timeZoneState struct {
Zone string
Acronym string
}
func (r *timeZoneResource) readState() (timeZoneState, error) {
tables, err := r.client.FetchTables(timeZonePath)
if err != nil {
return timeZoneState{}, err
}
table, ok := tables[timeZoneCaption]
if !ok {
return timeZoneState{}, fmt.Errorf("table captioned %q not found on %s", timeZoneCaption, timeZonePath)
}
return timeZoneState{
Zone: table.Record[timeZoneNameLabel],
Acronym: table.Record[timeZoneAcronymLabel],
}, nil
}
func (r *timeZoneResource) setState(desired timeZoneState) error {
tables, err := r.client.FetchTables(timeZonePath)
if err != nil {
return err
}
table, ok := tables[timeZoneCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", timeZoneCaption, timeZonePath)
}
zoneField, ok := table.FieldNames[timeZoneNameLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", timeZoneNameLabel, timeZoneCaption)
}
acronymField, ok := table.FieldNames[timeZoneAcronymLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", timeZoneAcronymLabel, timeZoneCaption)
}
_, err = r.client.SubmitForm(timeZonePath, map[string]string{
zoneField: desired.Zone,
acronymField: desired.Acronym,
})
return err
}
func timeZoneStateToModel(s timeZoneState) timeZoneResourceModel {
return timeZoneResourceModel{
ID: types.StringValue("time_zone"),
Zone: types.StringValue(s.Zone),
Acronym: types.StringValue(s.Acronym),
}
}
func (r *timeZoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan timeZoneResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := timeZoneState{Zone: plan.Zone.ValueString(), Acronym: plan.Acronym.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Time Zone state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *timeZoneResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *timeZoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan timeZoneResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := timeZoneState{Zone: plan.Zone.ValueString(), Acronym: plan.Acronym.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Time Zone state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets to "None" (no timezone offset) with an empty acronym --
// harmless, matches the switch's out-of-box default.
func (r *timeZoneResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(timeZoneState{Zone: "None", Acronym: ""}); err != nil {
resp.Diagnostics.AddError("Unable to reset Time Zone state on delete", err.Error())
}
}
@@ -0,0 +1,254 @@
// Port of TrunkConfig.html + TrunkMembership.html -- see
// client/trunk.go's package doc comment for the full write-protocol
// writeup. Unlike every other resource so far (singletons, or a fixed
// for_each set of 24 ports), a trunk is a genuine named sub-entity: create,
// assign members, delete.
//
// admin_mode and static_capability are exposed read-only (Computed) only --
// only Create/set-members/Delete were verified live; toggling those two via
// the row-based "Modify" mechanism was not tested, so this resource doesn't
// claim to control them.
package provider
import (
"context"
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
var (
_ resource.Resource = &trunkResource{}
_ resource.ResourceWithConfigure = &trunkResource{}
)
func NewTrunkResource() resource.Resource {
return &trunkResource{}
}
type trunkResource struct {
client *client.Client
}
type trunkResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Members types.Set `tfsdk:"members"`
AdminMode types.Bool `tfsdk:"admin_mode"`
StaticCapability types.Bool `tfsdk:"static_capability"`
}
func (r *trunkResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_trunk"
}
func (r *trunkResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Creates a link aggregation trunk (LAG) and sets its member ports (TrunkConfig.html + TrunkMembership.html). The provider's admin_port (default 24) can never be included in members. Delete removes the trunk entirely.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Switch-assigned identifier, e.g. \"Trunk1\".",
},
"name": schema.StringAttribute{
Required: true,
Description: "1-15 alphanumeric characters. Changing this replaces the resource (trunks aren't renamed in place).",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"members": schema.SetAttribute{
Required: true,
ElementType: types.StringType,
Description: "Port numbers to include in this trunk, e.g. [\"5\", \"6\"]. Never include the admin_port.",
},
"admin_mode": schema.BoolAttribute{
Computed: true,
Description: "Read-only -- reflects the switch's current value, not settable by this resource (defaults to Enable on creation).",
},
"static_capability": schema.BoolAttribute{
Computed: true,
Description: "Read-only -- reflects the switch's current value, not settable by this resource (defaults to Disable, i.e. LACP/dynamic, on creation).",
},
},
}
}
func (r *trunkResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func (r *trunkResource) guardAdminPort(members []string) error {
for _, m := range members {
n, err := strconv.Atoi(m)
if err != nil {
return fmt.Errorf("member %q is not a valid port number: %w", m, err)
}
if int64(n) == r.client.AdminPort {
return fmt.Errorf("refusing to include interface %q in a trunk: it's the provider's admin_port (%d), protected from all writes by this resource", m, r.client.AdminPort)
}
}
return nil
}
func trunkToModel(t client.Trunk, membersSet types.Set) trunkResourceModel {
return trunkResourceModel{
ID: types.StringValue(t.ID),
Name: types.StringValue(t.Name),
Members: membersSet,
AdminMode: types.BoolValue(t.AdminMode),
StaticCapability: types.BoolValue(t.StaticCapability),
}
}
func (r *trunkResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan trunkResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
var members []string
resp.Diagnostics.Append(plan.Members.ElementsAs(ctx, &members, false)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.guardAdminPort(members); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
trunk, err := r.client.CreateTrunk(plan.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to create trunk", err.Error())
return
}
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
if err := r.client.SetTrunkMembers(trunk.ID, memberSet); err != nil {
resp.Diagnostics.AddError("Unable to set trunk members", err.Error())
return
}
final, _, err := r.client.FindTrunkByName(plan.Name.ValueString())
if err != nil || final == nil {
resp.Diagnostics.AddError("Unable to read back trunk", fmt.Sprintf("err=%v, found=%v", err, final != nil))
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, final.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
state := trunkToModel(*final, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *trunkResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
trunk, _, err := r.client.FindTrunkByName(state.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to read trunk", err.Error())
return
}
if trunk == nil {
resp.State.RemoveResource(ctx)
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, trunk.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
newState := trunkToModel(*trunk, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
func (r *trunkResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan trunkResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var members []string
resp.Diagnostics.Append(plan.Members.ElementsAs(ctx, &members, false)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.guardAdminPort(members); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
trunkID := state.ID.ValueString()
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
if err := r.client.SetTrunkMembers(trunkID, memberSet); err != nil {
resp.Diagnostics.AddError("Unable to set trunk members", err.Error())
return
}
final, _, err := r.client.FindTrunkByName(plan.Name.ValueString())
if err != nil || final == nil {
resp.Diagnostics.AddError("Unable to read back trunk", fmt.Sprintf("err=%v, found=%v", err, final != nil))
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, final.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
newState := trunkToModel(*final, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
// Delete removes the trunk entirely -- there's no meaningful "safe default"
// to reset a trunk to; a LAG either exists or it doesn't.
func (r *trunkResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteTrunk(state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("Unable to delete trunk", err.Error())
}
}