Now works - more or less fully

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

No files matched your search

+70 -1
View File
@@ -141,9 +141,78 @@ toolchain/`go.mod`, not nested in this Python project), scaffolded from HashiCor
collides with the data source's (`hpe1810_system_description`) -- fine, since Terraform collides with the data source's (`hpe1810_system_description`) -- fine, since Terraform
keeps `resource.*`/`data.*` in separate namespaces. `Create` applied live (System Name keeps `resource.*`/`data.*` in separate namespaces. `Create` applied live (System Name
`Draupnir01` -> `draupnir01`), confirmed via curl. Delete resets all three fields to `""`. `Draupnir01` -> `draupnir01`), confirmed via curl. Delete resets all three fields to `""`.
Next write resources per the risk triage: Jumbo Frames, then Ping Test. Correction (2026-08-25): "Jumbo Frames, then Ping Test" as the suggested next targets
was wrong -- neither is marked "y" in TODO.md, so neither is actually opted into. Check
TODO.md's "Want it?" column before picking the next write target, not just the risk
triage below (the triage says what's *safe*, TODO.md says what's *wanted*). Marked "y"
but not yet implemented, as of 2026-08-25: Time Zone, Daylight Saving Time, Port
Mirroring, Flow Control, Loop Protection Cfg (LoopProtectionCfg.html -- distinct from the
already-done read-only Loop Protection Status), Advanced Security, Secure Connection,
Trunk Configuration, Trunk Membership, LLDP Configuration, LLDP Local/Remote Device.
Note: switch only used on port 24 for uplink — avoid touching that port in any future Note: switch only used on port 24 for uplink — avoid touching that port in any future
port-configuration resource testing. port-configuration resource testing.
**Update**: Time Zone, Daylight Saving Time, LLDP Configuration, Flow Control, and Port
Mirroring were built next per explicit user request (2026-08-25) -- see below. Remaining
marked-"y"-but-not-built: Loop Protection Cfg, Advanced Security, Secure Connection,
Trunk Configuration, Trunk Membership, LLDP Local/Remote Device.
- Three more write resources (2026-08-25), user-requested: `hpe1810_time_zone`
(`Time_Zone_Configuration.html`, simple 2-field scalar page, no collisions),
`hpe1810_lldp_configuration` (`LLDPConfig.html`'s "Global Mode" table only -- the page's
per-port "Interface Mode" table, with per-port field names like `1.0.24.v_2_1_2`, is a
separate not-yet-built write target), and `hpe1810_daylight_saving_time`
(`Summer_Time_Configuration.html`). DST was the trickiest: the page has THREE modes
(Disable/Recurring/NonRecurring) each with a full field set all present in the DOM at
once, several labels colliding WITHIN one table (Month/Hours/Minutes/Offset each appear
in both the NonRecurring and Recurring blocks) -- handled with a dedicated regex-based
field reader/writer (`client/dst_config.go`) rather than the generic parser, bypassing
the ambiguity entirely. Confirmed live that (unlike PortConfiguration.html) this page
does NOT need hidden context fields or the unused mode's fields sent -- submitting just
`v_1_1_1` + the Recurring block was accepted with no error. Only Disable/Recurring are
supported; NonRecurring (absolute Month/Date/Year fields) is explicitly rejected. All
three verified with real writes confirmed via curl (Time Zone acronym, LLDP
transmit_interval, DST start_hour), then reverted to the switch's actual original values
-- `tofu plan` shows zero drift.
- Two more write resources (2026-08-25), user-requested, in this order: Flow Control
first (`hpe1810_flow_control`, `SwitchConfig.html`, trivial single-boolean page, same
shape as Locator), then Port Mirroring evaluated before building -- `FDBConfig.html` is
misleadingly named (it's Port Mirroring, not FDB) and has two parts: a global
Enable+Destination Port scalar table, and a per-source-port Direction table (25 rows:
ports 1-24 + CPU, per-port field names like `1.0.25.v_1_3_2`) structurally identical in
complexity to LLDPConfig.html's deferred per-port table. User confirmed building only the
global part as `hpe1810_port_mirroring`, same scope-split precedent as LLDP
Configuration. Hit the same `FILTER_MISSING` issue as PortConfiguration.html: a hidden
context field (`v_2_2_2`, always `"1"`, sits next to Destination Port with no visible
label) must be included on every write or the switch rejects with "Error! Failed to Set
'Enable Mirroring' ... FILTER_MISSING" -- confirmed live, fixed, then both resources
verified with zero drift on `tofu plan`.
- **`hpe1810_trunk` (link aggregation / LAG), user-requested (2026-08-25)**: the first
resource with a genuinely different shape from everything above -- a named sub-entity
with real Create/Delete (`TrunkConfig.html`), not a singleton or a fixed for_each set.
Membership assignment (`TrunkMembership.html`) uses a dedicated JS file
(`lagViewAutoGen.js`) to dynamically render the picker; the raw HTML table is just a
hidden data template the JS reads, not the actual submitted form -- had to read that JS
to find the real field-naming convention (`1.<zero-based-port-index>.24.v_2_3_1/2/3`).
Three write-protocol gotchas confirmed live before writing any Go code (test trunk on
ports 5+6 only, never 24, cleaned up after): (1) `v_2_3_1` (port number) is marked
`DISABLED` in the raw HTML, but the page's own JS un-disables it right before submit --
a raw POST omitting it fails with `FILTER_MISSING`, same for the page's other hidden
context fields; (2) Delete requires resubmitting that trunk's row fields
(`v_1_5_1`..`v_1_5_6`) unchanged alongside the delete trigger
(`v_1_5_7=Enable`+`v_1_5_8=Delete`) or it fails with "Error! Failed to remove LAG.";
(3) **including the page's top-level Create-section fields
(`v_1_1_1`/`v_1_2_1`/`v_1_4_1`) in the SAME request as a delete causes the switch to
create ANOTHER trunk as a side effect instead of deleting** -- caught live (deleting
"Trunk1" while resending `v_1_4_1=Add` produced a fresh empty "Trunk2"); fixed by
omitting those fields entirely from delete requests. `admin_mode`/`static_capability`
are exposed read-only only (Computed, not settable) -- only Create/set-members/Delete
were verified live. `hpe1810_trunk`'s `members` reuses the `admin_port` guard pattern
from `resource_port.go`. Full CRUD verified live **through the actual Terraform
provider** (not just curl): Create, Update (added a member), the admin_port guard
(blocked live, port 24 confirmed untouched via curl afterward), and Delete -- all with
zero drift on `tofu plan`. Multi-trunk row-indexing (`1.<index>.1.` prefix on
TrunkConfig.html) is inferred by the same positional pattern used everywhere else in
this firmware, not independently verified against a live switch with 2+ trunks (this
homelab only ever had one during testing).
- **`save_running_config` provider flag (2026-08-25)**: new optional provider attribute - **`save_running_config` provider flag (2026-08-25)**: new optional provider attribute
(`HPE1810_SAVE_RUNNING_CONFIG` env fallback, default `false`). If `true`, `Shutdown()` (`HPE1810_SAVE_RUNNING_CONFIG` env fallback, default `false`). If `true`, `Shutdown()`
(called once at process exit, same place as the logout below) POSTs to (called once at process exit, same place as the logout below) POSTs to
+85
View File
@@ -0,0 +1,85 @@
# Quick pickup (last updated 2026-08-25)
Read this first. `AGENT.md` has the full history/design rationale if you need depth on
any specific decision below.
## What this project is
Two things, in one working tree:
1. `~/code/experiments/hpe/` (this dir) -- a Python reference implementation
(`extractors/`, `actions/`) for scraping/writing an HP ProCurve/OfficeConnect 1810
(J9450A) switch's web UI at `192.168.2.10`, no password.
2. `~/code/experiments/hpe/terraform-provider-hpe1810/` -- the **real deliverable**: a
Go/OpenTofu Terraform Plugin Framework provider that does the same thing properly.
Example usage lives in `~/code/experiments/hpe/terraform/`.
The Python side is essentially done and frozen as a reference; almost all active work now
is in the Go provider.
## Current state (2026-08-25)
Provider builds clean. `tofu plan` in `~/code/experiments/hpe/terraform/` shows **zero
drift** -- state matches the real switch exactly.
**13 data sources** (all read-only pages) -- done.
**10 write resources** -- all built, all verified live on real hardware:
`hpe1810_locator`, `hpe1810_green_features`, `hpe1810_port` (for_each over `var.ports`),
`hpe1810_system_description`, `hpe1810_time_zone`, `hpe1810_lldp_configuration` (Global
Mode only), `hpe1810_daylight_saving_time` (Disable/Recurring only), `hpe1810_flow_control`,
`hpe1810_port_mirroring` (global settings only), `hpe1810_trunk` (create/delete LAGs +
membership).
Plus: `save_running_config` provider flag (only saves to flash if something actually
changed this run) and `admin_port` provider flag (default **24** -- `hpe1810_port` and
`hpe1810_trunk` both hard-refuse to write to it, guard verified live).
## Still on TODO.md as wanted ("y") but not built
Loop Protection Cfg (`LoopProtectionCfg.html`), Advanced Security (`security.html`),
Secure Connection (`SSLCfg.html`), LLDP Local/Remote Device (read-only summary pages).
Check `TODO.md`'s "Want it?" column before picking a next target -- don't assume the risk
triage in `AGENT.md` is the same as what's actually wanted (learned this the hard way once
already this session).
Also explicitly deferred (not on the TODO list, just noted as future work if ever wanted):
per-port tables on `LLDPConfig.html` (Interface Mode) and `FDBConfig.html` (per-source-port
Direction) -- same complexity class as `resource_port.go`/`resource_trunk.go`.
## Things to remember before touching the switch again
- **Proxy**: always `NO_PROXY=192.168.2.10 no_proxy=192.168.2.10` before any curl/tofu
command, or requests silently go through `fproxy.tutus.se:8080` and hang/502.
- **Single session limit**: the switch allows exactly one active web session. A stray
browser tab or leftover curl cookie jar session will lock out the next login. The Go
provider logs itself out at process exit (`Client.Logout()` via `Shutdown()` in
`main.go`) so back-to-back `tofu` runs are fine now -- but manual curl testing sessions
(like `/tmp/*.txt` cookie jars used during dev) need an explicit
`curl ".../index.html?logout=1"` when you're done, or wait out the ~5 min timeout.
- **Rebuild after any Go change**: `cd terraform-provider-hpe1810 && go build -o
terraform-provider-hpe1810 .` -- `~/.tofurc` dev_overrides picks it up automatically,
no `tofu init` needed/possible.
- **Port 24 is the only real uplink right now.** Never write to it directly; the
`admin_port` guard covers `hpe1810_port`/`hpe1810_trunk`, but there's no such guard on
any other resource (nor should there need to be, since nothing else targets a specific
interface).
- This firmware has repeatedly needed **live experimentation** to find the real write
protocol -- static HTML snapshots in `page_snapshots/` are useful for structure but have
been wrong or incomplete more than once (`FILTER_MISSING` on omitted hidden fields;
`TrunkMembership.html`'s real form is JS-rendered, not in the static HTML at all; DST's
duplicate field labels). Budget time for a probe-with-curl step before writing Go for any
new write target.
## How to pick up
1. Ask the user (or check `TODO.md`) what's next.
2. If it's a new write page: fetch it live with curl (logged in via the pattern used
throughout this session -- GET `/` then POST `/hp_login.html` with empty `pwd`), check
`page_snapshots/<Page>.html.html` for structure, but verify writes live with curl before
writing any Go.
3. Follow the established resource shape: `client/<name>.go` (or a new method on
`client.Client`) for the HTTP protocol, `resource_<name>.go` for the Terraform side,
register in `provider.go`'s `Resources()`, add example usage to
`~/code/experiments/hpe/terraform/main.tf`, verify live (Create/Update/Delete round-trip
confirmed against the real switch, not just "no error"), update `TODO.md` and
`AGENT.md`.
+11 -11
View File
@@ -20,27 +20,27 @@ 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 | y| | | Time Zone | http://192.168.2.10/Time_Zone_Configuration.html | y| yes |
| Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | y| | | Daylight Saving Time | http://192.168.2.10/Summer_Time_Configuration.html | y| yes (Disable/Recurring only, not NonRecurring) |
| 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 | y| | | Port Configuration | http://192.168.2.10/PortConfiguration.html | y| yes (hpe1810_port, No SFP/RJ45 ports only) |
| 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 | y| | | Port Mirroring | http://192.168.2.10/FDBConfig.html | y| yes (global settings only, not per-source-port Direction) |
| Flow Control | http://192.168.2.10/SwitchConfig.html | y| | | Flow Control | http://192.168.2.10/SwitchConfig.html | y| yes |
| Green Features | http://192.168.2.10/greenmode.html | y| | | Green Features | http://192.168.2.10/greenmode.html | y| yes |
| Loop Protection | http://192.168.2.10/LoopProtectionCfg.html | y| | | 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 | y| | | Advanced Security | http://192.168.2.10/security.html | y| |
| Secure Connection | http://192.168.2.10/SSLCfg.html | y| | | 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 | y| | | Trunk Configuration | http://192.168.2.10/TrunkConfig.html | y| yes (hpe1810_trunk) |
| Trunk Membership | http://192.168.2.10/TrunkMembership.html | y| | | Trunk Membership | http://192.168.2.10/TrunkMembership.html | y| yes (hpe1810_trunk) |
| 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 | y| | | LLDP Configuration | http://192.168.2.10/LLDPConfig.html | y| yes (Global Mode only, not per-port Interface Mode) |
| Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | y| | | Local Device | http://192.168.2.10/LLDPLocalDeviceSumm.html | y| |
| Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | y| | | 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 | | |
@@ -49,10 +49,10 @@ Regenerate with `python discover.py`. Edit the "Want it?" column by hand;
| 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 | y| | | Locator | http://192.168.2.10/locator.html | y| yes |
| 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 | y| | | Save Configuration | http://192.168.2.10/SaveAllChanges.html | y| yes (save_running_config provider flag) |
| Dual Image Configuration | http://192.168.2.10/dual_image_cfg.html | | | | Dual Image Configuration | http://192.168.2.10/dual_image_cfg.html | | |
@@ -0,0 +1,123 @@
// Summer_Time_Configuration.html has THREE DST modes (Disable, Recurring,
// NonRecurring), each with its own full set of fields, ALL present in the
// DOM simultaneously (JS shows/hides based on the selected mode) -- same
// shape of problem as PortConfiguration.html's duplicate Link Speed fields,
// except here several labels collide WITHIN one table (Month/Hours/Minutes/
// Offset each appear once in the NonRecurring block and once in the
// Recurring block). Rather than lean on generic xepage.go parsing and its
// document-order last-wins behavior, this reads/writes via hardcoded field
// names extracted directly with regex -- unambiguous regardless of DOM
// order.
//
// Only Disable and Recurring are supported; NonRecurring is rejected
// outright rather than guessed at (it uses a different, absolute-date field
// set: Month/Date/Year/Hours/Minutes, v_3_1_1..v_3_10_1).
//
// Confirmed live before this was wired into a resource: submitting mode +
// only the Recurring block's fields (omitting the NonRecurring block
// entirely) was accepted with no err_flag and no FILTER_MISSING -- unlike
// PortConfiguration.html, this page does NOT require hidden hold_all_fields.
package client
import (
"fmt"
"regexp"
"strconv"
)
const (
dstConfigPath = "/Summer_Time_Configuration.html"
dstModeField = "v_1_1_1"
dstStartWeekField = "v_3_12_1"
dstStartDayField = "v_3_13_1"
dstStartMonthField = "v_3_14_1"
dstStartHourField = "v_3_15_1"
dstStartMinuteField = "v_3_399_1"
dstEndWeekField = "v_3_16_1"
dstEndDayField = "v_3_17_1"
dstEndMonthField = "v_3_18_1"
dstEndHourField = "v_3_19_1"
dstEndMinuteField = "v_3_498_1"
dstOffsetField = "v_3_20_1"
dstOffsetAcronymField = "v_3_21_1"
DSTModeDisable = "Disable"
DSTModeRecurring = "Recurring"
)
type DSTConfig struct {
Mode string
StartWeek int
StartDay string
StartMonth string
StartHour int
StartMinute int
EndWeek int
EndDay string
EndMonth string
EndHour int
EndMinute int
OffsetMinutes int
OffsetAcronym string
}
func extractHiddenFieldValue(html, name string) string {
re := regexp.MustCompile(`(?i)NAME=` + regexp.QuoteMeta(name) + `\b[^>]*VALUE="([^"]*)"`)
m := re.FindStringSubmatch(html)
if m == nil {
return ""
}
return m[1]
}
func (c *Client) ReadDSTConfig() (DSTConfig, error) {
html, err := c.Fetch(dstConfigPath)
if err != nil {
return DSTConfig{}, err
}
atoi := func(name string) int {
n, _ := strconv.Atoi(extractHiddenFieldValue(html, name))
return n
}
return DSTConfig{
Mode: extractHiddenFieldValue(html, dstModeField),
StartWeek: atoi(dstStartWeekField),
StartDay: extractHiddenFieldValue(html, dstStartDayField),
StartMonth: extractHiddenFieldValue(html, dstStartMonthField),
StartHour: atoi(dstStartHourField),
StartMinute: atoi(dstStartMinuteField),
EndWeek: atoi(dstEndWeekField),
EndDay: extractHiddenFieldValue(html, dstEndDayField),
EndMonth: extractHiddenFieldValue(html, dstEndMonthField),
EndHour: atoi(dstEndHourField),
EndMinute: atoi(dstEndMinuteField),
OffsetMinutes: atoi(dstOffsetField),
OffsetAcronym: extractHiddenFieldValue(html, dstOffsetAcronymField),
}, nil
}
func (c *Client) SetDSTConfig(desired DSTConfig) error {
if desired.Mode != DSTModeDisable && desired.Mode != DSTModeRecurring {
return fmt.Errorf("unsupported Daylight Saving Time mode %q -- this resource only supports %q and %q (NonRecurring is not supported)", desired.Mode, DSTModeDisable, DSTModeRecurring)
}
fields := map[string]string{
dstModeField: desired.Mode,
dstStartWeekField: strconv.Itoa(desired.StartWeek),
dstStartDayField: desired.StartDay,
dstStartMonthField: desired.StartMonth,
dstStartHourField: strconv.Itoa(desired.StartHour),
dstStartMinuteField: strconv.Itoa(desired.StartMinute),
dstEndWeekField: strconv.Itoa(desired.EndWeek),
dstEndDayField: desired.EndDay,
dstEndMonthField: desired.EndMonth,
dstEndHourField: strconv.Itoa(desired.EndHour),
dstEndMinuteField: strconv.Itoa(desired.EndMinute),
dstOffsetField: strconv.Itoa(desired.OffsetMinutes),
dstOffsetAcronymField: desired.OffsetAcronym,
}
_, err := c.SubmitForm(dstConfigPath, fields)
return err
}
@@ -0,0 +1,243 @@
// Trunking (link aggregation / LAG) spans two pages with a genuinely
// different shape from everything else ported so far: TrunkConfig.html is a
// dynamic list of named entities (create/delete trunks, each gets a
// switch-assigned "TrunkN" id), and TrunkMembership.html assigns member
// ports to one of them. Every other resource so far is a singleton or a
// fixed for_each set (24 ports); this is the first with real create/delete
// of sub-entities.
//
// All three operations (Create, set members, Delete) were verified live
// before being wired into a Terraform resource, using a throwaway test
// trunk on ports 5 and 6 (never port 24), cleaned up afterward:
//
// - Create: POST TrunkConfig.html with v_1_1_1=Enable (the "Create"
// field), v_1_2_1=<name>, v_1_4_1=Add. The switch assigns the next
// "TrunkN" id automatically.
// - Set members: POST TrunkMembership.html with the selected trunk id in
// v_1_1_1, plus ALL 24 ports' v_2_3_1 (port number), v_2_3_2 (per-port
// LACP mode), v_2_3_3 (membership: the trunk id string if a member,
// "1" if not -- the exact sentinel the page's own JS uses for "off").
// Two gotchas confirmed live: (1) the page's other hidden context
// fields (v_1_2_1, v_1_2_2, v_1_3_1, v_5_1_1, v_6_1_1, v_2_1_2, v_2_1_3,
// v_2_1_4, v_3_1_1, v_3_2_1, v_3_3_1, v_3_4_1) must be resubmitted
// unchanged or the switch rejects with FILTER_MISSING; (2) v_2_3_1 is
// marked DISABLED in the raw HTML (a real browser wouldn't normally
// submit a disabled field), but the page's own JS
// (lagViewAutoGen.js's showvalues()) explicitly un-disables it right
// before submit -- omitting it also fails with FILTER_MISSING.
// - Delete: POST TrunkConfig.html with that trunk's row fields
// (v_1_5_1..v_1_5_6) resubmitted unchanged, plus v_1_5_7=Enable (the
// "Delete" trigger) and v_1_5_8=Delete (a fixed action-name constant).
// Omitting the row's current-value fields fails with "Error! Failed to
// remove LAG." Critically, including the page's top-level Create
// section (v_1_1_1/v_1_2_1/v_1_4_1) in the SAME request as a delete
// causes the switch to create ANOTHER trunk as a side effect instead --
// confirmed live (deleting "Trunk1" while resending v_1_4_1=Add
// produced a brand new empty "Trunk2"). So a delete request must omit
// those fields entirely.
//
// The per-row/per-trunk field name prefix ("1.<index>.1.") uses this
// trunk's zero-based position in ListTrunks()'s order, the same positional
// pattern used throughout this firmware (PortConfiguration, Port
// Mirroring, LLDP, Trunk Membership). Verified live only for the
// single-trunk case (index 0); multi-trunk indexing is inferred by pattern,
// not independently confirmed against a live switch with 2+ trunks.
package client
import (
"fmt"
"strconv"
"strings"
)
const (
trunkConfigPath = "/TrunkConfig.html"
trunkMembershipPath = "/TrunkMembership.html"
trunkSummaryTableID = "1_5"
trunkInterfaceLabel = "Interface"
trunkNameLabel = "Trunk Name"
trunkMembersLabel = "Trunk Members"
trunkAdminModeLabel = "Admin Mode"
trunkStaticCapLabel = "Static Capability"
trunkCreateField = "v_1_1_1"
trunkNameField = "v_1_2_1"
trunkAddActionField = "v_1_4_1"
trunkRowInterfaceField = "v_1_5_1"
trunkRowNameField = "v_1_5_2"
trunkRowMembersField = "v_1_5_5"
trunkRowAdminField = "v_1_5_3"
trunkRowStaticField = "v_1_5_4"
trunkRowModifyField = "v_1_5_6"
trunkRowDeleteField = "v_1_5_7"
trunkRowDeleteActionField = "v_1_5_8"
)
type Trunk struct {
ID string
Name string
Members []string
AdminMode bool
StaticCapability bool
}
// ListTrunks returns every configured trunk, in the switch's own display
// order -- that order also determines the zero-based row index used in
// per-row field names, needed by DeleteTrunk.
func (c *Client) ListTrunks() ([]Trunk, error) {
tables, err := c.FetchAllTables(trunkConfigPath)
if err != nil {
return nil, err
}
var summary *Table
for i := range tables {
if tables[i].ID == trunkSummaryTableID {
summary = &tables[i]
break
}
}
if summary == nil {
return nil, fmt.Errorf("table id %q not found on %s", trunkSummaryTableID, trunkConfigPath)
}
trunks := make([]Trunk, 0, len(summary.Rows))
for _, row := range summary.Rows {
var members []string
if m := row[trunkMembersLabel]; m != "" {
members = strings.Split(m, ",")
}
trunks = append(trunks, Trunk{
ID: row[trunkInterfaceLabel],
Name: row[trunkNameLabel],
Members: members,
AdminMode: row[trunkAdminModeLabel] == "Enable",
StaticCapability: row[trunkStaticCapLabel] == "Enable",
})
}
return trunks, nil
}
// FindTrunkByName returns the trunk with the given name, its zero-based
// row index (for DeleteTrunk), or (nil, -1, nil) if no trunk has that name.
func (c *Client) FindTrunkByName(name string) (*Trunk, int, error) {
trunks, err := c.ListTrunks()
if err != nil {
return nil, -1, err
}
for i, t := range trunks {
if t.Name == name {
found := t
return &found, i, nil
}
}
return nil, -1, nil
}
// CreateTrunk creates a new trunk with the given name (1-15 alphanumeric
// characters) and returns it once created, including its switch-assigned
// "TrunkN" id.
func (c *Client) CreateTrunk(name string) (Trunk, error) {
_, err := c.SubmitForm(trunkConfigPath, map[string]string{
trunkCreateField: "Enable",
trunkNameField: name,
trunkAddActionField: "Add",
})
if err != nil {
return Trunk{}, err
}
trunk, _, err := c.FindTrunkByName(name)
if err != nil {
return Trunk{}, err
}
if trunk == nil {
return Trunk{}, fmt.Errorf("trunk %q not found after creation", name)
}
return *trunk, nil
}
// DeleteTrunk removes the named trunk. See the package doc comment for the
// two confirmed-live gotchas (must resend the row's current values, must
// NOT include the page's Create-section fields).
func (c *Client) DeleteTrunk(name string) error {
trunk, index, err := c.FindTrunkByName(name)
if err != nil {
return err
}
if trunk == nil {
return nil // already gone
}
adminValue := "Disable"
if trunk.AdminMode {
adminValue = "Enable"
}
staticValue := "Disable"
if trunk.StaticCapability {
staticValue = "Enable"
}
prefix := fmt.Sprintf("1.%d.1.", index)
_, err = c.SubmitForm(trunkConfigPath, map[string]string{
prefix + trunkRowInterfaceField: trunk.ID,
prefix + trunkRowNameField: trunk.Name,
prefix + trunkRowMembersField: strings.Join(trunk.Members, ","),
prefix + trunkRowAdminField: adminValue,
prefix + trunkRowStaticField: staticValue,
prefix + trunkRowModifyField: "Disable",
prefix + trunkRowDeleteField: "Enable",
prefix + trunkRowDeleteActionField: "Delete",
})
return err
}
const trunkMembershipTrunkIDField = "v_1_1_1"
// trunkMembershipHiddenFields must be resubmitted unchanged on every
// SetTrunkMembers call or the switch rejects the write with FILTER_MISSING
// -- confirmed live.
var trunkMembershipHiddenFields = []string{
"v_1_2_1", "v_1_2_2", "v_1_3_1", "v_5_1_1", "v_6_1_1",
"v_2_1_2", "v_2_1_3", "v_2_1_4", "v_3_1_1", "v_3_2_1", "v_3_3_1", "v_3_4_1",
}
// SetTrunkMembers sets exactly which of the switch's 24 ports belong to
// trunkID (e.g. "Trunk1"), replacing any previous membership entirely.
func (c *Client) SetTrunkMembers(trunkID string, members map[string]bool) error {
// Select the trunk first (submit_flag=1, reload -- doesn't apply
// anything, just like PortConfiguration.html's port selector) so the
// hidden context fields we read back reflect this specific trunk, not
// whatever was last selected.
html, err := c.postForm(trunkMembershipPath, map[string]string{
"submit_flag": "1",
"submit_target": "TrunkMembership.html",
"err_flag": "0",
"err_msg": "",
"clazz_information": "TrunkMembership.html",
trunkMembershipTrunkIDField: trunkID,
})
if err != nil {
return err
}
fields := map[string]string{trunkMembershipTrunkIDField: trunkID}
for _, name := range trunkMembershipHiddenFields {
fields[name] = extractHiddenFieldValue(html, name)
}
for i := 0; i < 24; i++ {
port := strconv.Itoa(i + 1)
prefix := fmt.Sprintf("1.%d.24.", i)
fields[prefix+"v_2_3_1"] = port
fields[prefix+"v_2_3_2"] = "Enable"
if members[port] {
fields[prefix+"v_2_3_3"] = trunkID
} else {
fields[prefix+"v_2_3_3"] = "1"
}
}
_, err = c.SubmitForm(trunkMembershipPath, fields)
return err
}
@@ -184,5 +184,11 @@ func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resourc
NewGreenFeaturesResource, NewGreenFeaturesResource,
NewPortResource, NewPortResource,
NewSystemDescriptionResource, NewSystemDescriptionResource,
NewTimeZoneResource,
NewLLDPConfigurationResource,
NewDaylightSavingTimeResource,
NewFlowControlResource,
NewPortMirroringResource,
NewTrunkResource,
} }
} }
@@ -0,0 +1,235 @@
// Port of Summer_Time_Configuration.html -- see client/dst_config.go's
// package doc comment for why this page needed dedicated handling. Only
// Disable and Recurring modes are supported; NonRecurring is rejected by
// the client with a clear error rather than guessed at.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
var (
_ resource.Resource = &daylightSavingTimeResource{}
_ resource.ResourceWithConfigure = &daylightSavingTimeResource{}
)
func NewDaylightSavingTimeResource() resource.Resource {
return &daylightSavingTimeResource{}
}
type daylightSavingTimeResource struct {
client *client.Client
}
type daylightSavingTimeResourceModel struct {
ID types.String `tfsdk:"id"`
Mode types.String `tfsdk:"mode"`
StartWeek types.Int64 `tfsdk:"start_week"`
StartDay types.String `tfsdk:"start_day"`
StartMonth types.String `tfsdk:"start_month"`
StartHour types.Int64 `tfsdk:"start_hour"`
StartMinute types.Int64 `tfsdk:"start_minute"`
EndWeek types.Int64 `tfsdk:"end_week"`
EndDay types.String `tfsdk:"end_day"`
EndMonth types.String `tfsdk:"end_month"`
EndHour types.Int64 `tfsdk:"end_hour"`
EndMinute types.Int64 `tfsdk:"end_minute"`
OffsetMinutes types.Int64 `tfsdk:"offset_minutes"`
OffsetAcronym types.String `tfsdk:"offset_acronym"`
}
func (r *daylightSavingTimeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_daylight_saving_time"
}
func (r *daylightSavingTimeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's Daylight Saving Time Configuration page (Summer_Time_Configuration.html). Singleton -- there is only ever one of these per switch. Only \"Disable\" and \"Recurring\" modes are supported (the page's NonRecurring mode uses a different absolute-date field set and is rejected). Delete sets mode to \"Disable\", leaving the recurring rule fields as last configured (harmless once disabled).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"daylight_saving_time\" -- this is a singleton resource.",
},
"mode": schema.StringAttribute{
Required: true,
Description: "\"Disable\" or \"Recurring\" only.",
},
"start_week": schema.Int64Attribute{
Required: true,
Description: "Week of the month DST starts (1-4, or 5 for \"last\").",
},
"start_day": schema.StringAttribute{
Required: true,
Description: "e.g. \"Sun\".",
},
"start_month": schema.StringAttribute{
Required: true,
Description: "e.g. \"Mar\".",
},
"start_hour": schema.Int64Attribute{
Required: true,
Description: "0-23.",
},
"start_minute": schema.Int64Attribute{
Required: true,
Description: "0-59.",
},
"end_week": schema.Int64Attribute{
Required: true,
Description: "Week of the month DST ends (1-4, or 5 for \"last\").",
},
"end_day": schema.StringAttribute{
Required: true,
Description: "e.g. \"Sun\".",
},
"end_month": schema.StringAttribute{
Required: true,
Description: "e.g. \"Oct\".",
},
"end_hour": schema.Int64Attribute{
Required: true,
Description: "0-23.",
},
"end_minute": schema.Int64Attribute{
Required: true,
Description: "0-59.",
},
"offset_minutes": schema.Int64Attribute{
Required: true,
Description: "Minutes to shift the clock during DST. Range 1-1440.",
},
"offset_acronym": schema.StringAttribute{
Required: true,
Description: "0-4 characters, e.g. \"CEST\". Can be an empty string.",
},
},
}
}
func (r *daylightSavingTimeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func dstConfigToModel(c client.DSTConfig) daylightSavingTimeResourceModel {
return daylightSavingTimeResourceModel{
ID: types.StringValue("daylight_saving_time"),
Mode: types.StringValue(c.Mode),
StartWeek: types.Int64Value(int64(c.StartWeek)),
StartDay: types.StringValue(c.StartDay),
StartMonth: types.StringValue(c.StartMonth),
StartHour: types.Int64Value(int64(c.StartHour)),
StartMinute: types.Int64Value(int64(c.StartMinute)),
EndWeek: types.Int64Value(int64(c.EndWeek)),
EndDay: types.StringValue(c.EndDay),
EndMonth: types.StringValue(c.EndMonth),
EndHour: types.Int64Value(int64(c.EndHour)),
EndMinute: types.Int64Value(int64(c.EndMinute)),
OffsetMinutes: types.Int64Value(int64(c.OffsetMinutes)),
OffsetAcronym: types.StringValue(c.OffsetAcronym),
}
}
func modelToDSTConfig(m daylightSavingTimeResourceModel) client.DSTConfig {
return client.DSTConfig{
Mode: m.Mode.ValueString(),
StartWeek: int(m.StartWeek.ValueInt64()),
StartDay: m.StartDay.ValueString(),
StartMonth: m.StartMonth.ValueString(),
StartHour: int(m.StartHour.ValueInt64()),
StartMinute: int(m.StartMinute.ValueInt64()),
EndWeek: int(m.EndWeek.ValueInt64()),
EndDay: m.EndDay.ValueString(),
EndMonth: m.EndMonth.ValueString(),
EndHour: int(m.EndHour.ValueInt64()),
EndMinute: int(m.EndMinute.ValueInt64()),
OffsetMinutes: int(m.OffsetMinutes.ValueInt64()),
OffsetAcronym: m.OffsetAcronym.ValueString(),
}
}
func (r *daylightSavingTimeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.SetDSTConfig(modelToDSTConfig(plan)); err != nil {
resp.Diagnostics.AddError("Unable to set Daylight Saving Time state", err.Error())
return
}
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *daylightSavingTimeResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *daylightSavingTimeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.SetDSTConfig(modelToDSTConfig(plan)); err != nil {
resp.Diagnostics.AddError("Unable to set Daylight Saving Time state", err.Error())
return
}
actual, err := r.client.ReadDSTConfig()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Daylight Saving Time state", err.Error())
return
}
state := dstConfigToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete sets mode to Disable, leaving the recurring rule fields as last
// configured -- harmless once DST itself is disabled, and avoids having to
// invent a "correct" default rule for an arbitrary switch's timezone.
func (r *daylightSavingTimeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state daylightSavingTimeResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
desired := modelToDSTConfig(state)
desired.Mode = client.DSTModeDisable
if err := r.client.SetDSTConfig(desired); err != nil {
resp.Diagnostics.AddError("Unable to reset Daylight Saving Time state on delete", err.Error())
}
}
@@ -0,0 +1,167 @@
// Port of SwitchConfig.html -- single boolean field, same shape as
// resource_locator.go.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
flowControlPath = "/SwitchConfig.html"
flowControlCaption = "Flow Control Configuration"
flowControlEnableLabel = "Enable Flow Control"
)
var (
_ resource.Resource = &flowControlResource{}
_ resource.ResourceWithConfigure = &flowControlResource{}
)
func NewFlowControlResource() resource.Resource {
return &flowControlResource{}
}
type flowControlResource struct {
client *client.Client
}
type flowControlResourceModel struct {
ID types.String `tfsdk:"id"`
Enabled types.Bool `tfsdk:"enabled"`
}
func (r *flowControlResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_flow_control"
}
func (r *flowControlResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's global Flow Control setting (SwitchConfig.html). Singleton -- there is only ever one of these per switch. Delete resets it to Enable (the switch's factory default).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"flow_control\" -- this is a singleton resource.",
},
"enabled": schema.BoolAttribute{
Required: true,
Description: "Whether 802.3x flow control is enabled switch-wide.",
},
},
}
}
func (r *flowControlResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func (r *flowControlResource) readState() (bool, error) {
tables, err := r.client.FetchTables(flowControlPath)
if err != nil {
return false, err
}
table, ok := tables[flowControlCaption]
if !ok {
return false, fmt.Errorf("table captioned %q not found on %s", flowControlCaption, flowControlPath)
}
return table.Record[flowControlEnableLabel] == "Enable", nil
}
func (r *flowControlResource) setState(enabled bool) error {
tables, err := r.client.FetchTables(flowControlPath)
if err != nil {
return err
}
table, ok := tables[flowControlCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", flowControlCaption, flowControlPath)
}
field, ok := table.FieldNames[flowControlEnableLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", flowControlEnableLabel, flowControlCaption)
}
value := "Disable"
if enabled {
value = "Enable"
}
_, err = r.client.SubmitForm(flowControlPath, map[string]string{field: value})
return err
}
func (r *flowControlResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan flowControlResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set Flow Control state", err.Error())
return
}
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *flowControlResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *flowControlResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan flowControlResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setState(plan.Enabled.ValueBool()); err != nil {
resp.Diagnostics.AddError("Unable to set Flow Control state", err.Error())
return
}
enabled, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Flow Control state", err.Error())
return
}
state := flowControlResourceModel{ID: types.StringValue("flow_control"), Enabled: types.BoolValue(enabled)}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets to Enable -- the switch's factory default, harmless (flow
// control is a link-layer congestion mechanism, not a security setting).
func (r *flowControlResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(true); err != nil {
resp.Diagnostics.AddError("Unable to reset Flow Control state on delete", err.Error())
}
}
@@ -0,0 +1,273 @@
// Port of LLDPConfig.html's "Global Mode" table only. The page also has a
// per-port "Interface Mode" table (Transmit/Receive Enable etc, one row per
// port with per-port field names like "1.0.24.v_2_1_2") -- that's a
// separate, more involved write target (needs the same per-port-field-name
// handling as resource_port.go) and isn't built yet; only requested was
// "LLDP Configuration" which maps to the global settings.
package provider
import (
"context"
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
lldpConfigPath = "/LLDPConfig.html"
lldpGlobalModeCaption = "Global Mode"
lldpTransmitIntervalLabel = "Transmit Interval"
lldpTransmitHoldLabel = "Transmit Hold"
lldpReinitDelayLabel = "Re-Initialization Delay"
lldpNotificationIntervalLabel = "Notification Interval"
)
var (
_ resource.Resource = &lldpConfigurationResource{}
_ resource.ResourceWithConfigure = &lldpConfigurationResource{}
)
func NewLLDPConfigurationResource() resource.Resource {
return &lldpConfigurationResource{}
}
type lldpConfigurationResource struct {
client *client.Client
}
type lldpConfigurationResourceModel struct {
ID types.String `tfsdk:"id"`
TransmitInterval types.Int64 `tfsdk:"transmit_interval"`
TransmitHold types.Int64 `tfsdk:"transmit_hold"`
ReinitDelay types.Int64 `tfsdk:"reinit_delay"`
NotificationInterval types.Int64 `tfsdk:"notification_interval"`
}
func (r *lldpConfigurationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_lldp_configuration"
}
func (r *lldpConfigurationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's LLDP Global Mode settings (LLDPConfig.html). Singleton -- there is only ever one of these per switch. Does NOT cover the page's per-port Interface Mode table -- see the package doc comment. Delete resets all four fields to their documented factory defaults.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"lldp_configuration\" -- this is a singleton resource.",
},
"transmit_interval": schema.Int64Attribute{
Required: true,
Description: "Seconds between LLDP transmissions. Range 5-32768 (switch default: 30).",
},
"transmit_hold": schema.Int64Attribute{
Required: true,
Description: "Multiplier for how long a receiver holds LLDP info before aging it out. Range 2-10 (switch default: 4).",
},
"reinit_delay": schema.Int64Attribute{
Required: true,
Description: "Seconds to wait before re-initializing LLDP on a port after it's disabled. Range 1-10 (switch default: 2).",
},
"notification_interval": schema.Int64Attribute{
Required: true,
Description: "Minimum seconds between LLDP change notifications. Range 5-3600 (switch default: 5).",
},
},
}
}
func (r *lldpConfigurationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type lldpConfigurationState struct {
TransmitInterval int64
TransmitHold int64
ReinitDelay int64
NotificationInterval int64
}
func (r *lldpConfigurationResource) readState() (lldpConfigurationState, error) {
tables, err := r.client.FetchTables(lldpConfigPath)
if err != nil {
return lldpConfigurationState{}, err
}
table, ok := tables[lldpGlobalModeCaption]
if !ok {
return lldpConfigurationState{}, fmt.Errorf("table captioned %q not found on %s", lldpGlobalModeCaption, lldpConfigPath)
}
parse := func(label string) (int64, error) {
v, err := strconv.ParseInt(table.Record[label], 10, 64)
if err != nil {
return 0, fmt.Errorf("parsing %q (%q) as int: %w", label, table.Record[label], err)
}
return v, nil
}
transmitInterval, err := parse(lldpTransmitIntervalLabel)
if err != nil {
return lldpConfigurationState{}, err
}
transmitHold, err := parse(lldpTransmitHoldLabel)
if err != nil {
return lldpConfigurationState{}, err
}
reinitDelay, err := parse(lldpReinitDelayLabel)
if err != nil {
return lldpConfigurationState{}, err
}
notificationInterval, err := parse(lldpNotificationIntervalLabel)
if err != nil {
return lldpConfigurationState{}, err
}
return lldpConfigurationState{
TransmitInterval: transmitInterval,
TransmitHold: transmitHold,
ReinitDelay: reinitDelay,
NotificationInterval: notificationInterval,
}, nil
}
func (r *lldpConfigurationResource) setState(desired lldpConfigurationState) error {
tables, err := r.client.FetchTables(lldpConfigPath)
if err != nil {
return err
}
table, ok := tables[lldpGlobalModeCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", lldpGlobalModeCaption, lldpConfigPath)
}
field := func(label string) (string, error) {
f, ok := table.FieldNames[label]
if !ok {
return "", fmt.Errorf("field %q not found in table %q", label, lldpGlobalModeCaption)
}
return f, nil
}
transmitIntervalField, err := field(lldpTransmitIntervalLabel)
if err != nil {
return err
}
transmitHoldField, err := field(lldpTransmitHoldLabel)
if err != nil {
return err
}
reinitDelayField, err := field(lldpReinitDelayLabel)
if err != nil {
return err
}
notificationIntervalField, err := field(lldpNotificationIntervalLabel)
if err != nil {
return err
}
_, err = r.client.SubmitForm(lldpConfigPath, map[string]string{
transmitIntervalField: strconv.FormatInt(desired.TransmitInterval, 10),
transmitHoldField: strconv.FormatInt(desired.TransmitHold, 10),
reinitDelayField: strconv.FormatInt(desired.ReinitDelay, 10),
notificationIntervalField: strconv.FormatInt(desired.NotificationInterval, 10),
})
return err
}
func lldpConfigStateToModel(s lldpConfigurationState) lldpConfigurationResourceModel {
return lldpConfigurationResourceModel{
ID: types.StringValue("lldp_configuration"),
TransmitInterval: types.Int64Value(s.TransmitInterval),
TransmitHold: types.Int64Value(s.TransmitHold),
ReinitDelay: types.Int64Value(s.ReinitDelay),
NotificationInterval: types.Int64Value(s.NotificationInterval),
}
}
func (r *lldpConfigurationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan lldpConfigurationResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := lldpConfigurationState{
TransmitInterval: plan.TransmitInterval.ValueInt64(),
TransmitHold: plan.TransmitHold.ValueInt64(),
ReinitDelay: plan.ReinitDelay.ValueInt64(),
NotificationInterval: plan.NotificationInterval.ValueInt64(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set LLDP Configuration state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *lldpConfigurationResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *lldpConfigurationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan lldpConfigurationResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := lldpConfigurationState{
TransmitInterval: plan.TransmitInterval.ValueInt64(),
TransmitHold: plan.TransmitHold.ValueInt64(),
ReinitDelay: plan.ReinitDelay.ValueInt64(),
NotificationInterval: plan.NotificationInterval.ValueInt64(),
}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set LLDP Configuration state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back LLDP Configuration state", err.Error())
return
}
state := lldpConfigStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets all four fields to their documented factory defaults --
// safe, no traffic impact.
func (r *lldpConfigurationResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
defaults := lldpConfigurationState{TransmitInterval: 30, TransmitHold: 4, ReinitDelay: 2, NotificationInterval: 5}
if err := r.setState(defaults); err != nil {
resp.Diagnostics.AddError("Unable to reset LLDP Configuration state on delete", err.Error())
}
}
@@ -0,0 +1,210 @@
// Port of FDBConfig.html's "Port Mirroring Configuration" table only (the
// page's filename is misleading -- it's actually Port Mirroring, not FDB).
// The page also has a per-source-port "Direction" table (25 rows: ports
// 1-24 + CPU, per-port field names like "1.0.25.v_1_3_2") -- same shape of
// complexity as LLDPConfig.html's per-port Interface Mode table, and
// likewise deferred as a separate not-yet-built write target.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
portMirroringPath = "/FDBConfig.html"
portMirroringCaption = "Port Mirroring Configuration"
portMirroringEnableLabel = "Enable Mirroring"
portMirroringDestinationLabel = "Destination Port"
// portMirroringHiddenContextField (v_2_2_2, always "1") sits next to
// Destination Port in the DOM with no visible label -- same
// "FILTER_MISSING if omitted" pattern as PortConfiguration.html's
// hidden UnitIndex/mstid fields. Confirmed live: omitting it fails with
// "Error! Failed to Set 'Enable Mirroring' ... error occured
// FILTER_MISSING".
portMirroringHiddenContextField = "v_2_2_2"
)
var (
_ resource.Resource = &portMirroringResource{}
_ resource.ResourceWithConfigure = &portMirroringResource{}
)
func NewPortMirroringResource() resource.Resource {
return &portMirroringResource{}
}
type portMirroringResource struct {
client *client.Client
}
type portMirroringResourceModel struct {
ID types.String `tfsdk:"id"`
Enabled types.Bool `tfsdk:"enabled"`
DestinationPort types.String `tfsdk:"destination_port"`
}
func (r *portMirroringResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_port_mirroring"
}
func (r *portMirroringResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's global Port Mirroring settings (FDBConfig.html). Singleton -- there is only ever one of these per switch. Does NOT cover the page's per-source-port Direction table -- see the package doc comment. Delete disables mirroring and clears the destination port.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"port_mirroring\" -- this is a singleton resource.",
},
"enabled": schema.BoolAttribute{
Required: true,
Description: "Whether port mirroring is enabled.",
},
"destination_port": schema.StringAttribute{
Required: true,
Description: "\"None\", or a port number \"1\"..\"24\" to receive mirrored traffic.",
},
},
}
}
func (r *portMirroringResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type portMirroringState struct {
Enabled bool
DestinationPort string
}
func (r *portMirroringResource) readState() (portMirroringState, error) {
tables, err := r.client.FetchTables(portMirroringPath)
if err != nil {
return portMirroringState{}, err
}
table, ok := tables[portMirroringCaption]
if !ok {
return portMirroringState{}, fmt.Errorf("table captioned %q not found on %s", portMirroringCaption, portMirroringPath)
}
return portMirroringState{
Enabled: table.Record[portMirroringEnableLabel] == "Enable",
DestinationPort: table.Record[portMirroringDestinationLabel],
}, nil
}
func (r *portMirroringResource) setState(desired portMirroringState) error {
tables, err := r.client.FetchTables(portMirroringPath)
if err != nil {
return err
}
table, ok := tables[portMirroringCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", portMirroringCaption, portMirroringPath)
}
enableField, ok := table.FieldNames[portMirroringEnableLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", portMirroringEnableLabel, portMirroringCaption)
}
destField, ok := table.FieldNames[portMirroringDestinationLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", portMirroringDestinationLabel, portMirroringCaption)
}
value := "Disable"
if desired.Enabled {
value = "Enable"
}
_, err = r.client.SubmitForm(portMirroringPath, map[string]string{
enableField: value,
destField: desired.DestinationPort,
portMirroringHiddenContextField: "1",
})
return err
}
func portMirroringStateToModel(s portMirroringState) portMirroringResourceModel {
return portMirroringResourceModel{
ID: types.StringValue("port_mirroring"),
Enabled: types.BoolValue(s.Enabled),
DestinationPort: types.StringValue(s.DestinationPort),
}
}
func (r *portMirroringResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan portMirroringResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := portMirroringState{Enabled: plan.Enabled.ValueBool(), DestinationPort: plan.DestinationPort.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Port Mirroring state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portMirroringResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *portMirroringResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan portMirroringResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := portMirroringState{Enabled: plan.Enabled.ValueBool(), DestinationPort: plan.DestinationPort.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Port Mirroring state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Port Mirroring state", err.Error())
return
}
state := portMirroringStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete disables mirroring and clears the destination port -- matches the
// switch's observed factory default, harmless.
func (r *portMirroringResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(portMirroringState{Enabled: false, DestinationPort: "None"}); err != nil {
resp.Diagnostics.AddError("Unable to reset Port Mirroring state on delete", err.Error())
}
}
@@ -0,0 +1,194 @@
// Port of Time_Zone_Configuration.html. Simple scalar page, both fields
// (Time Zone combo, Acronym textbox) are read-write, no duplicate labels.
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
const (
timeZonePath = "/Time_Zone_Configuration.html"
timeZoneCaption = "Time Zone Configuration"
timeZoneNameLabel = "Time Zone"
timeZoneAcronymLabel = "Acronym"
)
var (
_ resource.Resource = &timeZoneResource{}
_ resource.ResourceWithConfigure = &timeZoneResource{}
)
func NewTimeZoneResource() resource.Resource {
return &timeZoneResource{}
}
type timeZoneResource struct {
client *client.Client
}
type timeZoneResourceModel struct {
ID types.String `tfsdk:"id"`
Zone types.String `tfsdk:"zone"`
Acronym types.String `tfsdk:"acronym"`
}
func (r *timeZoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_time_zone"
}
func (r *timeZoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Controls the switch's Time Zone Configuration page. Singleton -- there is only ever one of these per switch. Delete resets to \"None\" (no offset) with an empty acronym.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Always \"time_zone\" -- this is a singleton resource.",
},
"zone": schema.StringAttribute{
Required: true,
Description: "e.g. \"(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna\", or \"None\". Not enum-validated client-side -- the exact display strings the switch accepts are numerous (see _xe_Time_Zone_Configuration.js on the switch); invalid values are caught by reading back rather than guessed at up front.",
},
"acronym": schema.StringAttribute{
Required: true,
Description: "0-4 characters, e.g. \"CET\". Can be an empty string.",
},
},
}
}
func (r *timeZoneResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
type timeZoneState struct {
Zone string
Acronym string
}
func (r *timeZoneResource) readState() (timeZoneState, error) {
tables, err := r.client.FetchTables(timeZonePath)
if err != nil {
return timeZoneState{}, err
}
table, ok := tables[timeZoneCaption]
if !ok {
return timeZoneState{}, fmt.Errorf("table captioned %q not found on %s", timeZoneCaption, timeZonePath)
}
return timeZoneState{
Zone: table.Record[timeZoneNameLabel],
Acronym: table.Record[timeZoneAcronymLabel],
}, nil
}
func (r *timeZoneResource) setState(desired timeZoneState) error {
tables, err := r.client.FetchTables(timeZonePath)
if err != nil {
return err
}
table, ok := tables[timeZoneCaption]
if !ok {
return fmt.Errorf("table captioned %q not found on %s", timeZoneCaption, timeZonePath)
}
zoneField, ok := table.FieldNames[timeZoneNameLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", timeZoneNameLabel, timeZoneCaption)
}
acronymField, ok := table.FieldNames[timeZoneAcronymLabel]
if !ok {
return fmt.Errorf("field %q not found in table %q", timeZoneAcronymLabel, timeZoneCaption)
}
_, err = r.client.SubmitForm(timeZonePath, map[string]string{
zoneField: desired.Zone,
acronymField: desired.Acronym,
})
return err
}
func timeZoneStateToModel(s timeZoneState) timeZoneResourceModel {
return timeZoneResourceModel{
ID: types.StringValue("time_zone"),
Zone: types.StringValue(s.Zone),
Acronym: types.StringValue(s.Acronym),
}
}
func (r *timeZoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan timeZoneResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := timeZoneState{Zone: plan.Zone.ValueString(), Acronym: plan.Acronym.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Time Zone state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *timeZoneResource) Read(ctx context.Context, _ resource.ReadRequest, resp *resource.ReadResponse) {
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *timeZoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan timeZoneResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
desired := timeZoneState{Zone: plan.Zone.ValueString(), Acronym: plan.Acronym.ValueString()}
if err := r.setState(desired); err != nil {
resp.Diagnostics.AddError("Unable to set Time Zone state", err.Error())
return
}
actual, err := r.readState()
if err != nil {
resp.Diagnostics.AddError("Unable to read back Time Zone state", err.Error())
return
}
state := timeZoneStateToModel(actual)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
// Delete resets to "None" (no timezone offset) with an empty acronym --
// harmless, matches the switch's out-of-box default.
func (r *timeZoneResource) Delete(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) {
if err := r.setState(timeZoneState{Zone: "None", Acronym: ""}); err != nil {
resp.Diagnostics.AddError("Unable to reset Time Zone state on delete", err.Error())
}
}
@@ -0,0 +1,254 @@
// Port of TrunkConfig.html + TrunkMembership.html -- see
// client/trunk.go's package doc comment for the full write-protocol
// writeup. Unlike every other resource so far (singletons, or a fixed
// for_each set of 24 ports), a trunk is a genuine named sub-entity: create,
// assign members, delete.
//
// admin_mode and static_capability are exposed read-only (Computed) only --
// only Create/set-members/Delete were verified live; toggling those two via
// the row-based "Modify" mechanism was not tested, so this resource doesn't
// claim to control them.
package provider
import (
"context"
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-hpe1810/internal/provider/client"
)
var (
_ resource.Resource = &trunkResource{}
_ resource.ResourceWithConfigure = &trunkResource{}
)
func NewTrunkResource() resource.Resource {
return &trunkResource{}
}
type trunkResource struct {
client *client.Client
}
type trunkResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Members types.Set `tfsdk:"members"`
AdminMode types.Bool `tfsdk:"admin_mode"`
StaticCapability types.Bool `tfsdk:"static_capability"`
}
func (r *trunkResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_trunk"
}
func (r *trunkResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Creates a link aggregation trunk (LAG) and sets its member ports (TrunkConfig.html + TrunkMembership.html). The provider's admin_port (default 24) can never be included in members. Delete removes the trunk entirely.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Switch-assigned identifier, e.g. \"Trunk1\".",
},
"name": schema.StringAttribute{
Required: true,
Description: "1-15 alphanumeric characters. Changing this replaces the resource (trunks aren't renamed in place).",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"members": schema.SetAttribute{
Required: true,
ElementType: types.StringType,
Description: "Port numbers to include in this trunk, e.g. [\"5\", \"6\"]. Never include the admin_port.",
},
"admin_mode": schema.BoolAttribute{
Computed: true,
Description: "Read-only -- reflects the switch's current value, not settable by this resource (defaults to Enable on creation).",
},
"static_capability": schema.BoolAttribute{
Computed: true,
Description: "Read-only -- reflects the switch's current value, not settable by this resource (defaults to Disable, i.e. LACP/dynamic, on creation).",
},
},
}
}
func (r *trunkResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData))
return
}
r.client = c
}
func (r *trunkResource) guardAdminPort(members []string) error {
for _, m := range members {
n, err := strconv.Atoi(m)
if err != nil {
return fmt.Errorf("member %q is not a valid port number: %w", m, err)
}
if int64(n) == r.client.AdminPort {
return fmt.Errorf("refusing to include interface %q in a trunk: it's the provider's admin_port (%d), protected from all writes by this resource", m, r.client.AdminPort)
}
}
return nil
}
func trunkToModel(t client.Trunk, membersSet types.Set) trunkResourceModel {
return trunkResourceModel{
ID: types.StringValue(t.ID),
Name: types.StringValue(t.Name),
Members: membersSet,
AdminMode: types.BoolValue(t.AdminMode),
StaticCapability: types.BoolValue(t.StaticCapability),
}
}
func (r *trunkResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan trunkResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
var members []string
resp.Diagnostics.Append(plan.Members.ElementsAs(ctx, &members, false)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.guardAdminPort(members); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
trunk, err := r.client.CreateTrunk(plan.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to create trunk", err.Error())
return
}
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
if err := r.client.SetTrunkMembers(trunk.ID, memberSet); err != nil {
resp.Diagnostics.AddError("Unable to set trunk members", err.Error())
return
}
final, _, err := r.client.FindTrunkByName(plan.Name.ValueString())
if err != nil || final == nil {
resp.Diagnostics.AddError("Unable to read back trunk", fmt.Sprintf("err=%v, found=%v", err, final != nil))
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, final.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
state := trunkToModel(*final, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *trunkResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
trunk, _, err := r.client.FindTrunkByName(state.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to read trunk", err.Error())
return
}
if trunk == nil {
resp.State.RemoveResource(ctx)
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, trunk.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
newState := trunkToModel(*trunk, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
func (r *trunkResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan trunkResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
var members []string
resp.Diagnostics.Append(plan.Members.ElementsAs(ctx, &members, false)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.guardAdminPort(members); err != nil {
resp.Diagnostics.AddError("Refusing to write to admin_port", err.Error())
return
}
trunkID := state.ID.ValueString()
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
if err := r.client.SetTrunkMembers(trunkID, memberSet); err != nil {
resp.Diagnostics.AddError("Unable to set trunk members", err.Error())
return
}
final, _, err := r.client.FindTrunkByName(plan.Name.ValueString())
if err != nil || final == nil {
resp.Diagnostics.AddError("Unable to read back trunk", fmt.Sprintf("err=%v, found=%v", err, final != nil))
return
}
membersValue, diags := types.SetValueFrom(ctx, types.StringType, final.Members)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
newState := trunkToModel(*final, membersValue)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
// Delete removes the trunk entirely -- there's no meaningful "safe default"
// to reset a trunk to; a LAG either exists or it doesn't.
func (r *trunkResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state trunkResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteTrunk(state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("Unable to delete trunk", err.Error())
}
}
+67
View File
@@ -39,6 +39,73 @@ resource "hpe1810_system_description" "draupnir01" {
contact = "" contact = ""
} }
# Fifth write resource: Time Zone Configuration. Singleton; Delete resets
# to "None" with an empty acronym (see resource_time_zone.go).
resource "hpe1810_time_zone" "draupnir01" {
zone = "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna"
acronym = ""
}
# Sixth write resource: LLDP Global Mode settings only (LLDPConfig.html's
# per-port Interface Mode table isn't covered -- see resource_lldp_configuration.go).
# Singleton; Delete resets to factory defaults.
resource "hpe1810_lldp_configuration" "draupnir01" {
transmit_interval = 30
transmit_hold = 4
reinit_delay = 2
notification_interval = 5
}
# Seventh write resource: Daylight Saving Time. Singleton; only "Disable"
# and "Recurring" modes are supported (see resource_daylight_saving_time.go
# and client/dst_config.go -- NonRecurring uses a different field set and is
# rejected). Delete sets mode to Disable. Values below match what's
# currently live (standard EU DST rule).
resource "hpe1810_daylight_saving_time" "draupnir01" {
mode = "Recurring"
start_week = 5
start_day = "Sun"
start_month = "Mar"
start_hour = 2
start_minute = 0
end_week = 5
end_day = "Sun"
end_month = "Oct"
end_hour = 3
end_minute = 0
offset_minutes = 1
offset_acronym = ""
}
# Eighth write resource: Flow Control (SwitchConfig.html). Singleton;
# Delete resets to Enable (factory default).
resource "hpe1810_flow_control" "draupnir01" {
enabled = true
}
# Ninth write resource: Port Mirroring global settings only (FDBConfig.html's
# per-source-port Direction table isn't covered -- see resource_port_mirroring.go).
# Singleton; Delete disables mirroring and clears the destination port.
resource "hpe1810_port_mirroring" "draupnir01" {
enabled = false
destination_port = ""
}
# Tenth write resource: link aggregation (LAG). Genuinely different shape
# from everything above -- a named sub-entity with create/delete, not a
# singleton or a fixed for_each set. See resource_trunk.go and
# client/trunk.go for the full write-protocol writeup, including the
# "including the Create-section fields in a delete request creates ANOTHER
# trunk as a side effect" quirk. admin_mode/static_capability are read-only
# here (not independently verified writable). Delete removes the trunk.
# No trunk declared by default -- add one here when you actually want to
# bond ports, e.g.:
#
# resource "hpe1810_trunk" "unraid" {
# name = "unraid"
# members = ["5", "6"]
# }
data "hpe1810_system_description" "draupnir01" {} data "hpe1810_system_description" "draupnir01" {}
data "hpe1810_network_setup" "draupnir01" {} data "hpe1810_network_setup" "draupnir01" {}
data "hpe1810_port_summary" "draupnir01" {} data "hpe1810_port_summary" "draupnir01" {}