Now works - more or less fully
This commit is contained in:
1 parent
fb8d468943
commit
92701aab92
13 files changed
+1938
-12
No files matched your search
@@ -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.<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
|
||||
(`HPE1810_SAVE_RUNNING_CONFIG` env fallback, default `false`). If `true`, `Shutdown()`
|
||||
(called once at process exit, same place as the logout below) POSTs to
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 | | |
|
||||
@@ -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,
|
||||
NewPortResource,
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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" {}
|
||||
|
||||
Reference in new issue
Block a user