diff --git a/AGENT.md b/AGENT.md index c7e6355..e083ccb 100644 --- a/AGENT.md +++ b/AGENT.md @@ -100,8 +100,68 @@ toolchain/`go.mod`, not nested in this Python project), scaffolded from HashiCor further write resources in the risk-triage order below (Safe category first). - **Status (2026-08-25)**: all 13 data sources are ported and verified with a live `tofu plan` against the real switch (`~/code/experiments/hpe/terraform/`) — confirmed - port 24 (the active uplink) reads `Link Up`, all other ports `Link Down`. Only - `resource_locator` (and further write resources) remain unbuilt. + port 24 (the active uplink) reads `Link Up`, all other ports `Link Down`. + `resource_locator` is fully built and its whole CRUD lifecycle verified live: `Create` + (enabled=true), `Update` (enabled=false), and `Delete` (`tofu destroy -target=...` -> + reset-to-Disable) all round-tripped correctly, each confirmed visually by the LED + blinking/stopping. First write resource is done end-to-end. + `resource_green_features` (Green Mode, Mode LED Time, Phy Auto Power-Down -- all three + submitted together per write, since the firmware form has no per-field submission) is + also built and its whole CRUD lifecycle verified live: `Create` matched the switch's + actual state, `Update` (mode_led_time 1->5) and `Delete` (reset to factory defaults: + Enable/10/Enable) both confirmed directly via curl against the switch (no LED to eyeball + for this one). + `resource_port` (`hpe1810_port`, `PortConfiguration.html`) is also built and its whole + CRUD lifecycle verified live, driven by `for_each` over a `var.ports` map keyed by port + number (see `~/code/experiments/hpe/terraform/variables.tf`). This page needed dedicated + handling beyond the generic parser -- see `client/port_config.go`'s doc comment for the + full writeup, short version: (1) port selection is a `submit_flag=1` reload POST with + `v_1_1_1=`, not a query param; (2) the page has three duplicate-labeled "Link + Speed" rows (`v_1_8_1`/`v_1_13_1`/`v_1_15_1`, one per SFP/PHY group) that read the same + but do NOT accept the same values on write -- confirmed live, submitting all three at + once was rejected; only `v_1_8_1` (the "No SFP"/RJ45-copper field, which covers every + port on this switch) is used, and any other Physical Type is explicitly rejected rather + than guessed at; (3) three hidden context fields (`v_1_21_1` UnitIndex, `v_1_31_1` + duplicate interface, `v_1_2_1` mstid) must be included on every write or the switch + rejects with `FILTER_MISSING` -- also confirmed live, hardcoded to their observed + constant values since this is a single, non-stacked unit. + New provider-level `admin_port` attribute (int, default **24**, env fallback + `HPE1810_ADMIN_PORT`) protects the switch's uplink: `resource_port`'s `Create`/ + `Update`/`Delete` all refuse to touch that interface, verified live -- a scratch resource + targeting interface 24 was correctly rejected with a diagnostic error, and port 24's live + state (`Enable`/`Auto`/`Link Up`) was confirmed unchanged afterward via curl. Full CRUD + (`Create`, `Update` speed 100->10 Mbps, `Delete` -> Disable+Auto) verified live on port 5 + only, then restored to its original state (`Enable`/`100 Mbps Full Duplex`). + `resource_system_description` (`hpe1810_system_description`, `SysDescription.html`) is + also built and verified live: only 3 of the page's 9 fields are actually writable (System + Name/Location/Contact, `v_1_2_1`/`v_1_3_1`/`v_1_4_1`) -- confirmed by the page's own + client-side validation script only defining error messages for those three field ids; the + rest (Description, Software Version, Object ID, Up Time, Current Time, Date) stay + read-only, exclusively on the data source. Note this resource's TypeName intentionally + collides with the data source's (`hpe1810_system_description`) -- fine, since Terraform + keeps `resource.*`/`data.*` in separate namespaces. `Create` applied live (System Name + `Draupnir01` -> `draupnir01`), confirmed via curl. Delete resets all three fields to `""`. + Next write resources per the risk triage: Jumbo Frames, then Ping Test. + Note: switch only used on port 24 for uplink — avoid touching that port in any future + port-configuration resource testing. +- **`save_running_config` provider flag (2026-08-25)**: new optional provider attribute + (`HPE1810_SAVE_RUNNING_CONFIG` env fallback, default `false`). If `true`, `Shutdown()` + (called once at process exit, same place as the logout below) POSTs to + `SaveAllChanges.html` ("Save Configuration" in the web UI) -- but ONLY if + `client.Dirty()` is also true, i.e. some `SubmitForm` call actually succeeded this run. + `Client.dirty` is an `atomic.Bool` set inside `SubmitForm` on any successful write. + Rationale (user's, 2026-08-25): config is already source-of-truth as IaC, saving isn't + critical, and unconditionally saving on every `tofu` invocation would wear the switch's + flash for no reason -- so it only fires on runs that actually changed something, and even + then only when explicitly opted into. Verified live: `SaveAllChanges.html` accepts the + same POST shape `SubmitForm` sends (`err_flag=0` back, confirmed via curl); an + apply with `save_running_config=true` that wrote a real change completed with no errors. +- **Session logout on shutdown**: found `GET /index.html?logout=1` invalidates the session + (undocumented, discovered by probing — no `/hp_logout.html` or similar exists). + `client.Logout()` calls it; `main.go` calls `p.Logout()` right after + `providerserver.Serve` returns (process shutdown), freeing the switch's one session slot + so the *next* `tofu` invocation doesn't have to wait out the ~5 min session timeout. + Verified live: two `tofu plan` runs back-to-back now both succeed. ### What this means for code written in this Python project meanwhile diff --git a/TODO.md b/TODO.md index 24f7501..6716f6d 100644 --- a/TODO.md +++ b/TODO.md @@ -20,39 +20,39 @@ Regenerate with `python discover.py`. Edit the "Want it?" column by hand; | Network Setup | http://192.168.2.10/tree1.html#NetworkSetup | y | | | Get Connected | http://192.168.2.10/Network.html | | yes | | SNTP | http://192.168.2.10/SNTP.html | y | yes | -| Time Zone | http://192.168.2.10/Time_Zone_Configuration.html | | | -| Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | | | +| Time Zone | http://192.168.2.10/Time_Zone_Configuration.html | y| | +| Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | y| | | Switching | http://192.168.2.10/tree1.html#Switching | | | -| Port Configuration | http://192.168.2.10/PortConfiguration.html | | | +| Port Configuration | http://192.168.2.10/PortConfiguration.html | y| | | Jumbo Frames | http://192.168.2.10/JumboFrames.html | | | -| Port Mirroring | http://192.168.2.10/FDBConfig.html | | | -| Flow Control | http://192.168.2.10/SwitchConfig.html | | | -| Green Features | http://192.168.2.10/greenmode.html | | | -| Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | | | +| Port Mirroring | http://192.168.2.10/FDBConfig.html | y| | +| Flow Control | http://192.168.2.10/SwitchConfig.html | y| | +| Green Features | http://192.168.2.10/greenmode.html | y| | +| Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | y| | | Security | http://192.168.2.10/tree1.html#Security | | | -| Advanced Security | http://192.168.2.10/security.html | | | -| Secure Connection | http://192.168.2.10/SSLCfg.html | | | +| Advanced Security | http://192.168.2.10/security.html | y| | +| Secure Connection | http://192.168.2.10/SSLCfg.html | y| | | Trunks | http://192.168.2.10/tree1.html#Trunks | | | -| Trunk Configuration | http://192.168.2.10/TrunkConfig.html | | | -| Trunk Membership | http://192.168.2.10/TrunkMembership.html | | | +| Trunk Configuration | http://192.168.2.10/TrunkConfig.html | y| | +| Trunk Membership | http://192.168.2.10/TrunkMembership.html | y| | | VLANs | http://192.168.2.10/tree1.html#VLANs | | | | VLAN Configuration | http://192.168.2.10/VlanCreateConfig.html | | | | VLAN Ports | http://192.168.2.10/VLANPortConfig.html | | | | Participation / Tagging | http://192.168.2.10/VLANPortParticipation.html | | | | LLDP | http://192.168.2.10/tree1.html#LLDP | | | -| LLDP Configuration | http://192.168.2.10/LLDPConfig.html | | | -| Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | | | -| Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | | | +| LLDP Configuration | http://192.168.2.10/LLDPConfig.html | y| | +| Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | y| | +| Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | y| | | Diagnostics | http://192.168.2.10/tree1.html#Diagnostics | | | | Ping Test | http://192.168.2.10/Ping.html | | | | Log Configuration | http://192.168.2.10/Logging.html | y | yes | | Reboot Switch | http://192.168.2.10/SystemReset.html | | | | Factory Defaults | http://192.168.2.10/ResetConfigToDefaults.html | | | | Support File | http://192.168.2.10/textBasedConfig.html | | | -| Locator | http://192.168.2.10/locator.html | | | +| Locator | http://192.168.2.10/locator.html | y| | | Maintenance | http://192.168.2.10/tree1.html#Maintenance | | | | Backup Manager | http://192.168.2.10/UploadFile.html | y | yes | | Update Manager | http://192.168.2.10/http_file_download.html | | | | Password Manager | http://192.168.2.10/UserAccounts.html | | | -| Save Configuration | http://192.168.2.10/SaveAllChanges.html | | | +| Save Configuration | http://192.168.2.10/SaveAllChanges.html | y| | | Dual Image Configuration | http://192.168.2.10/dual_image_cfg.html | | | diff --git a/terraform-provider-hpe1810/internal/provider/client/client.go b/terraform-provider-hpe1810/internal/provider/client/client.go index 6a1c032..0647dcf 100644 --- a/terraform-provider-hpe1810/internal/provider/client/client.go +++ b/terraform-provider-hpe1810/internal/provider/client/client.go @@ -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 +} diff --git a/terraform-provider-hpe1810/internal/provider/client/port_config.go b/terraform-provider-hpe1810/internal/provider/client/port_config.go new file mode 100644 index 0000000..9888b25 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/port_config.go @@ -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= -- 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 , 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 +} diff --git a/terraform-provider-hpe1810/internal/provider/provider.go b/terraform-provider-hpe1810/internal/provider/provider.go index 6eb86d8..c7d46a3 100644 --- a/terraform-provider-hpe1810/internal/provider/provider.go +++ b/terraform-provider-hpe1810/internal/provider/provider.go @@ -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, + } } diff --git a/terraform-provider-hpe1810/internal/provider/resource_green_features.go b/terraform-provider-hpe1810/internal/provider/resource_green_features.go new file mode 100644 index 0000000..5d49055 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_green_features.go @@ -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
: "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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_locator.go b/terraform-provider-hpe1810/internal/provider/resource_locator.go new file mode 100644 index 0000000..441e2de --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_locator.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_port.go b/terraform-provider-hpe1810/internal/provider/resource_port.go new file mode 100644 index 0000000..7ad0473 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_port.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_system_description.go b/terraform-provider-hpe1810/internal/provider/resource_system_description.go new file mode 100644 index 0000000..16473e9 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_system_description.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/main.go b/terraform-provider-hpe1810/main.go index 769f294..772a900 100644 --- a/terraform-provider-hpe1810/main.go +++ b/terraform-provider-hpe1810/main.go @@ -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()) } diff --git a/terraform/main.tf b/terraform/main.tf index 65259e0..3ed668c 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,17 +1,54 @@ # All 13 read-only extractors are now ported as data sources -- see # ~/code/experiments/hpe/terraform-provider-hpe1810/internal/provider/data_source_*.go -# Eventually resource "hpe1810_locator" once actions/locator.py is ported. +# +# First write resource: the locator LED. Singleton; Delete resets it to +# Disable (see resource_locator.go for why that's safe here specifically). +resource "hpe1810_locator" "draupnir01" { + enabled = false +} -data "hpe1810_system_description" "this" {} -data "hpe1810_network_setup" "this" {} -data "hpe1810_port_summary" "this" {} -data "hpe1810_lldp_statistics" "this" {} -data "hpe1810_buffered_log" "this" {} -data "hpe1810_mac_table" "this" {} -data "hpe1810_trunk_status" "this" {} -data "hpe1810_loop_protection_status" "this" {} -data "hpe1810_dual_image_status" "this" {} -data "hpe1810_clock" "this" {} -data "hpe1810_sntp" "this" {} -data "hpe1810_log_configuration" "this" {} -data "hpe1810_backup_manager" "this" {} +# Second write resource: Green Features (EEE power-saving). Singleton; +# Delete resets to factory defaults (see resource_green_features.go). +# Values below match what's currently live on the switch, so the first +# `tofu plan` shows no drift. +resource "hpe1810_green_features" "draupnir01" { + green_mode = true + mode_led_time = 10 + phy_auto_power_down = true +} + +# Third write resource: per-port config, driven by for_each over var.ports +# (see variables.tf). The admin_port (default 24, see provider.tf) can +# never be written by draupnir01 resource regardless of what's in var.ports -- +# see resource_port.go's guardAdminPort. Delete disables the port and +# resets Link Speed to Auto (see resource_port.go for why that's safe here). +resource "hpe1810_port" "draupnir01" { + for_each = var.ports + + interface = each.key + admin_mode = each.value.admin_mode + link_speed = each.value.link_speed +} + +# Fourth write resource: System Name/Location/Contact (SysDescription.html). +# Singleton; only these three fields are actually writable on that page -- +# see resource_system_description.go. Delete resets all three to "". +resource "hpe1810_system_description" "draupnir01" { + name = "draupnir01" + location = "Lab" + contact = "" +} + +data "hpe1810_system_description" "draupnir01" {} +data "hpe1810_network_setup" "draupnir01" {} +data "hpe1810_port_summary" "draupnir01" {} +data "hpe1810_lldp_statistics" "draupnir01" {} +data "hpe1810_buffered_log" "draupnir01" {} +data "hpe1810_mac_table" "draupnir01" {} +data "hpe1810_trunk_status" "draupnir01" {} +data "hpe1810_loop_protection_status" "draupnir01" {} +data "hpe1810_dual_image_status" "draupnir01" {} +data "hpe1810_clock" "draupnir01" {} +data "hpe1810_sntp" "draupnir01" {} +data "hpe1810_log_configuration" "draupnir01" {} +data "hpe1810_backup_manager" "draupnir01" {} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 98fef91..f950d6a 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -1,21 +1,33 @@ output "system_description" { description = "Everything read back from the switch's System Description page." - value = data.hpe1810_system_description.this + value = data.hpe1810_system_description.draupnir01 } output "system_name" { - value = data.hpe1810_system_description.this.name + value = data.hpe1810_system_description.draupnir01.name } output "port_summary" { - value = data.hpe1810_port_summary.this.ports + value = data.hpe1810_port_summary.draupnir01.ports } output "port_24" { description = "The uplink port -- verifying it's readable without touching it." - value = [for p in data.hpe1810_port_summary.this.ports : p if p.Interface == "24"] + value = [for p in data.hpe1810_port_summary.draupnir01.ports : p if p.Interface == "24"] } output "mac_table" { - value = data.hpe1810_mac_table.this + value = data.hpe1810_mac_table.draupnir01 +} + +output "managed_port_status" { + description = "physical_type/link_status per port managed via hpe1810_port." + value = { + for k, p in hpe1810_port.draupnir01 : k => { + admin_mode = p.admin_mode + link_speed = p.link_speed + physical_type = p.physical_type + link_status = p.link_status + } + } } diff --git a/terraform/provider.tf b/terraform/provider.tf index 36e263f..319f96d 100644 --- a/terraform/provider.tf +++ b/terraform/provider.tf @@ -1,10 +1,11 @@ -# All three fields are optional. If omitted, each falls back to an env var, -# then to a default -- same pattern as config.py in the Python reference -# project (SWITCH_HOST / SWITCH_HTTPS / SWITCH_PASSWORD): +# All fields are optional. If omitted, each falls back to an env var, then +# to a default -- same pattern as config.py in the Python reference project +# (SWITCH_HOST / SWITCH_HTTPS / SWITCH_PASSWORD): # -# host <- HPE1810_HOST <- "192.168.2.10" -# https <- HPE1810_HTTPS <- false -# password <- HPE1810_PASSWORD <- "" +# host <- HPE1810_HOST <- "192.168.2.10" +# https <- HPE1810_HTTPS <- false +# password <- HPE1810_PASSWORD <- "" +# save_running_config <- HPE1810_SAVE_RUNNING_CONFIG <- false # # So for the switch as currently configured (no password, plain HTTP, at its # default IP), an empty provider block is enough: @@ -17,4 +18,18 @@ provider "hpe1810" { host = "192.168.2.10" https = false password = "" # switch currently has no password set + + # If true, "Save Configuration" runs once at the end of this `tofu` + # invocation, but only if it actually wrote something -- a plan-only run, + # or an apply where nothing changed, never triggers a save. Left false by + # default: the config is already source-of-truth as IaC, and saving on + # every write wears the switch's flash for no real benefit. Flip to true + # once you actually want changes to survive a reboot/power cycle. + save_running_config = false + + # hpe1810_port refuses to write to this interface under any circumstances + # -- protects the switch's uplink/management port. Matches the default, + # stated explicitly here for clarity. NEVER put this port number in + # var.ports (see variables.tf). + admin_port = 24 } diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 0000000..2682d3d --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,10 @@ +# Matches what's currently live on the switch, so `tofu plan` shows no +# drift -- port 5 has a manually-fixed 100 Mbps Full Duplex speed +# (AutoNeg Status=Disable in port_summary), ports 1-4 are Auto. +ports = { + "1" = { admin_mode = true, link_speed = "Auto" } + "2" = { admin_mode = true, link_speed = "Auto" } + "3" = { admin_mode = true, link_speed = "Auto" } + "4" = { admin_mode = true, link_speed = "Auto" } + "5" = { admin_mode = true, link_speed = "100 Mbps Full Duplex" } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..f550555 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,12 @@ +# Port number (as a string, e.g. "5") -> desired config. NEVER include the +# admin_port here (default 24, see provider.tf) -- hpe1810_port refuses to +# write to it anyway (see resource_port.go's guardAdminPort), but it's +# clearer to just never ask for it. +variable "ports" { + description = "Port number -> desired Admin Mode / Link Speed." + type = map(object({ + admin_mode = bool + link_speed = string + })) + # No default -- set actual values in terraform.tfvars (or -var-file/-var). +}