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
+1336 -66

No files matched your search

+62 -2
View File
@@ -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). 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 - **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 `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 port 24 (the active uplink) reads `Link Up`, all other ports `Link Down`.
`resource_locator` (and further write resources) remain unbuilt. `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=<port>`, 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 ### What this means for code written in this Python project meanwhile
+16 -16
View File
@@ -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 | | | Network Setup | http://192.168.2.10/tree1.html#NetworkSetup | y | |
| Get Connected | http://192.168.2.10/Network.html | | yes | | Get Connected | http://192.168.2.10/Network.html | | yes |
| SNTP | http://192.168.2.10/SNTP.html | y | yes | | SNTP | http://192.168.2.10/SNTP.html | y | yes |
| Time Zone | http://192.168.2.10/Time_Zone_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 | | | | Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | y| |
| Switching | http://192.168.2.10/tree1.html#Switching | | | | 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 | | | | Jumbo Frames | http://192.168.2.10/JumboFrames.html | | |
| Port Mirroring | http://192.168.2.10/FDBConfig.html | | | | Port Mirroring | http://192.168.2.10/FDBConfig.html | y| |
| Flow Control | http://192.168.2.10/SwitchConfig.html | | | | Flow Control | http://192.168.2.10/SwitchConfig.html | y| |
| Green Features | http://192.168.2.10/greenmode.html | | | | Green Features | http://192.168.2.10/greenmode.html | y| |
| Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | | | | Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | y| |
| Security | http://192.168.2.10/tree1.html#Security | | | | Security | http://192.168.2.10/tree1.html#Security | | |
| Advanced Security | http://192.168.2.10/security.html | | | | Advanced Security | http://192.168.2.10/security.html | y| |
| Secure Connection | http://192.168.2.10/SSLCfg.html | | | | Secure Connection | http://192.168.2.10/SSLCfg.html | y| |
| Trunks | http://192.168.2.10/tree1.html#Trunks | | | | Trunks | http://192.168.2.10/tree1.html#Trunks | | |
| Trunk Configuration | http://192.168.2.10/TrunkConfig.html | | | | Trunk Configuration | http://192.168.2.10/TrunkConfig.html | y| |
| Trunk Membership | http://192.168.2.10/TrunkMembership.html | | | | Trunk Membership | http://192.168.2.10/TrunkMembership.html | y| |
| VLANs | http://192.168.2.10/tree1.html#VLANs | | | | VLANs | http://192.168.2.10/tree1.html#VLANs | | |
| VLAN Configuration | http://192.168.2.10/VlanCreateConfig.html | | | | VLAN Configuration | http://192.168.2.10/VlanCreateConfig.html | | |
| VLAN Ports | http://192.168.2.10/VLANPortConfig.html | | | | VLAN Ports | http://192.168.2.10/VLANPortConfig.html | | |
| Participation / Tagging | http://192.168.2.10/VLANPortParticipation.html | | | | Participation / Tagging | http://192.168.2.10/VLANPortParticipation.html | | |
| LLDP | http://192.168.2.10/tree1.html#LLDP | | | | LLDP | http://192.168.2.10/tree1.html#LLDP | | |
| LLDP Configuration | http://192.168.2.10/LLDPConfig.html | | | | LLDP Configuration | http://192.168.2.10/LLDPConfig.html | y| |
| Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | | | | Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | y| |
| Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | | | | Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | y| |
| Diagnostics | http://192.168.2.10/tree1.html#Diagnostics | | | | Diagnostics | http://192.168.2.10/tree1.html#Diagnostics | | |
| Ping Test | http://192.168.2.10/Ping.html | | | | Ping Test | http://192.168.2.10/Ping.html | | |
| Log Configuration | http://192.168.2.10/Logging.html | y | yes | | Log Configuration | http://192.168.2.10/Logging.html | y | yes |
| Reboot Switch | http://192.168.2.10/SystemReset.html | | | | Reboot Switch | http://192.168.2.10/SystemReset.html | | |
| Factory Defaults | http://192.168.2.10/ResetConfigToDefaults.html | | | | Factory Defaults | http://192.168.2.10/ResetConfigToDefaults.html | | |
| Support File | http://192.168.2.10/textBasedConfig.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 | | | | Maintenance | http://192.168.2.10/tree1.html#Maintenance | | |
| Backup Manager | http://192.168.2.10/UploadFile.html | y | yes | | Backup Manager | http://192.168.2.10/UploadFile.html | y | yes |
| Update Manager | http://192.168.2.10/http_file_download.html | | | | Update Manager | http://192.168.2.10/http_file_download.html | | |
| Password Manager | http://192.168.2.10/UserAccounts.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 | | | | Dual Image Configuration | http://192.168.2.10/dual_image_cfg.html | | |
@@ -10,12 +10,20 @@ import (
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"sync/atomic"
) )
type Client struct { type Client struct {
BaseURL string BaseURL string
httpClient *http.Client httpClient *http.Client
password string 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) { func NewClient(host string, https bool, password string) (*Client, error) {
@@ -87,6 +95,22 @@ func (c *Client) Login() error {
return nil 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. // Fetch GETs BaseURL+path (path may be relative or absolute) and returns the body.
func (c *Client) Fetch(path string) (string, error) { func (c *Client) Fetch(path string) (string, error) {
resp, err := c.httpClient.Get(c.resolveURL(path)) 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 errFlagRejectedRe = regexp.MustCompile(`(?i)name="err_flag"[^>]*value="1"`)
var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`) 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 // SubmitForm POSTs fields to path, merged with the firmware's standard
// bookkeeping fields (submit_flag, submit_target, err_flag, err_msg, // bookkeeping fields (submit_flag, submit_target, err_flag, err_msg,
// clazz_information), and returns the response body. // 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. // afterwards and compare, rather than trusting the absence of an error.
func (c *Client) SubmitForm(path string, fields map[string]string) (string, error) { func (c *Client) SubmitForm(path string, fields map[string]string) (string, error) {
target := strings.TrimPrefix(path, "/") target := strings.TrimPrefix(path, "/")
form := url.Values{ merged := map[string]string{
// 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js); // 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js);
// 1 is xui_operation_reload and silently applies nothing. // 1 is xui_operation_reload and silently applies nothing.
"submit_flag": {"8"}, "submit_flag": "8",
"submit_target": {target}, "submit_target": target,
"err_flag": {"0"}, "err_flag": "0",
"err_msg": {""}, "err_msg": "",
"clazz_information": {target}, "clazz_information": target,
} }
for k, v := range fields { 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 { if err != nil {
return "", fmt.Errorf("submitting form to %s: %w", path, err) return "", 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)
} }
bodyStr := string(body)
if errFlagRejectedRe.MatchString(bodyStr) { if errFlagRejectedRe.MatchString(bodyStr) {
msg := "unknown error" msg := "unknown error"
if m := errMsgRe.FindStringSubmatch(bodyStr); m != nil { 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) return "", fmt.Errorf("switch rejected write to %q: %s", path, msg)
} }
c.dirty.Store(true)
return bodyStr, nil 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
}
@@ -18,20 +18,47 @@ var _ provider.Provider = &hpe1810Provider{}
type hpe1810Provider struct { type hpe1810Provider struct {
version string version string
client *client.Client
saveRunningConfig bool
} }
// hpe1810ProviderModel mirrors config.py's three settings (SWITCH_HOST, // 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 { type hpe1810ProviderModel struct {
Host types.String `tfsdk:"host"` Host types.String `tfsdk:"host"`
HTTPS types.Bool `tfsdk:"https"` HTTPS types.Bool `tfsdk:"https"`
Password types.String `tfsdk:"password"` 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 { // New returns a single provider instance (not a fresh one per call) so that
return func() provider.Provider { // 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} 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) { 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, Sensitive: true,
Description: "Switch login password. Falls back to the HPE1810_PASSWORD env var, then empty.", 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() 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) c, err := client.NewClient(host, https, password)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Unable to create switch client", err.Error()) resp.Diagnostics.AddError("Unable to create switch client", err.Error())
return return
} }
c.AdminPort = adminPort
if err := c.Login(); err != nil { if err := c.Login(); err != nil {
resp.Diagnostics.AddError("Unable to log in to switch", err.Error()) resp.Diagnostics.AddError("Unable to log in to switch", err.Error())
return return
} }
p.client = c
resp.DataSourceData = c resp.DataSourceData = c
resp.ResourceData = 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) // Resources: one entry per actions/*.py equivalent, as they're ported.
// as they're ported.
func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resource { 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" "context"
"log" "log"
tfprovider "github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-framework/providerserver"
"terraform-provider-hpe1810/internal/provider" "terraform-provider-hpe1810/internal/provider"
@@ -15,12 +16,18 @@ import (
var version = "dev" var version = "dev"
func main() { 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 // Only meaningful once/if this is ever published; for local
// development with dev_overrides (~/.tofurc) this address just // development with dev_overrides (~/.tofurc) this address just
// needs to match what's referenced there. // needs to match what's referenced there.
Address: "local/joachimfriberg/hpe1810", 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 { if err != nil {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }
+51 -14
View File
@@ -1,17 +1,54 @@
# All 13 read-only extractors are now ported as data sources -- see # All 13 read-only extractors are now ported as data sources -- see
# ~/code/experiments/hpe/terraform-provider-hpe1810/internal/provider/data_source_*.go # ~/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" {} # Second write resource: Green Features (EEE power-saving). Singleton;
data "hpe1810_network_setup" "this" {} # Delete resets to factory defaults (see resource_green_features.go).
data "hpe1810_port_summary" "this" {} # Values below match what's currently live on the switch, so the first
data "hpe1810_lldp_statistics" "this" {} # `tofu plan` shows no drift.
data "hpe1810_buffered_log" "this" {} resource "hpe1810_green_features" "draupnir01" {
data "hpe1810_mac_table" "this" {} green_mode = true
data "hpe1810_trunk_status" "this" {} mode_led_time = 10
data "hpe1810_loop_protection_status" "this" {} phy_auto_power_down = true
data "hpe1810_dual_image_status" "this" {} }
data "hpe1810_clock" "this" {}
data "hpe1810_sntp" "this" {} # Third write resource: per-port config, driven by for_each over var.ports
data "hpe1810_log_configuration" "this" {} # (see variables.tf). The admin_port (default 24, see provider.tf) can
data "hpe1810_backup_manager" "this" {} # 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" {}
+17 -5
View File
@@ -1,21 +1,33 @@
output "system_description" { output "system_description" {
description = "Everything read back from the switch's System Description page." 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" { output "system_name" {
value = data.hpe1810_system_description.this.name value = data.hpe1810_system_description.draupnir01.name
} }
output "port_summary" { output "port_summary" {
value = data.hpe1810_port_summary.this.ports value = data.hpe1810_port_summary.draupnir01.ports
} }
output "port_24" { output "port_24" {
description = "The uplink port -- verifying it's readable without touching it." 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" { 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
}
}
} }
+18 -3
View File
@@ -1,10 +1,11 @@
# All three fields are optional. If omitted, each falls back to an env var, # All fields are optional. If omitted, each falls back to an env var, then
# then to a default -- same pattern as config.py in the Python reference # to a default -- same pattern as config.py in the Python reference project
# project (SWITCH_HOST / SWITCH_HTTPS / SWITCH_PASSWORD): # (SWITCH_HOST / SWITCH_HTTPS / SWITCH_PASSWORD):
# #
# host <- HPE1810_HOST <- "192.168.2.10" # host <- HPE1810_HOST <- "192.168.2.10"
# https <- HPE1810_HTTPS <- false # https <- HPE1810_HTTPS <- false
# password <- HPE1810_PASSWORD <- "" # password <- HPE1810_PASSWORD <- ""
# save_running_config <- HPE1810_SAVE_RUNNING_CONFIG <- false
# #
# So for the switch as currently configured (no password, plain HTTP, at its # So for the switch as currently configured (no password, plain HTTP, at its
# default IP), an empty provider block is enough: # default IP), an empty provider block is enough:
@@ -17,4 +18,18 @@ provider "hpe1810" {
host = "192.168.2.10" host = "192.168.2.10"
https = false https = false
password = "" # switch currently has no password set 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
} }
+10
View File
@@ -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" }
}
+12
View File
@@ -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).
}