Plenty of writes now works and an expanded example exists in the terraform directory

This commit is contained in:
Joachim Friberg committed 2026-08-25 20:25:08 +02:00
1 parent a6f1019175
commit fb8d468943
15 files changed
+1344 -74

No files matched your search

@@ -10,12 +10,20 @@ import (
"net/url"
"regexp"
"strings"
"sync/atomic"
)
type Client struct {
BaseURL string
httpClient *http.Client
password string
dirty atomic.Bool
// AdminPort is the interface number that resource_port.go's write
// methods refuse to touch, regardless of what's requested -- protects
// whichever port is the switch's uplink/management path. Set once by
// provider.Configure(), read by resource_port.go's guard.
AdminPort int64
}
func NewClient(host string, https bool, password string) (*Client, error) {
@@ -87,6 +95,22 @@ func (c *Client) Login() error {
return nil
}
// Logout releases the switch's one active session slot (GET
// /index.html?logout=1, found by probing -- this firmware has no documented
// logout endpoint). Best-effort: call it when the provider process is
// shutting down so the *next* `tofu` invocation doesn't have to wait out the
// switch's own session timeout (see Web Parameters -> Session Timeout, ~5
// min) before it can log in.
func (c *Client) Logout() error {
resp, err := c.httpClient.Get(c.resolveURL("/index.html?logout=1"))
if err != nil {
return fmt.Errorf("logout request: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return nil
}
// Fetch GETs BaseURL+path (path may be relative or absolute) and returns the body.
func (c *Client) Fetch(path string) (string, error) {
resp, err := c.httpClient.Get(c.resolveURL(path))
@@ -108,6 +132,33 @@ func (c *Client) Fetch(path string) (string, error) {
var errFlagRejectedRe = regexp.MustCompile(`(?i)name="err_flag"[^>]*value="1"`)
var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`)
// postForm POSTs exactly the fields given (caller supplies everything,
// including submit_flag) and returns the raw response body. Low-level
// building block shared by SubmitForm (submit_flag=8, the common case) and
// port_config.go's reloadPortConfig (submit_flag=1, to select a port
// without applying any change).
func (c *Client) postForm(path string, fields map[string]string) (string, error) {
form := url.Values{}
for k, v := range fields {
form.Set(k, v)
}
resp, err := c.httpClient.PostForm(c.resolveURL(path), form)
if err != nil {
return "", fmt.Errorf("posting to %s: %w", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response for %s: %w", path, err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("posting to %s returned status %d", path, resp.StatusCode)
}
return string(body), nil
}
// SubmitForm POSTs fields to path, merged with the firmware's standard
// bookkeeping fields (submit_flag, submit_target, err_flag, err_msg,
// clazz_information), and returns the response body.
@@ -121,34 +172,24 @@ var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`)
// afterwards and compare, rather than trusting the absence of an error.
func (c *Client) SubmitForm(path string, fields map[string]string) (string, error) {
target := strings.TrimPrefix(path, "/")
form := url.Values{
merged := map[string]string{
// 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js);
// 1 is xui_operation_reload and silently applies nothing.
"submit_flag": {"8"},
"submit_target": {target},
"err_flag": {"0"},
"err_msg": {""},
"clazz_information": {target},
"submit_flag": "8",
"submit_target": target,
"err_flag": "0",
"err_msg": "",
"clazz_information": target,
}
for k, v := range fields {
form.Set(k, v)
merged[k] = v
}
resp, err := c.httpClient.PostForm(c.resolveURL(path), form)
bodyStr, err := c.postForm(path, merged)
if err != nil {
return "", fmt.Errorf("submitting form to %s: %w", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response for %s: %w", path, err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("submitting form to %s returned status %d", path, resp.StatusCode)
return "", err
}
bodyStr := string(body)
if errFlagRejectedRe.MatchString(bodyStr) {
msg := "unknown error"
if m := errMsgRe.FindStringSubmatch(bodyStr); m != nil {
@@ -156,5 +197,26 @@ func (c *Client) SubmitForm(path string, fields map[string]string) (string, erro
}
return "", fmt.Errorf("switch rejected write to %q: %s", path, msg)
}
c.dirty.Store(true)
return bodyStr, nil
}
// Dirty reports whether any SubmitForm call has succeeded yet during this
// Client's lifetime -- used by the provider to decide whether a
// save_running_config=true shutdown save is actually warranted (no point
// wearing the switch's flash on a plan-only run that wrote nothing).
func (c *Client) Dirty() bool {
return c.dirty.Load()
}
const SaveAllChangesPath = "/SaveAllChanges.html"
// SaveRunningConfig persists the switch's running configuration to its
// non-volatile config file (the web UI's "Save Configuration" button) --
// without this, config changes made via SubmitForm are lost on the next
// reboot/power cycle. The page has no editable fields, just a submit
// button, so this posts the baseline fields only.
func (c *Client) SaveRunningConfig() error {
_, err := c.SubmitForm(SaveAllChangesPath, map[string]string{})
return err
}
@@ -0,0 +1,128 @@
// PortConfiguration.html is unlike every other page ported so far: it's a
// single-port form with an Interface selector (v_1_1_1), not a per-port
// table. Selecting a port is done with a "reload" POST (submit_flag=1, not
// the usual submit_flag=8) carrying v_1_1_1=<port> -- confirmed live against
// real ports (1 and 5) before any write code was written.
//
// The page also has three rows all captioned "Link Speed" (v_1_8_1,
// v_1_13_1, v_1_15_1 -- one per SFP/PHY type group). READS are fine via the
// generic xepage.go parser regardless (both ports probed showed all three
// mirroring the same displayed value). WRITES are NOT interchangeable,
// though -- confirmed live: submitting the same value to all three at once
// was rejected by the switch ("Error! Failed to Set 'Link Speed' ... error
// occured") because each field has its own server-side accepted-value
// enum (v_1_13_1 is 100FX-SFP-specific, v_1_15_1 is 1000Mbps-SFP-specific).
// Per _xe_PortConfiguration.js's xa_1_12_1 mapping table, "No SFP" (i.e.
// RJ45 copper -- confirmed via port_summary to be every port on this
// switch, an all-copper 1810G-24GE) maps to v_1_8_1 as the one real field;
// this client only ever writes that one and rejects any other Physical
// Type outright rather than guessing.
//
// The write also needs three additional hidden fields the read-only
// generic parser can't see (they're TRs with no defleft/defright, just a
// bare hidden <TD>, so parseTable's scalar-row logic skips them entirely):
// v_1_21_1 (UnitIndex), v_1_31_1 (duplicate of the interface), v_1_2_1
// (mstid). Omitting them was confirmed live to fail with "FILTER_MISSING"
// on Admin Mode. Both probed ports show v_1_21_1=v_1_2_1="1" always and
// v_1_31_1 mirroring the interface -- hardcoded accordingly rather than
// scraped, since this switch is a single, non-stacked unit.
package client
import "fmt"
const (
portConfigPath = "/PortConfiguration.html"
portConfigCaption = "Port Configuration"
portInterfaceField = "v_1_1_1"
portUnitIndexField = "v_1_21_1"
portDupInterfaceField = "v_1_31_1"
portMstidField = "v_1_2_1"
portAdminModeField = "v_1_5_1"
portLinkSpeedFieldNoSFP = "v_1_8_1"
portInterfaceLabel = "Interface"
portLinkStatusLabel = "Link Status"
portPhysicalTypeLabel = "Physical Type"
portAdminModeLabel = "Admin Mode"
portLinkSpeedLabel = "Link Speed"
portPhysicalTypeNoSFP = "No SFP"
)
type PortConfig struct {
Interface string
AdminMode bool // true == Enable
LinkSpeed string
PhysicalType string
LinkStatus string
}
// reloadPortConfig POSTs submit_flag=1 (reload, not apply) with
// v_1_1_1=interfaceNum to select that port, then parses the "Port
// Configuration" table out of the response the same way FetchTables would.
func (c *Client) reloadPortConfig(interfaceNum string) (Table, error) {
target := "PortConfiguration.html"
form := map[string]string{
"submit_flag": "1", // xui_operation_reload -- select the port, apply nothing
"submit_target": target,
"err_flag": "0",
"err_msg": "",
"clazz_information": target,
portInterfaceField: interfaceNum,
}
body, err := c.postForm(portConfigPath, form)
if err != nil {
return Table{}, err
}
tables, err := ParseXETables(body)
if err != nil {
return Table{}, err
}
for _, t := range tables {
if t.Caption == portConfigCaption {
return t, nil
}
}
return Table{}, fmt.Errorf("table captioned %q not found on %s", portConfigCaption, portConfigPath)
}
// ReadPortConfig returns the current configuration for one port.
func (c *Client) ReadPortConfig(interfaceNum string) (PortConfig, error) {
table, err := c.reloadPortConfig(interfaceNum)
if err != nil {
return PortConfig{}, err
}
return PortConfig{
Interface: table.Record[portInterfaceLabel],
AdminMode: table.Record[portAdminModeLabel] == "Enable",
LinkSpeed: table.Record[portLinkSpeedLabel],
PhysicalType: table.Record[portPhysicalTypeLabel],
LinkStatus: table.Record[portLinkStatusLabel],
}, nil
}
// SetPortConfig applies Admin Mode and Link Speed to one port. Only
// supports "No SFP" (RJ45 copper) ports -- see the package doc comment.
func (c *Client) SetPortConfig(interfaceNum string, adminMode bool, linkSpeed string) error {
current, err := c.reloadPortConfig(interfaceNum)
if err != nil {
return err
}
physicalType := current.Record[portPhysicalTypeLabel]
if physicalType != portPhysicalTypeNoSFP {
return fmt.Errorf("interface %q has Physical Type %q -- this resource only supports %q (RJ45 copper) ports", interfaceNum, physicalType, portPhysicalTypeNoSFP)
}
value := "Disable"
if adminMode {
value = "Enable"
}
fields := map[string]string{
portInterfaceField: interfaceNum,
portUnitIndexField: "1",
portDupInterfaceField: interfaceNum,
portMstidField: "1",
portAdminModeField: value,
portLinkSpeedFieldNoSFP: linkSpeed,
}
_, err = c.SubmitForm(portConfigPath, fields)
return err
}
@@ -17,21 +17,48 @@ import (
var _ provider.Provider = &hpe1810Provider{}
type hpe1810Provider struct {
version string
version string
client *client.Client
saveRunningConfig bool
}
// hpe1810ProviderModel mirrors config.py's three settings (SWITCH_HOST,
// SWITCH_HTTPS, SWITCH_PASSWORD) from the Python reference implementation.
// SWITCH_HTTPS, SWITCH_PASSWORD) from the Python reference implementation,
// plus save_running_config which has no Python-side equivalent.
type hpe1810ProviderModel struct {
Host types.String `tfsdk:"host"`
HTTPS types.Bool `tfsdk:"https"`
Password types.String `tfsdk:"password"`
Host types.String `tfsdk:"host"`
HTTPS types.Bool `tfsdk:"https"`
Password types.String `tfsdk:"password"`
SaveRunningConfig types.Bool `tfsdk:"save_running_config"`
AdminPort types.Int64 `tfsdk:"admin_port"`
}
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &hpe1810Provider{version: version}
// New returns a single provider instance (not a fresh one per call) so that
// main.go can hold onto it and call Shutdown() after providerserver.Serve
// returns -- see Shutdown() below.
func New(version string) *hpe1810Provider {
return &hpe1810Provider{version: version}
}
// Shutdown runs once, when the provider process is exiting (main.go calls
// it right after providerserver.Serve returns). It optionally persists any
// changes made this run to the switch's non-volatile config, then always
// releases the session slot.
//
// The save only happens if save_running_config=true AND the client actually
// wrote something this run (client.Dirty()) -- a plan-only invocation, or a
// run where nothing changed, does not trigger a save. This matters because
// "Save Configuration" writes to flash, and doing that on every single
// `tofu` invocation regardless of whether anything changed would wear it
// out for no reason.
func (p *hpe1810Provider) Shutdown() {
if p.client == nil {
return
}
if p.saveRunningConfig && p.client.Dirty() {
_ = p.client.SaveRunningConfig()
}
_ = p.client.Logout()
}
func (p *hpe1810Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -56,6 +83,14 @@ func (p *hpe1810Provider) Schema(_ context.Context, _ provider.SchemaRequest, re
Sensitive: true,
Description: "Switch login password. Falls back to the HPE1810_PASSWORD env var, then empty.",
},
"save_running_config": schema.BoolAttribute{
Optional: true,
Description: "If true, persist changes to the switch's non-volatile config (\"Save Configuration\") at the end of this run, but only when this run actually wrote something. Falls back to the HPE1810_SAVE_RUNNING_CONFIG env var, then false. Leave false while iterating -- IaC already has the config as source of truth, and saving unnecessarily wears the switch's flash.",
},
"admin_port": schema.Int64Attribute{
Optional: true,
Description: "The interface number hpe1810_port refuses to write to under any circumstances -- protects the switch's uplink/management port. Falls back to the HPE1810_ADMIN_PORT env var, then 24. An int, not a string, so it can't be set to a non-numeric value that would silently never match a real interface.",
},
},
}
}
@@ -88,16 +123,37 @@ func (p *hpe1810Provider) Configure(ctx context.Context, req provider.ConfigureR
password = config.Password.ValueString()
}
saveRunningConfig := false
if envSave := os.Getenv("HPE1810_SAVE_RUNNING_CONFIG"); envSave != "" {
saveRunningConfig, _ = strconv.ParseBool(envSave)
}
if !config.SaveRunningConfig.IsNull() {
saveRunningConfig = config.SaveRunningConfig.ValueBool()
}
p.saveRunningConfig = saveRunningConfig
adminPort := int64(24)
if envAdminPort := os.Getenv("HPE1810_ADMIN_PORT"); envAdminPort != "" {
if parsed, err := strconv.ParseInt(envAdminPort, 10, 64); err == nil {
adminPort = parsed
}
}
if !config.AdminPort.IsNull() {
adminPort = config.AdminPort.ValueInt64()
}
c, err := client.NewClient(host, https, password)
if err != nil {
resp.Diagnostics.AddError("Unable to create switch client", err.Error())
return
}
c.AdminPort = adminPort
if err := c.Login(); err != nil {
resp.Diagnostics.AddError("Unable to log in to switch", err.Error())
return
}
p.client = c
resp.DataSourceData = c
resp.ResourceData = c
}
@@ -121,8 +177,12 @@ func (p *hpe1810Provider) DataSources(_ context.Context) []func() datasource.Dat
}
}
// Resources will grow one entry per actions/*.py equivalent (locator first)
// as they're ported.
// Resources: one entry per actions/*.py equivalent, as they're ported.
func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{}
return []func() resource.Resource{
NewLocatorResource,
NewGreenFeaturesResource,
NewPortResource,
NewSystemDescriptionResource,
}
}
@@ -0,0 +1,253 @@
// First resource ported straight from a saved snapshot (no Python
// extractors/actions equivalent existed yet) -- see
// ~/code/experiments/hpe/page_snapshots/greenmode.html.html. Safest write
// target after Locator per AGENT.md's risk triage: EEE power-saving, no
// traffic impact.
//
// The page has two tables in one <FORM>: "Green Features Configuration"
// (Green Mode, Mode LED Time) and "Phy Auto Power-Down" (Mode). All three
// fields are always submitted together on any change -- the firmware's form
// includes all of them at once in a real browser submission, and there's no
// evidence omitted fields are left alone rather than reset, so this plays it
// safe and always sends current-or-desired values for all three.
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 (
greenFeaturesPath = "/greenmode.html"
greenFeaturesCaption = "Green Features Configuration"
greenFeaturesGreenModeField = "Green Mode"
greenFeaturesLEDTimeField = "Mode LED Time"
phyAutoPowerDownCaption = "Phy Auto Power-Down"
phyAutoPowerDownModeField = "Mode"
)
var (
_ resource.Resource = &greenFeaturesResource{}
_ resource.ResourceWithConfigure = &greenFeaturesResource{}
)
func NewGreenFeaturesResource() resource.Resource {
return &greenFeaturesResource{}
}
type greenFeaturesResource struct {
client *client.Client
}
type greenFeaturesResourceModel struct {
ID types.String `tfsdk:"id"`
GreenMode types.Bool `tfsdk:"green_mode"`
ModeLEDTime types.Int64 `tfsdk:"mode_led_time"`
PhyAutoPowerDown types.Bool `tfsdk:"phy_auto_power_down"`
}
func (r *greenFeaturesResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_green_features"
}
func (r *greenFeaturesResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's Green Features (EEE power-saving) page. Singleton -- there is only ever one of these per switch. Delete resets all three fields to their documented factory defaults (Green Mode/Phy Auto Power-Down Enable, Mode LED Time 10).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"green_features\" -- this is a singleton resource.",
},
"green_mode": schema.BoolAttribute{
Required: true,
Description: "Whether EEE (Energy Efficient Ethernet) is enabled.",
},
"mode_led_time": schema.Int64Attribute{
Required: true,
Description: "Minutes of link-up activity before the port LED dims in green mode. Range 1-30 (switch default: 10).",
},
"phy_auto_power_down": schema.BoolAttribute{
Required: true,
Description: "Whether PHY Auto Power-Down (link-partner-absent power saving) is enabled.",
},
},
}
}
func (r *greenFeaturesResource) 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 greenFeaturesState struct {
GreenMode bool
ModeLEDTime int64
PhyAutoPowerDown bool
}
func (r *greenFeaturesResource) readState() (greenFeaturesState, error) {
tables, err := r.client.FetchTables(greenFeaturesPath)
if err != nil {
return greenFeaturesState{}, err
}
green, ok := tables[greenFeaturesCaption]
if !ok {
return greenFeaturesState{}, fmt.Errorf("table captioned %q not found on %s", greenFeaturesCaption, greenFeaturesPath)
}
phy, ok := tables[phyAutoPowerDownCaption]
if !ok {
return greenFeaturesState{}, fmt.Errorf("table captioned %q not found on %s", phyAutoPowerDownCaption, greenFeaturesPath)
}
ledTime, err := strconv.ParseInt(green.Record[greenFeaturesLEDTimeField], 10, 64)
if err != nil {
return greenFeaturesState{}, fmt.Errorf("parsing %q as int: %w", green.Record[greenFeaturesLEDTimeField], err)
}
return greenFeaturesState{
GreenMode: green.Record[greenFeaturesGreenModeField] == "Enable",
ModeLEDTime: ledTime,
PhyAutoPowerDown: phy.Record[phyAutoPowerDownModeField] == "Enable",
}, nil
}
func (r *greenFeaturesResource) setState(desired greenFeaturesState) error {
tables, err := r.client.FetchTables(greenFeaturesPath)
if err != nil {
return err
}
green, ok := tables[greenFeaturesCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", greenFeaturesCaption, greenFeaturesPath)
}
phy, ok := tables[phyAutoPowerDownCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", phyAutoPowerDownCaption, greenFeaturesPath)
}
greenModeField, ok := green.FieldNames[greenFeaturesGreenModeField]
if !ok {
return fmt.Errorf("field %q not found in table %q", greenFeaturesGreenModeField, greenFeaturesCaption)
}
ledTimeField, ok := green.FieldNames[greenFeaturesLEDTimeField]
if !ok {
return fmt.Errorf("field %q not found in table %q", greenFeaturesLEDTimeField, greenFeaturesCaption)
}
phyModeField, ok := phy.FieldNames[phyAutoPowerDownModeField]
if !ok {
return fmt.Errorf("field %q not found in table %q", phyAutoPowerDownModeField, phyAutoPowerDownCaption)
}
enableDisable := func(b bool) string {
if b {
return "Enable"
}
return "Disable"
}
_, err = r.client.SubmitForm(greenFeaturesPath, map[string]string{
greenModeField: enableDisable(desired.GreenMode),
ledTimeField: strconv.FormatInt(desired.ModeLEDTime, 10),
phyModeField: enableDisable(desired.PhyAutoPowerDown),
})
return err
}
func stateToModel(s greenFeaturesState) greenFeaturesResourceModel {
return greenFeaturesResourceModel{
ID: types.StringValue("green_features"),
GreenMode: types.BoolValue(s.GreenMode),
ModeLEDTime: types.Int64Value(s.ModeLEDTime),
PhyAutoPowerDown: types.BoolValue(s.PhyAutoPowerDown),
}
}
func (r *greenFeaturesResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan greenFeaturesResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := greenFeaturesState{
GreenMode: plan.GreenMode.ValueBool(),
ModeLEDTime: plan.ModeLEDTime.ValueInt64(),
PhyAutoPowerDown: plan.PhyAutoPowerDown.ValueBool(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Green Features state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Green Features state", err.Error())
return
}
state := stateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *greenFeaturesResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Green Features state", err.Error())
return
}
state := stateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *greenFeaturesResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan greenFeaturesResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := greenFeaturesState{
GreenMode: plan.GreenMode.ValueBool(),
ModeLEDTime: plan.ModeLEDTime.ValueInt64(),
PhyAutoPowerDown: plan.PhyAutoPowerDown.ValueBool(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Green Features state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Green Features state", err.Error())
return
}
state := stateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets all three fields to their documented factory defaults --
// safe here since this is a pure power-saving feature with no traffic
// impact (Safe tier in AGENT.md's risk triage), unlike e.g. Network Setup
// where touching anything on delete would be the risky move.
func (r *greenFeaturesResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
defaults := greenFeaturesState{GreenMode: true, ModeLEDTime: 10, PhyAutoPowerDown: true}
if err := r.setState(defaults); err != nil {
resp.Diagnostics.AddError("Unable to reset Green Features state on delete", err.Error())
}
}
@@ -0,0 +1,175 @@
// Port of ~/code/experiments/hpe/actions/locator.py -- the first write
// resource. Singleton setting (nothing to "create" in the usual sense), so
// Create/Update both just push the desired state and Read always reports
// whatever the switch currently has.
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 (
locatorPath = "/locator.html"
locatorCaption = "Locator Configuration"
locatorField = "Locate"
)
var (
_ resource.Resource = &locatorResource{}
_ resource.ResourceWithConfigure = &locatorResource{}
)
func NewLocatorResource() resource.Resource {
return &locatorResource{}
}
type locatorResource struct {
client *client.Client
}
type locatorResourceModel struct {
ID types.String `tfsdk:"id"`
Enabled types.Bool `tfsdk:"enabled"`
}
func (r *locatorResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_locator"
}
func (r *locatorResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's front-panel Locator LED (blink on/off). Singleton -- there is only ever one of these per switch. Delete resets it to Disable (a safe, harmless default), not a no-op.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"locator\" -- this is a singleton resource.",
},
"enabled": schema.BoolAttribute{
Required: true,
Description: "Whether the locator LED should be blinking.",
},
},
}
}
func (r *locatorResource) 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
}
// readLocatorState mirrors actions/locator.py's get_locator_state().
func (r *locatorResource) readLocatorState() (bool, error) {
tables, err := r.client.FetchTables(locatorPath)
if err != nil {
return false, err
}
table, ok := tables[locatorCaption]
if !ok {
return false, fmt.Errorf("table captioned %q not found on %s", locatorCaption, locatorPath)
}
return table.Record[locatorField] == "Enable", nil
}
// setLocatorState mirrors actions/locator.py's set_locator(): it looks up
// the real (hidden) form field name via FieldNames rather than hardcoding
// it, same as the Python reference implementation does.
func (r *locatorResource) setLocatorState(enabled bool) error {
tables, err := r.client.FetchTables(locatorPath)
if err != nil {
return err
}
table, ok := tables[locatorCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", locatorCaption, locatorPath)
}
fieldName, ok := table.FieldNames[locatorField]
if !ok {
return fmt.Errorf("field %q not found in table %q", locatorField, locatorCaption)
}
value := "Disable"
if enabled {
value = "Enable"
}
_, err = r.client.SubmitForm(locatorPath, map[string]string{fieldName: value})
return err
}
func (r *locatorResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan locatorResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setLocatorState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set locator state", err.Error())
return
}
enabled, err := r.readLocatorState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back locator state", err.Error())
return
}
state := locatorResourceModel{ID: types.StringValue("locator"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *locatorResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
enabled, err := r.readLocatorState()
if err != nil {
resp.Diagnostics.AddError("Unable to read locator state", err.Error())
return
}
state := locatorResourceModel{ID: types.StringValue("locator"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *locatorResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan locatorResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setLocatorState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set locator state", err.Error())
return
}
enabled, err := r.readLocatorState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back locator state", err.Error())
return
}
state := locatorResourceModel{ID: types.StringValue("locator"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets the locator to Disable (its safe, harmless default) rather
// than leaving it however it was -- see AGENT.md's per-resource Delete
// semantics notes. Unlike Network Setup, there's no risk in touching this on
// delete: the locator is purely a physical LED, not a management config.
func (r *locatorResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setLocatorState(false); err != nil {
resp.Diagnostics.AddError("Unable to reset locator state on delete", err.Error())
}
}
@@ -0,0 +1,218 @@
// Port of PortConfiguration.html -- see client/port_config.go's package doc
// for why this page needed dedicated (non-generic) client-side handling.
//
// This is a materially riskier write target than Locator/Green Features: a
// wrong write can disable the port a management session, or the switch's
// only uplink, depends on. See the admin-port guard below.
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 = &portResource{}
_ resource.ResourceWithConfigure = &portResource{}
)
func NewPortResource() resource.Resource {
return &portResource{}
}
type portResource struct {
client *client.Client
}
type portResourceModel struct {
ID types.String `tfsdk:"id"`
Interface types.String `tfsdk:"interface"`
AdminMode types.Bool `tfsdk:"admin_mode"`
LinkSpeed types.String `tfsdk:"link_speed"`
PhysicalType types.String `tfsdk:"physical_type"`
LinkStatus types.String `tfsdk:"link_status"`
}
func (r *portResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_port"
}
func (r *portResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls one switch port's Admin Mode and Link Speed. Intended to be driven with for_each over a map keyed by port number. The provider's admin_port (default 24) can never be written by this resource, regardless of what's in the for_each map -- see the provider schema.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Same as interface.",
},
"interface": schema.StringAttribute{
Required: true,
Description: "Port number, \"1\"..\"24\". Changing this replaces the resource (it's the for_each key in normal usage).",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"admin_mode": schema.BoolAttribute{
Required: true,
Description: "Whether the port is enabled.",
},
"link_speed": schema.StringAttribute{
Required: true,
Description: "e.g. \"Auto\", \"100 Mbps Full Duplex\". Not enum-validated client-side -- the switch's accepted values for gigabit copper ports aren't fully confirmed, so invalid values are caught by reading the value back rather than guessed at up front.",
},
"physical_type": schema.StringAttribute{
Computed: true,
Description: "Read-only, e.g. \"No SFP\" for RJ45 copper.",
},
"link_status": schema.StringAttribute{
Computed: true,
Description: "Read-only, \"Link Up\" or \"Link Down\".",
},
},
}
}
func (r *portResource) 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
}
// guardAdminPort refuses any write targeting the provider's protected
// admin_port (default 24 -- the switch's uplink), and refuses a
// non-numeric interface value outright. This is the safety net requested
// explicitly: even if the admin port ends up in a for_each map by mistake,
// the write never reaches the switch.
func (r *portResource) guardAdminPort(interfaceStr string) error {
n, err := strconv.Atoi(interfaceStr)
if err != nil {
return fmt.Errorf("interface %q is not a valid port number: %w", interfaceStr, err)
}
if int64(n) == r.client.AdminPort {
return fmt.Errorf("refusing to write to interface %q: it's the provider's admin_port (%d), protected from all writes by this resource", interfaceStr, r.client.AdminPort)
}
return nil
}
func portConfigToModel(pc client.PortConfig) portResourceModel {
return portResourceModel{
ID: types.StringValue(pc.Interface),
Interface: types.StringValue(pc.Interface),
AdminMode: types.BoolValue(pc.AdminMode),
LinkSpeed: types.StringValue(pc.LinkSpeed),
PhysicalType: types.StringValue(pc.PhysicalType),
LinkStatus: types.StringValue(pc.LinkStatus),
}
}
func (r *portResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan portResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
iface := plan.Interface.ValueString()
if err := r.guardAdminPort(iface); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
if err := r.client.SetPortConfig(iface, plan.AdminMode.ValueBool(), plan.LinkSpeed.ValueString()); err != nil {
resp.Diagnostics.AddError("Unable to set port config", err.Error())
return
}
pc, err := r.client.ReadPortConfig(iface)
if err != nil {
resp.Diagnostics.AddError("Unable to read back port config", err.Error())
return
}
state := portConfigToModel(pc)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state portResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
pc, err := r.client.ReadPortConfig(state.Interface.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to read port config", err.Error())
return
}
newState := portConfigToModel(pc)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
func (r *portResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan portResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
iface := plan.Interface.ValueString()
if err := r.guardAdminPort(iface); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
if err := r.client.SetPortConfig(iface, plan.AdminMode.ValueBool(), plan.LinkSpeed.ValueString()); err != nil {
resp.Diagnostics.AddError("Unable to set port config", err.Error())
return
}
pc, err := r.client.ReadPortConfig(iface)
if err != nil {
resp.Diagnostics.AddError("Unable to read back port config", err.Error())
return
}
state := portConfigToModel(pc)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete disables the port and resets Link Speed to Auto -- matches
// AGENT.md's Port Configuration risk-triage decision: "no longer managed by
// Terraform" should fail toward safer (closed), not toward whatever it
// happened to be. Safe to apply unconditionally here because the one port
// that must never be disabled (admin_port) is structurally excluded by the
// guard below -- it can never have been Created in the first place.
func (r *portResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state portResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
iface := state.Interface.ValueString()
if err := r.guardAdminPort(iface); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
if err := r.client.SetPortConfig(iface, false, "Auto"); err != nil {
resp.Diagnostics.AddError("Unable to reset port config on delete", err.Error())
}
}
@@ -0,0 +1,221 @@
// Port of the writable subset of SysDescription.html. Most fields on that
// page (Description, Software Version, Object ID, Up Time, Current Time,
// Date) are pure device-reported info -- confirmed not writable by the
// page's own client-side validation script (xeValData), which only defines
// error messages for v_1_2_1/v_1_3_1/v_1_4_1 (System Name/Location/Contact).
// Those three are what this resource manages; the rest stay exclusively in
// data_source_system_description.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 (
sysDescriptionCaption = "System Description"
sysNameLabel = "System Name"
sysLocationLabel = "System Location"
sysContactLabel = "System Contact"
)
var (
_ resource.Resource = &systemDescriptionResource{}
_ resource.ResourceWithConfigure = &systemDescriptionResource{}
)
func NewSystemDescriptionResource() resource.Resource {
return &systemDescriptionResource{}
}
type systemDescriptionResource struct {
client *client.Client
}
type systemDescriptionResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Location types.String `tfsdk:"location"`
Contact types.String `tfsdk:"contact"`
}
func (r *systemDescriptionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_system_description"
}
func (r *systemDescriptionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's System Name/Location/Contact (SysDescription.html). Singleton -- there is only ever one of these per switch. The other fields on that page (description, software version, uptime, ...) are read-only device info -- see the hpe1810_system_description data source for those. Delete resets all three fields to empty strings.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"system_description\" -- this is a singleton resource.",
},
"name": schema.StringAttribute{
Required: true,
Description: "System Name (sysName).",
},
"location": schema.StringAttribute{
Required: true,
Description: "System Location (sysLocation).",
},
"contact": schema.StringAttribute{
Required: true,
Description: "System Contact (sysContact). Can be an empty string.",
},
},
}
}
func (r *systemDescriptionResource) 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 systemDescriptionState struct {
Name string
Location string
Contact string
}
func (r *systemDescriptionResource) readState() (systemDescriptionState, error) {
tables, err := r.client.FetchTables(systemDescriptionPath)
if err != nil {
return systemDescriptionState{}, err
}
table, ok := tables[sysDescriptionCaption]
if !ok {
return systemDescriptionState{}, fmt.Errorf("table captioned %q not found on %s", sysDescriptionCaption, systemDescriptionPath)
}
return systemDescriptionState{
Name: table.Record[sysNameLabel],
Location: table.Record[sysLocationLabel],
Contact: table.Record[sysContactLabel],
}, nil
}
func (r *systemDescriptionResource) setState(desired systemDescriptionState) error {
tables, err := r.client.FetchTables(systemDescriptionPath)
if err != nil {
return err
}
table, ok := tables[sysDescriptionCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", sysDescriptionCaption, systemDescriptionPath)
}
nameField, ok := table.FieldNames[sysNameLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", sysNameLabel, sysDescriptionCaption)
}
locationField, ok := table.FieldNames[sysLocationLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", sysLocationLabel, sysDescriptionCaption)
}
contactField, ok := table.FieldNames[sysContactLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", sysContactLabel, sysDescriptionCaption)
}
_, err = r.client.SubmitForm(systemDescriptionPath, map[string]string{
nameField: desired.Name,
locationField: desired.Location,
contactField: desired.Contact,
})
return err
}
func sysDescStateToModel(s systemDescriptionState) systemDescriptionResourceModel {
return systemDescriptionResourceModel{
ID: types.StringValue("system_description"),
Name: types.StringValue(s.Name),
Location: types.StringValue(s.Location),
Contact: types.StringValue(s.Contact),
}
}
func (r *systemDescriptionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan systemDescriptionResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := systemDescriptionState{
Name: plan.Name.ValueString(),
Location: plan.Location.ValueString(),
Contact: plan.Contact.ValueString(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set System Description state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back System Description state", err.Error())
return
}
state := sysDescStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *systemDescriptionResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read System Description state", err.Error())
return
}
state := sysDescStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *systemDescriptionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan systemDescriptionResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := systemDescriptionState{
Name: plan.Name.ValueString(),
Location: plan.Location.ValueString(),
Contact: plan.Contact.ValueString(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set System Description state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back System Description state", err.Error())
return
}
state := sysDescStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets all three fields to empty strings -- harmless, and matches
// the switch's out-of-box default before any of this was configured.
func (r *systemDescriptionResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(systemDescriptionState{}); err != nil {
resp.Diagnostics.AddError("Unable to reset System Description state on delete", err.Error())
}
}
+8 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
tfprovider "github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"terraform-provider-hpe1810/internal/provider"
@@ -15,12 +16,18 @@ import (
var version = "dev"
func main() {
err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{
p := provider.New(version)
err := providerserver.Serve(context.Background(), func() tfprovider.Provider { return p }, providerserver.ServeOpts{
// Only meaningful once/if this is ever published; for local
// development with dev_overrides (~/.tofurc) this address just
// needs to match what's referenced there.
Address: "local/joachimfriberg/hpe1810",
})
// Best-effort: optionally save the running config (if save_running_config=true
// and something actually changed this run), then always free up the switch's
// one session slot so the next `tofu` invocation doesn't have to wait out the
// switch's own session timeout.
p.Shutdown()
if err != nil {
log.Fatal(err.Error())
}