diff --git a/AGENT.md b/AGENT.md index e083ccb..8c106e0 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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 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. + 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 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..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..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 (`HPE1810_SAVE_RUNNING_CONFIG` env fallback, default `false`). If `true`, `Shutdown()` (called once at process exit, same place as the logout below) POSTs to diff --git a/PICKUP.md b/PICKUP.md new file mode 100644 index 0000000..a8d1d89 --- /dev/null +++ b/PICKUP.md @@ -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/.html.html` for structure, but verify writes live with curl before + writing any Go. +3. Follow the established resource shape: `client/.go` (or a new method on + `client.Client`) for the HTTP protocol, `resource_.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`. diff --git a/TODO.md b/TODO.md index 6716f6d..76ad8c1 100644 --- a/TODO.md +++ b/TODO.md @@ -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 | | | 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 | y| | -| Daylight Saving Time | http://192.168.2.10/Summer_Time_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| yes (Disable/Recurring only, not NonRecurring) | | 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 | | | -| 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| | +| 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| yes | +| Green Features | http://192.168.2.10/greenmode.html | y| yes | | 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 | 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 | y| | -| Trunk Membership | http://192.168.2.10/TrunkMembership.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| yes (hpe1810_trunk) | | 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 | 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| | | Remote Device | http://192.168.2.10/LLDPRemoteDeviceSumm.html | y| | | 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 | | | | 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 | y| | +| Locator | http://192.168.2.10/locator.html | y| yes | | 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 | 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 | | | diff --git a/terraform-provider-hpe1810/internal/provider/client/dst_config.go b/terraform-provider-hpe1810/internal/provider/client/dst_config.go new file mode 100644 index 0000000..5b74bcf --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/dst_config.go @@ -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 +} diff --git a/terraform-provider-hpe1810/internal/provider/client/trunk.go b/terraform-provider-hpe1810/internal/provider/client/trunk.go new file mode 100644 index 0000000..682a428 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/trunk.go @@ -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=, 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..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 +} diff --git a/terraform-provider-hpe1810/internal/provider/provider.go b/terraform-provider-hpe1810/internal/provider/provider.go index c7d46a3..4a63133 100644 --- a/terraform-provider-hpe1810/internal/provider/provider.go +++ b/terraform-provider-hpe1810/internal/provider/provider.go @@ -184,5 +184,11 @@ func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resourc NewGreenFeaturesResource, NewPortResource, NewSystemDescriptionResource, + NewTimeZoneResource, + NewLLDPConfigurationResource, + NewDaylightSavingTimeResource, + NewFlowControlResource, + NewPortMirroringResource, + NewTrunkResource, } } diff --git a/terraform-provider-hpe1810/internal/provider/resource_daylight_saving_time.go b/terraform-provider-hpe1810/internal/provider/resource_daylight_saving_time.go new file mode 100644 index 0000000..5382227 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_daylight_saving_time.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_flow_control.go b/terraform-provider-hpe1810/internal/provider/resource_flow_control.go new file mode 100644 index 0000000..eb49b3f --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_flow_control.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_lldp_configuration.go b/terraform-provider-hpe1810/internal/provider/resource_lldp_configuration.go new file mode 100644 index 0000000..c822586 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_lldp_configuration.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_port_mirroring.go b/terraform-provider-hpe1810/internal/provider/resource_port_mirroring.go new file mode 100644 index 0000000..7e37460 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_port_mirroring.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_time_zone.go b/terraform-provider-hpe1810/internal/provider/resource_time_zone.go new file mode 100644 index 0000000..a50443d --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_time_zone.go @@ -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()) + } +} diff --git a/terraform-provider-hpe1810/internal/provider/resource_trunk.go b/terraform-provider-hpe1810/internal/provider/resource_trunk.go new file mode 100644 index 0000000..5af26fe --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/resource_trunk.go @@ -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()) + } +} diff --git a/terraform/main.tf b/terraform/main.tf index 3ed668c..01c4e6a 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -39,6 +39,73 @@ resource "hpe1810_system_description" "draupnir01" { 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_network_setup" "draupnir01" {} data "hpe1810_port_summary" "draupnir01" {}