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