From a6f10191754412e56c40113f1e2b76ae5ae089b3 Mon Sep 17 00:00:00 2001 From: Joachim Friberg Date: Tue, 25 Aug 2026 18:58:03 +0200 Subject: [PATCH] Read now works, and terraform directory contains example code --- .gitignore | 1 + AGENT.md | 182 ++- CHEATSHEET.md | 12 + TODO.md | 8 +- config.py | 13 + extractors/dual_image_status.py | 24 + extractors/loop_protection_status.py | 20 + extractors/mac_table.py | 25 + extractors/trunk_status.py | 19 + extractors/xe_page.py | 32 +- install-opentofu.sh | 1362 +++++++++++++++++ switch_client.py | 8 +- terraform-provider-hpe1810/.gitignore | 1 + terraform-provider-hpe1810/go.mod | 34 + terraform-provider-hpe1810/go.sum | 168 ++ .../internal/provider/client/client.go | 160 ++ .../provider/client/testdata/FDBSearch.html | 67 + .../provider/client/testdata/PortSummary.html | 434 ++++++ .../client/testdata/SysDescription.html | 118 ++ .../internal/provider/client/xepage.go | 137 ++ .../internal/provider/client/xepage_test.go | 130 ++ .../internal/provider/convert.go | 24 + .../provider/data_source_backup_manager.go | 82 + .../provider/data_source_buffered_log.go | 82 + .../internal/provider/data_source_clock.go | 82 + .../provider/data_source_dual_image_status.go | 82 + .../provider/data_source_lldp_statistics.go | 94 ++ .../provider/data_source_log_configuration.go | 82 + .../data_source_loop_protection_status.go | 82 + .../provider/data_source_mac_table.go | 103 ++ .../provider/data_source_network_setup.go | 82 + .../provider/data_source_port_summary.go | 82 + .../internal/provider/data_source_sntp.go | 82 + .../data_source_system_description.go | 101 ++ .../provider/data_source_trunk_status.go | 82 + .../internal/provider/provider.go | 128 ++ terraform-provider-hpe1810/main.go | 27 + terraform/.gitignore | 4 + terraform/README.md | 42 + terraform/main.tf | 17 + terraform/outputs.tf | 21 + terraform/provider.tf | 20 + terraform/versions.tf | 23 + 43 files changed, 4341 insertions(+), 38 deletions(-) create mode 100644 config.py create mode 100644 extractors/dual_image_status.py create mode 100644 extractors/loop_protection_status.py create mode 100644 extractors/mac_table.py create mode 100644 extractors/trunk_status.py create mode 100755 install-opentofu.sh create mode 100644 terraform-provider-hpe1810/.gitignore create mode 100644 terraform-provider-hpe1810/go.mod create mode 100644 terraform-provider-hpe1810/go.sum create mode 100644 terraform-provider-hpe1810/internal/provider/client/client.go create mode 100644 terraform-provider-hpe1810/internal/provider/client/testdata/FDBSearch.html create mode 100644 terraform-provider-hpe1810/internal/provider/client/testdata/PortSummary.html create mode 100644 terraform-provider-hpe1810/internal/provider/client/testdata/SysDescription.html create mode 100644 terraform-provider-hpe1810/internal/provider/client/xepage.go create mode 100644 terraform-provider-hpe1810/internal/provider/client/xepage_test.go create mode 100644 terraform-provider-hpe1810/internal/provider/convert.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_backup_manager.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_buffered_log.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_clock.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_dual_image_status.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_lldp_statistics.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_log_configuration.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_loop_protection_status.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_mac_table.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_network_setup.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_port_summary.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_sntp.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_system_description.go create mode 100644 terraform-provider-hpe1810/internal/provider/data_source_trunk_status.go create mode 100644 terraform-provider-hpe1810/internal/provider/provider.go create mode 100644 terraform-provider-hpe1810/main.go create mode 100644 terraform/.gitignore create mode 100644 terraform/README.md create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/provider.tf create mode 100644 terraform/versions.tf diff --git a/.gitignore b/.gitignore index 12370ae..18f0c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc .session_cookie +terraform-provider-hpe1810/terraform-provider-hpe1810 diff --git a/AGENT.md b/AGENT.md index fdba2e0..c7e6355 100644 --- a/AGENT.md +++ b/AGENT.md @@ -5,6 +5,9 @@ A Python PoC that scrapes and writes to an HP ProCurve/OfficeConnect 1810 (J9450A) switch's built-in web UI at `192.168.2.10` (no auth configured on the switch). +- `config.py` — connection config from env vars (`SWITCH_HOST`, `SWITCH_HTTPS`, + `SWITCH_PASSWORD`). These three map directly onto what a future Terraform provider's + config block would need per-switch. - `switch_client.py` — session/login (`get_session()`), generic `fetch()` (GET) and `submit_form()` (POST, for writes). - `extractors/xe_page.py` — generic parser for the firmware's auto-generated ("XE") pages; @@ -16,41 +19,166 @@ switch's built-in web UI at `192.168.2.10` (no auth configured on the switch). setting (currently just `locator.py`). - `todo.py` / `TODO.md` — tracks which menu items have a read extractor implemented. -## Long-term goal: become a Terraform module +## Long-term goal: become a Terraform provider (full Go rewrite) -The intended end state is a Terraform provider/module for this switch (and possibly the -pattern generalized to similar switches). Terraform's core flow — as far as understood -right now, exact provider-SDK details not yet researched — is: **read current state → -diff against declared (desired) state → apply only the difference**. Each switch setting -(Locator, System Name, VLAN config, port config, ...) should eventually become a Terraform -resource with that Read/Diff/Apply semantics. +The intended end state is a real Terraform provider for this switch. Researched and +decided: Terraform's plugin protocol requires Go (HashiCorp's Terraform Plugin Framework +is the current recommended SDK — see sources below), and the plan is a **full rewrite in +Go**, not a thin Go shell calling out to this Python code. This Python project stays as-is +as the reference implementation the Go port gets verified against. -This is **not** an active task — no provider scaffolding, no SDK choice, no `ensure()` -helper exists yet. It's noted here so code written in the meantime doesn't need reshaping -later. +**Correction to an earlier assumption**: Terraform **Core** does the diffing itself +(compares saved state vs. declared config via the resource's schema) — the provider does +NOT need its own `ensure()`/diff mechanism. It only implements: +- **Read**: fetch real current state → write into Terraform state. +- **Update**: called by Core only when Core already determined there's a difference. -### What this means for code written now +This still supports the existing guideline below (keep `get_x`/`set_x` separate, `set_x` +idempotent) — just for a different reason than originally assumed: not because the +provider diffs, but as a safety net against config drift (someone changes the switch via +its web UI between `terraform plan` and `apply`). -When adding a new `get_x`/`set_x` pair in `actions/`: +Sources: [Terraform Plugin Framework (pkg.go.dev)](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework), +[Resources — HashiCorp Developer](https://developer.hashicorp.com/terraform/plugin/framework/resources), +[Implement a provider with the Plugin Framework (tutorial)](https://developer.hashicorp.com/terraform/tutorials/providers-plugin-framework/providers-plugin-framework-provider). + +### CLI tooling: OpenTofu, not Terraform + +The user is installing **OpenTofu** (`tofu` CLI) rather than the Terraform CLI. This +doesn't change any of the Go code below — OpenTofu speaks the same plugin protocol as +Terraform, and a provider built with `terraform-plugin-framework`/`terraform-plugin-go` +(there's no separate "OpenTofu SDK") works unmodified against it. Practical differences +only: the CLI binary is `tofu`, and local dev-override config lives in `~/.tofurc` +(env var `TF_CLI_CONFIG_FILE` also works) instead of `~/.terraformrc`. "Terraform +state"/"Terraform Core" below refers to the shared plugin-protocol concepts, which apply +identically under OpenTofu. + +### The plan + +New sibling Go module: `~/code/experiments/terraform-provider-hpe1810/` (separate +toolchain/`go.mod`, not nested in this Python project), scaffolded from HashiCorp's +`terraform-provider-scaffolding-framework` template. + +- `internal/provider/client/client.go` — port of `switch_client.py` (`net/http.Client` + + `net/http/cookiejar` for session cookies; same login POST fields, same GET-before-POST + workaround, same `submit_flag=8` fix in `SubmitForm`). **Simplification vs. Python**: the + provider process lives for the whole `terraform plan`/`apply` run, so the disk-cached + session (`.session_cookie`) mostly goes away — `Configure()` logs in once, the same + client is reused for every resource/data source in that run. The switch's single-session + limit still means don't run two `terraform` processes against it concurrently. +- `internal/provider/client/xepage.go` — port of `extractors/xe_page.py`, using `goquery` + (closest Go equivalent to BeautifulSoup). Same two table shapes (scalar + `defleft`/`defright`, tabular via ``), same duplicate-header caveat as a comment. +- `provider.go` schema maps 1:1 onto `config.py`'s three fields (`host`/`https`/`password`), + with the same env var fallback pattern already established there. +- One data source per current `extractors/*.py` (`system_description`, `network_setup`, + `port_summary`, `lldp_statistics`, `buffered_log`, `mac_table`, `trunk_status`, + `loop_protection_status`, `dual_image_status`, `clock`, `sntp`, `log_configuration`, + `backup_manager`). +- One resource per current `actions/*.py` (just `locator` today) — singleton-setting + resource with a fixed state ID. `Delete` semantics are decided **per resource**, not by + one blanket rule — the guiding question is "what's the safest state for *this* setting + to end up in", which sometimes means actively changing something, not just leaving it or + resetting to a factory default: + - **Locator**: reset to `Disable` — harmless, and leaving a physical LED blinking after + `terraform destroy` would be confusing. + - **Port Configuration**: `Delete` should disable/close the port — an unmanaged port is + a security exposure, so "no longer managed by Terraform" should fail toward *safer* + (closed), not toward "whatever it happened to be". + - **Network Setup** (IP, gateway, SNMP, management VLAN): `Delete` should be a pure + no-op that only drops it from Terraform state — there's no safe "unset" state for + the switch's own management network config; touching it on delete risks the same + lockout the risk triage below already warns about. + Decide this explicitly for each resource as it's built, using this "what does *safe* + mean for this specific setting" question — don't default to a single project-wide rule. +- Testing: unit tests for `xepage.go` against `page_snapshots/*.html` copied into + `testdata/` (already-collected golden fixtures, runnable offline); acceptance tests via + `terraform-plugin-testing`, gated behind `TF_ACC=1`, only runnable against the real + switch. +- Suggested build order: scaffold + `Configure()` → one vertical slice end-to-end + (`data.hpe1810_system_description`) → remaining data sources → `resource_locator` → + further write resources in the risk-triage order below (Safe category first). +- **Status (2026-08-25)**: all 13 data sources are ported and verified with a live + `tofu plan` against the real switch (`~/code/experiments/hpe/terraform/`) — confirmed + port 24 (the active uplink) reads `Link Up`, all other ports `Link Down`. Only + `resource_locator` (and further write resources) remain unbuilt. + +### What this means for code written in this Python project meanwhile + +Even though the Go rewrite won't call this code directly, keep the same shape so the port +stays a straight translation: - `get_x_state(session)` must be a pure read — no side effects, safe to call anytime. - `set_x(session, value)` should be idempotent — calling it again with the same `value` - it already holds should be harmless, not something the caller has to guard against - externally. (Not yet enforced anywhere; `actions/locator.py` always writes regardless of - current state — fine for a manual on/off toggle, but a future idempotent version would - read first and skip the write if already at `value`.) -- Keep `get_x_state` and `set_x` as separate, independently callable functions rather than - a combined "set and forget" call — that split is exactly what a Terraform resource's - Read and Update map onto. + it already holds should be harmless. (Not yet enforced anywhere; `actions/locator.py` + always writes regardless of current state — fine for a manual on/off toggle.) +- Keep `get_x_state` and `set_x` as separate, independently callable functions — that + split is exactly what a Terraform resource's Read and Update map onto. ### Explicit non-goals right now -- No Terraform provider code, scaffolding, or SDK/language decision (Go provider vs. - something else) exists yet. -- No generic `ensure(desired) -> read, diff, apply-if-different` helper exists yet either — - that's a natural next step once more `actions/` modules exist, but shouldn't be built - speculatively ahead of need. +- No Go module has been created yet — this is still just a plan. +- No generic `ensure(desired) -> read, diff, apply-if-different` helper needed in Python + either now, given Core does the diffing in the real provider. -Next concrete step towards this goal, when picked up: look into what a Terraform -provider's Read/Diff/Apply contract actually requires (e.g. Terraform Plugin Framework) -before designing anything here around it. +## Offline snapshots + +`page_snapshots/` holds raw HTML + per-page `_xe_*.js` (field names/types/enums/OIDs) for +every menu page, captured while the switch was reachable, plus `current_state.json` (a +dump of every extractor's output at capture time) and `manifest.json` (HTML/JS file +mapping). Use these to write and offline-verify new `extractors/`/`actions/` modules +(parse the saved HTML directly, no live switch needed) when the switch isn't reachable — +just re-verify against the real switch once it's back online, since a snapshot can go +stale (config changes, firmware updates). + +## Write-target risk triage (for future `actions/` modules) + +Assessed from the snapshotted HTML/JS, not yet attempted live. Categorized by how bad a +mistake would be and how easy it is to recover from. + +**Safe** (cosmetic or trivially reversible, like `actions/locator.py`): +- Green Features (`greenmode.html`) — EEE power-saving toggle, no traffic impact. +- Ping Test (`Ping.html`) — triggers a diagnostic ping, doesn't change config. +- Jumbo Frames (`JumboFrames.html`) — single global MTU toggle, reversible. + +**Moderate** (affects real behavior, recoverable if you're paying attention, but a bad +value has a real consequence — treat these with a real read-before-write / dry-run, not +a blind toggle): +- LLDP Configuration, Loop Protection Config, Port Mirroring (`FDBConfig.html`), + Flow Control (`SwitchConfig.html`), Trunk Configuration/Membership. +- Time Zone / Daylight Saving Time config — wrong values just show wrong time, not + dangerous, but affects log timestamps everywhere. + +**High risk — plan for a lockout, not just "recoverable"**: +- **Port Configuration** (`PortConfiguration.html`): disabling/misconfiguring the port + your management traffic actually flows over locks you out over the network (physical + access to the switch would be needed to recover). +- **VLAN Configuration / VLAN Ports / Participation** (`VlanCreateConfig.html`, + `VLANPortConfig.html`, `VLANPortParticipation.html`): misconfiguring the management + VLAN or tagging is one of the most common ways to lock yourself out of a switch + entirely. +- **Advanced Security / Secure Connection** (`security.html`, `SSLCfg.html`): e.g. + forcing HTTPS-only or an access-control change could cut off the very HTTP session + this project depends on. + +**Dangerous — do not automate without explicit, per-run human confirmation, ever**: +- Reboot Switch (`SystemReset.html`) — drops all connections, however briefly. +- Factory Defaults (`ResetConfigToDefaults.html`) — wipes all configuration. +- Password Manager (`UserAccounts.html`) — setting/losing the admin password on a switch + that currently has none configured is exactly the kind of mistake that requires a + factory reset (or worse) to undo. +- Update Manager / firmware upload (`http_file_download.html`) — a bad image can brick + the switch. +- Dual Image Configuration (`dual_image_cfg.html`) — changes which firmware image boots + next; combined with a reboot this has the same blast radius as a firmware update gone + wrong. +- Save Configuration (`SaveAllChanges.html`) — not destructive by itself (persists + running-config to startup-config, standard practice on this class of switch), but it's + what turns every other write above from "revert on next reboot" into permanent — + never call this automatically as a side effect of something else. + +Recommended order for future write targets, safest-value first: Green Features → Jumbo +Frames → Ping Test → LLDP/Loop Protection/Trunk config → Port Configuration → VLANs. Treat +everything in "Dangerous" as out of scope for automation entirely unless the user +explicitly asks for that specific action, and even then prefer a human-confirmed, +one-off script over a reusable `actions/` module. diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 118dabd..4a2b479 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -17,6 +17,18 @@ export NO_PROXY=192.168.2.10 **Stäng switchens webb-UI i webbläsaren** innan du kör skript — den tillåter bara en inloggad session åt gången. +## Konfiguration (`config.py`) + +Läses från miljövariabler, med defaults för dagens switch: + +```bash +export SWITCH_HOST=192.168.2.10 # default +export SWITCH_HTTPS=false # default; sätt till "true" om switchen kräver HTTPS +export SWITCH_PASSWORD="" # default (ingen); sätt om switchen får ett lösenord +``` + +Bara sätta de du behöver ändra — resten faller tillbaka på nuvarande värden. + ## Köra en extraktor ```bash diff --git a/TODO.md b/TODO.md index 1e7083f..24f7501 100644 --- a/TODO.md +++ b/TODO.md @@ -12,10 +12,10 @@ Regenerate with `python discover.py`. Edit the "Want it?" column by hand; | Log | http://192.168.2.10/BufferedLogs.html | y | yes | | Port Summary | http://192.168.2.10/PortSummary.html | y | yes | | LLDP Statistics | http://192.168.2.10/LLDPStats.html | y | yes | -| Trunk | http://192.168.2.10/TrunkStatus.html | | | -| MAC Table | http://192.168.2.10/FDBSearch.html | | | -| Loop Protection | http://192.168.2.10/LoopProtectionStatus.html | | | -| Dual Image | http://192.168.2.10/dual_image_status.html | | | +| Trunk | http://192.168.2.10/TrunkStatus.html | y | yes (offline, verify live) | +| MAC Table | http://192.168.2.10/FDBSearch.html | y | yes (offline, verify live) | +| Loop Protection | http://192.168.2.10/LoopProtectionStatus.html | y | yes (offline, verify live) | +| Dual Image | http://192.168.2.10/dual_image_status.html | y | yes (offline, verify live) | | Clock | http://192.168.2.10/Clock_Detail.html | y | yes | | Network Setup | http://192.168.2.10/tree1.html#NetworkSetup | y | | | Get Connected | http://192.168.2.10/Network.html | | yes | diff --git a/config.py b/config.py new file mode 100644 index 0000000..4396e5f --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +"""Switch connection config, read from environment variables. + +These three settings (host, https, password) are exactly what a future +Terraform provider config block would need per-switch -- see AGENT.md. +""" + +import os + +SWITCH_HOST = os.environ.get("SWITCH_HOST", "192.168.2.10") +SWITCH_HTTPS = os.environ.get("SWITCH_HTTPS", "false").strip().lower() in ("1", "true", "yes") +SWITCH_PASSWORD = os.environ.get("SWITCH_PASSWORD", "") + +SWITCH_BASE_URL = f"{'https' if SWITCH_HTTPS else 'http'}://{SWITCH_HOST}" diff --git a/extractors/dual_image_status.py b/extractors/dual_image_status.py new file mode 100644 index 0000000..19d5ab9 --- /dev/null +++ b/extractors/dual_image_status.py @@ -0,0 +1,24 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/dual_image_status.html" + + +def get_dual_image_status(session) -> list[dict]: + """Returns one dict per unit (Active image, Next-Active image). + + Note: the page also has a "Dual Image Descriptions" table (per-image + version/description) that uses a header shape (TD.tableHeaderright + + plain TD.defright rows, no defleft labels) our generic scalar/tabular + parser doesn't handle -- skipped for now, not needed for this data.""" + tables = fetch_tables(session, PATH) + return tables["Dual Image Status"]["rows"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_dual_image_status(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/loop_protection_status.py b/extractors/loop_protection_status.py new file mode 100644 index 0000000..711fafc --- /dev/null +++ b/extractors/loop_protection_status.py @@ -0,0 +1,20 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/LoopProtectionStatus.html" + + +def get_loop_protection_status(session) -> list[dict]: + """Returns one dict per port (Interface, Configured Action Taken, + Protection Feature Mode, Tx Mode, counters, Loop Detected).""" + tables = fetch_tables(session, PATH) + return tables["Loop Protection Status"]["rows"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_loop_protection_status(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/mac_table.py b/extractors/mac_table.py new file mode 100644 index 0000000..3e5b4d3 --- /dev/null +++ b/extractors/mac_table.py @@ -0,0 +1,25 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_all_tables + +PATH = "/FDBSearch.html" + + +def get_mac_table(session) -> dict: + """Returns {"summary": {...}, "entries": [...]}. + + The per-entry table on this page has no (just an empty one), + so it can't be looked up by caption like other pages -- fetch_all_tables() + + table id "1_1" is used instead.""" + tables = fetch_all_tables(session, PATH) + summary = next(t["record"] for t in tables if t.get("caption") == "MAC Table") + entries = next(t["rows"] for t in tables if t.get("id") == "1_1") + return {"summary": summary, "entries": entries} + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_mac_table(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/trunk_status.py b/extractors/trunk_status.py new file mode 100644 index 0000000..76d82fb --- /dev/null +++ b/extractors/trunk_status.py @@ -0,0 +1,19 @@ +import json + +import switch_client +import todo +from extractors.xe_page import fetch_tables + +PATH = "/TrunkStatus.html" + + +def get_trunk_status(session) -> list[dict]: + """Returns one dict per configured trunk. Empty list if no trunks exist.""" + tables = fetch_tables(session, PATH) + return tables["Trunk Status"]["rows"] + + +if __name__ == "__main__": + session = switch_client.get_session() + print(json.dumps(get_trunk_status(session), indent=2)) + todo.mark_implemented(switch_client.BASE_URL + PATH) diff --git a/extractors/xe_page.py b/extractors/xe_page.py index 138d2e9..f497c8c 100644 --- a/extractors/xe_page.py +++ b/extractors/xe_page.py @@ -28,19 +28,27 @@ def extract_cell_value(td) -> str: def parse_table(table) -> dict: + table_id = table.get("id") caption_tag = table.find("caption") caption = caption_tag.get_text(strip=True) if caption_tag else None headers = [th.get_text(strip=True) for th in table.find_all("th", recursive=False)] trs = table.find_all("tr", recursive=False) if headers: + # Some pages repeat a label across a hidden raw-value column and a + # visible display column (e.g. PortSummary's/MAC Table's two + # "Interface"/"MAC Address" headers). Building the row dict via zip() + # means the later (visible) column wins on a duplicate label -- that + # has matched the raw column's value in every case seen so far, but + # if a future page's hidden/visible pair ever genuinely differs, + # only the visible one survives here. rows = [] for tr in trs: tds = tr.find_all("td", recursive=False) if len(tds) != len(headers): continue rows.append({h: extract_cell_value(td) for h, td in zip(headers, tds)}) - return {"caption": caption, "kind": "tabular", "rows": rows} + return {"caption": caption, "kind": "tabular", "rows": rows, "id": table_id} record = {} field_names = {} @@ -58,7 +66,13 @@ def parse_table(table) -> dict: field_names[label] = inp["name"] # field_names lets a write action look up the real for a # label instead of hardcoding a guessed "v_R_C_1"-style field name. - return {"caption": caption, "kind": "scalar", "record": record, "field_names": field_names} + return { + "caption": caption, + "kind": "scalar", + "record": record, + "field_names": field_names, + "id": table_id, + } def parse_xe_tables(html: str) -> list[dict]: @@ -69,8 +83,18 @@ def parse_xe_tables(html: str) -> list[dict]: def fetch_tables(session, path: str) -> dict[str, dict]: """Fetch path and return {caption: table} for every captioned table. - Tables with no/blank caption (usually just a submit button row) are - dropped since there's nothing useful to key them by.""" + Tables with no/blank caption (usually just a submit button row, but + occasionally a real data table -- e.g. FDBSearch.html's MAC entries + table has no caption) are dropped since there's nothing useful to key + them by. Use fetch_all_tables() + table["id"] for those instead.""" html = switch_client.fetch(session, path) tables = parse_xe_tables(html) return {t["caption"]: t for t in tables if t["caption"]} + + +def fetch_all_tables(session, path: str) -> list[dict]: + """Fetch path and return every table, including uncaptioned ones, in + document order. Look up by table["id"] (the attribute) + when a table has no caption to key it by.""" + html = switch_client.fetch(session, path) + return parse_xe_tables(html) diff --git a/install-opentofu.sh b/install-opentofu.sh new file mode 100755 index 0000000..1ea2a04 --- /dev/null +++ b/install-opentofu.sh @@ -0,0 +1,1362 @@ +#!/bin/sh +# Copyright (c) The OpenTofu Authors +# SPDX-License-Identifier: Apache-2.0 + +# OpenTofu Installer +# +# This script installs OpenTofu via any of the supported methods. +# License: https://github.com/opentofu/get.opentofu.org/blob/main/LICENSE + +export TOFU_INSTALL_EXIT_CODE_OK=0 +export TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET=1 +export TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED=2 +export TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT=3 + +export TOFU_INSTALL_RETURN_CODE_COMMAND_NOT_FOUND=11 +export TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED=13 + +bold="" +normal="" +red="" +green="" +yellow="" +blue="" +magenta="" +cyan="" +gray="" +if [ -t 1 ]; then + if command -v "tput" >/dev/null 2>&1; then + colors=$(tput colors) + else + colors=2 + fi + + if [ "${colors}" -ge 8 ]; then + bold="$(tput bold)" + normal="$(tput sgr0)" + red="$(tput setaf 1)" + green="$(tput setaf 2)" + yellow="$(tput setaf 3)" + blue="$(tput setaf 4)" + magenta="$(tput setaf 5)" + cyan="$(tput setaf 6)" + gray="$(tput setaf 245)" + fi +fi + +ROOT_METHOD="auto" +INSTALL_METHOD="" +DEFAULT_INSTALL_PATH="/opt/opentofu" +INSTALL_PATH="${DEFAULT_INSTALL_PATH}" +DEFAULT_SYMLINK_PATH="/usr/local/bin" +SYMLINK_PATH="${DEFAULT_SYMLINK_PATH}" +DEFAULT_OPENTOFU_VERSION="latest" +OPENTOFU_VERSION="${DEFAULT_OPENTOFU_VERSION}" +DEFAULT_DEB_GPG_URL="https://get.opentofu.org/opentofu.gpg" +DEB_GPG_URL="${DEFAULT_DEB_GPG_URL}" +DEFAULT_DEB_REPO_GPG_URL="https://packages.opentofu.org/opentofu/tofu/gpgkey" +DEB_REPO_GPG_URL="${DEFAULT_DEB_REPO_GPG_URL}" +DEFAULT_DEB_REPO_URL="https://packages.opentofu.org/opentofu/tofu/any/" +DEB_REPO_URL="${DEFAULT_DEB_REPO_URL}" +DEFAULT_DEB_REPO_SUITE="any" +DEB_REPO_SUITE="${DEFAULT_DEB_REPO_SUITE}" +DEFAULT_DEB_REPO_COMPONENTS="main" +DEB_REPO_COMPONENTS="${DEFAULT_DEB_REPO_COMPONENTS}" +DEFAULT_RPM_REPO_URL="https://packages.opentofu.org/opentofu/tofu/rpm_any/rpm_any/" + +RPM_REPO_URL="${DEFAULT_RPM_REPO_URL}" +DEFAULT_RPM_REPO_GPG_URL="https://packages.opentofu.org/opentofu/tofu/gpgkey" +DEFAULT_RPM_GPG_URL="https://get.opentofu.org/opentofu.asc" +RPM_GPG_URL="${DEFAULT_RPM_GPG_URL}" +RPM_REPO_GPG_URL="${DEFAULT_RPM_REPO_GPG_URL}" +DEFAULT_APK_REPO_URL="@community https://dl-cdn.alpinelinux.org/alpine/edge/community" +APK_REPO_URL="${DEFAULT_APK_REPO_URL}" +DEFAULT_APK_PACKAGE="opentofu@community" +APK_PACKAGE="${DEFAULT_APK_PACKAGE}" +DEFAULT_GPG_PATH="gpg" +GPG_PATH="${DEFAULT_GPG_PATH}" +DEFAULT_GPG_URL="https://get.opentofu.org/opentofu.asc" +GPG_URL="https://get.opentofu.org/opentofu.asc" +DEFAULT_GPG_KEY_ID="E3E6E43D84CB852EADB0051D0C0AF313E5FD9F80" +GPG_KEY_ID="${DEFAULT_GPG_KEY_ID}" +DEFAULT_COSIGN_PATH="cosign" +COSIGN_PATH="${DEFAULT_COSIGN_PATH}" +DEFAULT_COSIGN_IDENTITY="autodetect" +COSIGN_IDENTITY="${DEFAULT_COSIGN_IDENTITY}" +DEFAULT_COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com" +COSIGN_OIDC_ISSUER="${DEFAULT_COSIGN_OIDC_ISSUER}" +SKIP_VERIFY=0 + +# region ZSH +if [ -n "${ZSH_VERSION}" ]; then + ## Enable POSIX-style word splitting: + setopt SH_WORD_SPLIT >/dev/null 2>&1 +fi +# endregion + +log_success() { + if [ -z "$1" ]; then + return + fi + echo "${green}$1${normal}" 1>&2 +} + +log_warning() { + if [ -z "$1" ]; then + return + fi + echo "${yellow}$1${normal}" 1>&2 +} + +log_info() { + if [ -z "$1" ]; then + return + fi + echo "${cyan}$1${normal}" 1>&2 +} + +log_debug() { + if [ -z "$1" ]; then + return + fi + if [ -z "${LOG_DEBUG}" ]; then + return + fi + echo "${gray}$1${normal}" 1>&2 +} + +log_error() { + if [ -z "$1" ]; then + return + fi + echo "${red}$1${normal}" 1>&2 +} + +# This function checks if the command specified in $1 exists. +command_exists() { + log_debug "Determining if the ${1} command is available..." + if [ -z "$1" ]; then + log_error "Bug: no command supplied to command_exists()" + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if ! command -v "$1" >/dev/null 2>&1; then + log_debug "The ${1} command is not available." + return "${TOFU_INSTALL_RETURN_CODE_COMMAND_NOT_FOUND}" + fi + log_debug "The ${1} command is available." + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +is_root() { + if [ "$(id -u || true)" -eq 0 ]; then + return 0 + fi + return 1 +} + +# This function runs the specified command as root. +as_root() { + # shellcheck disable=SC2145 + log_debug "Running command as root: $*" + case "${ROOT_METHOD}" in + auto) + log_debug "Automatically determining root method..." + if is_root; then + log_debug "We are already root, no user change needed." + "$@" + elif command_exists "sudo"; then + log_debug "Running command using sudo." + sudo "$@" + elif command_exists "su"; then + log_debug "Running command using su." + su root "$@" + else + log_error "Neither su nor sudo is installed, cannot obtain root privileges." + return "${TOFU_INSTALL_RETURN_CODE_COMMAND_NOT_FOUND}" + fi + return $? + ;; + none) + log_debug "Using manual root method 'none'." + "$@" + return $? + ;; + sudo) + log_debug "Using manual root method 'sudo'." + sudo "$@" + return $? + ;; + su) + log_debug "Using manual root method 'su'." + su root "$@" + return $? + ;; + *) + log_error "Bug: invalid root method value: $1" + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + esac +} + +# This function attempts to execute a function as the current user and switches to root if it fails. +maybe_root() { + if ! "$@" >/dev/null 2>&1; then + if ! as_root "$@"; then + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +# This function verifies if one of the supported download tools is installed and returns with +# $TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET if that is not th ecase. +download_tool_exists() { + log_debug "Determining if a supported download tool is installed..." + if command_exists "wget"; then + log_debug "wget is installed." + return "${TOFU_INSTALL_EXIT_CODE_OK}" + elif command_exists "curl"; then + log_debug "curl is installed." + return "${TOFU_INSTALL_EXIT_CODE_OK}" + else + log_debug "No supported download tool is installed." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi +} + +# This function downloads the URL specified in $1 into the file specified in $2. +# It returns $TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET if no supported download tool is installed, or $TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED +# if the download failed. +download_file() { + if [ -z "$1" ]; then + log_error "Bug: no URL supplied to download_file()" + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if [ -z "$2" ]; then + log_error "Bug: no destination file supplied to download_file()" + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + log_debug "Downloading URL ${1} to ${2}..." + IS_GITHUB=0 + if [ -n "${GITHUB_TOKEN}" ]; then + if [ "$(echo "$1" | grep -c "api.github.com" || true)" -ne 0 ]; then + IS_GITHUB=1 + fi + fi + if command_exists "wget"; then + if [ "${IS_GITHUB}" -eq 1 ]; then + log_debug "Downloading using wget with GITHUB_TOKEN..." + if ! wget -q --header="Authorization: token ${GITHUB_TOKEN}" -O "$2" "$1"; then + log_debug "Download failed." + return "${TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED}" + fi + else + log_debug "Downloading using wget without GITHUB_TOKEN, this may lead to rate limit issues..." + if ! wget -q -O "$2" "$1"; then + log_debug "Download failed, please try specifying the GITHUB_TOKEN environment variable." + return "${TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED}" + fi + fi + elif command_exists "curl"; then + if [ "${IS_GITHUB}" -eq 1 ]; then + log_debug "Downloading using curl with GITHUB_TOKEN..." + if ! curl --proto '=https' --tlsv1.2 -fsSL -H "Authorization: token ${GITHUB_TOKEN}" -o "$2" "$1"; then + log_debug "Download failed." + return "${TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED}" + fi + else + log_debug "Downloading using curl without GITHUB_TOKEN, this may lead to rate limit issues..." + if ! curl --proto '=https' --tlsv1.2 -fsSL -o "$2" "$1"; then + log_debug "Download failed, please try specifying the GITHUB_TOKEN environment variable." + return "${TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED}" + fi + fi + else + log_error "Neither wget nor curl are available on your system. Please install one of them to proceed." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi + log_debug "Download successful." + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +# This function downloads the OpenTofu GPG key from the specified URL to the specified location. Setting the third +# parameter to 1 causes the file to be moved as root. It returns $TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED if the +# download fails, or $TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET if no download tool is available. +download_gpg() { + if [ -z "$1" ]; then + log_error "Bug: no URL passed to download_gpg." + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if [ -z "$2" ]; then + log_error "Bug: no destination passed to download_gpg." + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if ! command_exists "gpg"; then + log_error "Missing gpg binary." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi + log_debug "Downloading GPG key from ${1} to ${2}..." + if ! download_tool_exists; then + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi + log_debug "Creating temporary directory..." + TEMPDIR=$(mktemp -d) + if [ -z "${TEMPDIR}" ]; then + log_error "Failed to create temporary directory for GPG download." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + TEMPFILE="${TEMPDIR}/opentofu.gpg" + + if ! download_file "${1}" "${TEMPFILE}"; then + log_debug "Removing temporary directory..." + rm -rf "${TEMPFILE}" + return "${TOFU_INSTALL_RETURN_CODE_DOWNLOAD_FAILED}" + fi + if [ "$(grep 'BEGIN PGP PUBLIC KEY BLOCK' -c "${TEMPFILE}" || true)" -ne 0 ]; then + log_debug "Performing GPG dearmor on ${TEMPFILE}" + if ! gpg --no-tty --batch --dearmor -o "${TEMPFILE}.tmp" <"${TEMPFILE}"; then + log_error "Failed to GPG dearmor ${TEMPFILE}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + if ! mv "${TEMPFILE}.tmp" "${TEMPFILE}"; then + log_error "Failed to move ${TEMPFILE}.tmp to ${TEMPFILE}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + if [ "$3" = "1" ]; then + log_debug "Moving GPG file as root..." + if ! as_root mv "${TEMPFILE}" "${2}"; then + log_error "Failed to move ${TEMPFILE} to ${2}." + rm -rf "${TEMPFILE}" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + else + log_debug "Moving GPG file as the current user..." + if ! mv "${TEMPFILE}" "${2}"; then + log_error "Failed to move ${TEMPFILE} to ${2}." + rm -rf "${TEMPFILE}" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + + log_debug "Removing temporary directory..." + rm -rf "${TEMPFILE}" + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +# This is a helper function that downloads a GPG URL to the specified file. +deb_download_gpg() { + DEB_GPG_URL="${1}" + GPG_FILE="${2}" + if [ -z "${DEB_GPG_URL}" ]; then + log_error "Bug: no GPG URL specified for deb_download_gpg." + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if [ -z "${GPG_FILE}" ]; then + log_error "Bug: no destination path specified for deb_download_gpg." + return "${TOFU_INSTALL_EXIT_CODE_INVALID_ARGUMENT}" + fi + if ! download_gpg "${DEB_GPG_URL}" "${GPG_FILE}" 1; then + log_error "Failed to download GPG key from ${DEB_GPG_URL}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + log_debug "Changing ownership and permissions of ${GPG_FILE}..." + if ! as_root chown root:root "${GPG_FILE}"; then + log_error "Failed to chown ${GPG_FILE}." + rm -rf "${GPG_FILE}" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + if ! as_root chmod a+r "${GPG_FILE}"; then + log_error "Failed to chmod ${GPG_FILE}." + rm -rf "${GPG_FILE}" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +# This function installs OpenTofu via a Debian repository. It returns +# $TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET if this is not a Debian system. +install_deb() { + log_info "Attempting installation via Debian repository..." + if ! command_exists apt-get; then + log_info "The apt-get command is not available, skipping Debian repository installation." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi + + if ! is_root; then + log_info "Root privileges are required to install OpenTofu as a Debian package." + log_info "The installer will now verify if it can correctly assume root privileges." + log_info "${bold}You may be asked to enter your password.${normal}" + if ! as_root echo -n ""; then + log_error "Cannot assume root privileges." + log_info "Please set up either '${bold}su${normal}' or '${bold}sudo${normal}'." + log_info "Alternatively, run this script with ${bold}-h${normal} for other installation methods." + fi + fi + + log_info "Updating package list..." + if ! as_root apt-get update; then + log_error "Failed to update apt package list." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + + log_debug "Determining packages to install..." + PACKAGE_LIST="apt-transport-https ca-certificates" + if [ "${SKIP_VERIFY}" -ne "1" ]; then + PACKAGE_LIST="${PACKAGE_LIST} gnupg" + fi + if ! download_tool_exists; then + log_debug "No download tool present, adding curl to the package list..." + PACKAGE_LIST="${PACKAGE_LIST} curl" + fi + + log_info "Installing necessary packages for installation..." + log_debug "Installing ${PACKAGE_LIST}..." + # shellcheck disable=SC2086 + if ! as_root apt-get install -y ${PACKAGE_LIST}; then + log_error "Failed to install requisite packages for Debian repository installation." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + log_debug "Necessary packages installed." + + if [ "${SKIP_VERIFY}" -ne "1" ]; then + log_info "Installing the OpenTofu GPG keys..." + log_debug "Creating /etc/apt/keyrings..." + if ! as_root install -m 0755 -d /etc/apt/keyrings; then + log_error "Failed to create /etc/apt/keyrings." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + log_debug "Created /etc/apt/keyrings." + + PACKAGE_GPG_FILE=/etc/apt/keyrings/opentofu.gpg + log_debug "Downloading the GPG key from ${DEB_GPG_URL}.." + if ! deb_download_gpg "${DEB_GPG_URL}" "${PACKAGE_GPG_FILE}"; then + log_error "Failed to download GPG key from ${DEB_GPG_URL}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + if [ -n "${DEB_REPO_GPG_URL}" ] && [ "${DEB_REPO_GPG_URL}" != "-" ]; then + log_debug "Downloading the repo GPG key from ${DEB_REPO_GPG_URL}.." + REPO_GPG_FILE=/etc/apt/keyrings/opentofu-repo.gpg + if ! deb_download_gpg "${DEB_REPO_GPG_URL}" "${REPO_GPG_FILE}" 1; then + log_error "Failed to download GPG key from ${DEB_REPO_GPG_URL}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + fi + + log_info "Creating OpenTofu sources list..." + if [ "${SKIP_VERIFY}" -ne "1" ]; then + if [ -n "${REPO_GPG_FILE}" ]; then + if ! as_root tee /etc/apt/sources.list.d/opentofu.list; then + log_error "Failed to create /etc/apt/sources.list.d/opentofu.list." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi < /dev/null; then + log_error "Failed to run tofu after installation." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +# This function installs OpenTofu via the zypper command line utility. It returns +# $TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET if zypper is not available. +install_zypper() { + if ! command_exists "zypper"; then + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_REQUIREMENTS_NOT_MET}" + fi + log_info "Installing OpenTofu using zypper..." + if [ "${SKIP_VERIFY}" -ne "1" ]; then + GPGCHECK=1 + FINAL_GPG_URL="${RPM_GPG_URL}" + if [ "${RPM_REPO_GPG_URL}" != "-" ]; then + FINAL_GPG_URL=$(cat <&1 | grep "Primary key fingerprint: " | sed -e 's/^Primary key fingerprint: //' -e 's/ //g') + if [ "${FINGERPRINT}" != "${GPG_KEY_ID}" ]; then + log_error "The release is signed with the incorrect key: ${FINGERPRINT}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + log_info "Signature verified." + elif [ "${VERIFY_METHOD}" != "-" ]; then + log_error "Bug: unsupported verification method: ${VERIFY_METHOD}" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + + log_info "Unpacking OpenTofu..." + if ! unzip -d "${ZIPDIR}" "${TEMPDIR}/${ZIPFILE}"; then + log_error "Failed to unzip ${TEMPDIR}/${ZIPFILE} to /" + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + + log_info "Moving OpenTofu installation to ${INSTALL_PATH}..." + if ! maybe_root mkdir -p "${INSTALL_PATH}"; then + log_error "Cannot create installation path at ${INSTALL_PATH}." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + + if ! maybe_root mv "${ZIPDIR}"/* "${INSTALL_PATH}" >/dev/null 2>&1; then + log_error "Cannot move ${ZIPDIR} contents to ${INSTALL_PATH}. Please check the permissions on the target directory." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + + if [ "${SYMLINK_PATH}" != "-" ]; then + log_info "Creating tofu symlink at ${SYMLINK_PATH}/tofu..." + if ! maybe_root ln -sf "${INSTALL_PATH}/tofu" "${SYMLINK_PATH}/tofu"; then + log_error "Failed to create symlink at ${INSTALL_PATH}/tofu." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + log_info "Checking if OpenTofu is installed correctly..." + if [ "${SYMLINK_PATH}" != "-" ]; then + if ! "${SYMLINK_PATH}/tofu" --version; then + log_error "Failed to run ${SYMLINK_PATH}/tofu after installation." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + else + if ! "${INSTALL_PATH}/tofu" --version; then + log_error "Failed to run ${INSTALL_PATH}/tofu after installation." + return "${TOFU_INSTALL_EXIT_CODE_INSTALL_FAILED}" + fi + fi + log_success "Installation complete." + return "${TOFU_INSTALL_EXIT_CODE_OK}" +} + +usage() { + if [ -n "$1" ]; then + log_error "Error: $1" + fi + cat < requests.Session: +def login(password: str = SWITCH_PASSWORD, session: requests.Session | None = None) -> requests.Session: """POST to /hp_login.html, return a Session with the SID cookie set.""" session = session or requests.Session() # A bare POST occasionally comes back without setting SID (the switch @@ -58,7 +60,7 @@ def _cache_session(session: requests.Session) -> None: f.write(sid) -def get_session(password: str = "", force_login: bool = False) -> requests.Session: +def get_session(password: str = SWITCH_PASSWORD, force_login: bool = False) -> requests.Session: """Reuse a still-valid cached session if one exists, otherwise log in. The switch only allows one active web session at a time and briefly diff --git a/terraform-provider-hpe1810/.gitignore b/terraform-provider-hpe1810/.gitignore new file mode 100644 index 0000000..3c73a46 --- /dev/null +++ b/terraform-provider-hpe1810/.gitignore @@ -0,0 +1 @@ +/terraform-provider-hpe1810 diff --git a/terraform-provider-hpe1810/go.mod b/terraform-provider-hpe1810/go.mod new file mode 100644 index 0000000..09e5c37 --- /dev/null +++ b/terraform-provider-hpe1810/go.mod @@ -0,0 +1,34 @@ +module terraform-provider-hpe1810 + +go 1.25.0 + +require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/hashicorp/terraform-plugin-framework v1.19.0 +) + +require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/terraform-provider-hpe1810/go.sum b/terraform-provider-hpe1810/go.sum new file mode 100644 index 0000000..4566c67 --- /dev/null +++ b/terraform-provider-hpe1810/go.sum @@ -0,0 +1,168 @@ +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform-provider-hpe1810/internal/provider/client/client.go b/terraform-provider-hpe1810/internal/provider/client/client.go new file mode 100644 index 0000000..6a1c032 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/client.go @@ -0,0 +1,160 @@ +// Package client is a Go port of the reference Python implementation's +// switch_client.py (see ~/code/experiments/hpe/switch_client.py). +package client + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" +) + +type Client struct { + BaseURL string + httpClient *http.Client + password string +} + +func NewClient(host string, https bool, password string) (*Client, error) { + scheme := "http" + if https { + scheme = "https" + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("creating cookie jar: %w", err) + } + return &Client{ + BaseURL: fmt.Sprintf("%s://%s", scheme, host), + httpClient: &http.Client{Jar: jar}, + password: password, + }, nil +} + +func (c *Client) resolveURL(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if strings.HasPrefix(path, "/") { + return c.BaseURL + path + } + return c.BaseURL + "/" + path +} + +// Login POSTs to /hp_login.html and establishes the session cookie. +// +// A bare POST occasionally comes back without setting SID (the switch seems +// to want a session cookie already present, even an empty one, before it +// will issue a real one) -- a GET first makes login reliable. +// +// Unlike the Python version, there's no on-disk session cache here: this +// provider process lives for the whole plan/apply run, so Configure() logs +// in once and every resource/data source reuses the same *Client. +func (c *Client) Login() error { + if _, err := c.httpClient.Get(c.resolveURL("/")); err != nil { + return fmt.Errorf("priming session: %w", err) + } + + form := url.Values{ + "pwd": {c.password}, + "login": {"Login"}, + "err_flag": {""}, + "err_msg": {""}, + } + resp, err := c.httpClient.PostForm(c.resolveURL("/hp_login.html"), form) + if err != nil { + return fmt.Errorf("login request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading login response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("login request returned status %d", resp.StatusCode) + } + if strings.Contains(strings.ToLower(string(body)), "login") { + snippet := string(body) + if len(snippet) > 500 { + snippet = snippet[:500] + } + return fmt.Errorf("login failed, response still looks like the login page: %q", snippet) + } + return nil +} + +// Fetch GETs BaseURL+path (path may be relative or absolute) and returns the body. +func (c *Client) Fetch(path string) (string, error) { + resp, err := c.httpClient.Get(c.resolveURL(path)) + if err != nil { + return "", fmt.Errorf("fetching %s: %w", path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response for %s: %w", path, err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetching %s returned status %d", path, resp.StatusCode) + } + return string(body), nil +} + +var errFlagRejectedRe = regexp.MustCompile(`(?i)name="err_flag"[^>]*value="1"`) +var errMsgRe = regexp.MustCompile(`(?i)name="err_msg"[^>]*value="([^"]*)"`) + +// SubmitForm POSTs fields to path, merged with the firmware's standard +// bookkeeping fields (submit_flag, submit_target, err_flag, err_msg, +// clazz_information), and returns the response body. +// +// Returns an error if the response's err_flag comes back "1" -- the +// firmware's own validation-failure signal. NOTE: most value validation in +// this firmware happens client-side in JS, not on the server -- an invalid +// enum value, for example, is just silently ignored (state left unchanged) +// rather than reported via err_flag. So this check catches *some* rejected +// writes, but a caller that needs certainty should read the value back +// afterwards and compare, rather than trusting the absence of an error. +func (c *Client) SubmitForm(path string, fields map[string]string) (string, error) { + target := strings.TrimPrefix(path, "/") + form := url.Values{ + // 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js); + // 1 is xui_operation_reload and silently applies nothing. + "submit_flag": {"8"}, + "submit_target": {target}, + "err_flag": {"0"}, + "err_msg": {""}, + "clazz_information": {target}, + } + for k, v := range fields { + form.Set(k, v) + } + + resp, err := c.httpClient.PostForm(c.resolveURL(path), form) + if err != nil { + return "", fmt.Errorf("submitting form to %s: %w", path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response for %s: %w", path, err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("submitting form to %s returned status %d", path, resp.StatusCode) + } + + bodyStr := string(body) + if errFlagRejectedRe.MatchString(bodyStr) { + msg := "unknown error" + if m := errMsgRe.FindStringSubmatch(bodyStr); m != nil { + msg = m[1] + } + return "", fmt.Errorf("switch rejected write to %q: %s", path, msg) + } + return bodyStr, nil +} diff --git a/terraform-provider-hpe1810/internal/provider/client/testdata/FDBSearch.html b/terraform-provider-hpe1810/internal/provider/client/testdata/FDBSearch.html new file mode 100644 index 0000000..333fe2d --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/testdata/FDBSearch.html @@ -0,0 +1,67 @@ + + + + + + +HP ProCurve Forwarding Database Search + + + + + + + + +
+
+ + +
+ + + + + + + + +
MAC Table
Maximum Entries Supported
Current Entries

+
+ + + + + + + + + + + + + +
MAC AddressVLANSource PortMAC Type
4C:EA:41:63:04:1B15Learned
B4:99:BA:DA:3A:201CPUManagement

+ + + + + + + +
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/terraform-provider-hpe1810/internal/provider/client/testdata/PortSummary.html b/terraform-provider-hpe1810/internal/provider/client/testdata/PortSummary.html new file mode 100644 index 0000000..adab03a --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/testdata/PortSummary.html @@ -0,0 +1,434 @@ + + + + + + +HP ProCurve Port Summary + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Port Summary
InterfacePhysical TypePort StatusAutoNeg StatusLink SpeedMTU

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Port Statistics
InterfaceReceived Packets
w/o Error
Received Packets
with Error
Broadcast
Received Packets
Transmitted Packets
w/o Errors
Transmitted Packets
with Errors
CollisionsTransmitted
Pause Frames
Received
Pause Frames
111338079119180000
200000000
300000000
400000000
5483202753900000
600000000
700000000
800000000
900000000
1000000000
1100000000
1200000000
1300000000
1400000000
1500000000
1600000000
1700000000
1800000000
1900000000
2000000000
2100000000
2200000000
2300000000
2400000000

+
+ + + + + + + + + + + +
Trunk Statistics
TrunkReceived Packets
w/o Error
Received Packets
with Error
Broadcast
Received Packets
Transmitted Packets
w/o Errors
Transmitted Packets
with Errors
Collisions

+ +
+ +

+ + + + + + + + +
+
+ + + + +
+
+
+
+
+ + \ No newline at end of file diff --git a/terraform-provider-hpe1810/internal/provider/client/testdata/SysDescription.html b/terraform-provider-hpe1810/internal/provider/client/testdata/SysDescription.html new file mode 100644 index 0000000..d1aca21 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/testdata/SysDescription.html @@ -0,0 +1,118 @@ + + + + + + +HP ProCurve + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
System Description
System Description
System Name
System Location
System Contact
Software Version
System Object ID
System Up Time
Current Time
Date

+ + +
+

+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ + \ No newline at end of file diff --git a/terraform-provider-hpe1810/internal/provider/client/xepage.go b/terraform-provider-hpe1810/internal/provider/client/xepage.go new file mode 100644 index 0000000..82a89bd --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/xepage.go @@ -0,0 +1,137 @@ +// Port of ~/code/experiments/hpe/extractors/xe_page.py -- see that file's +// docstring for the two table shapes this parses. +// +// Note on traversal: the switch firmware's HTML puts / as direct +// children of with no wrapper. A strict browser-grade HTML5 +// parser (which golang.org/x/net/html, underlying goquery, implements) would +// normally insert a synthetic around those rows, which would break a +// "direct children only" traversal the way the Python version does (Python's +// html.parser is lenient and doesn't insert one). We sidestep this by using +// Find() (any descendant) instead of direct-children selectors -- safe here +// because tables in this firmware are never nested inside one another. +package client + +import ( + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type Table struct { + Caption string + Kind string // "scalar" | "tabular" + Record map[string]string + FieldNames map[string]string + Rows []map[string]string + ID string +} + +func extractCellValue(td *goquery.Selection) string { + if inp := td.Find("input").First(); inp.Length() > 0 { + if v, exists := inp.Attr("value"); exists { + return v + } + return "" + } + return strings.TrimSpace(td.Text()) +} + +func parseTable(table *goquery.Selection) Table { + id, _ := table.Attr("id") + caption := strings.TrimSpace(table.Find("caption").First().Text()) + + var headers []string + table.Find("th").Each(func(_ int, th *goquery.Selection) { + headers = append(headers, strings.TrimSpace(th.Text())) + }) + + if len(headers) > 0 { + var rows []map[string]string + table.Find("tr").Each(func(_ int, tr *goquery.Selection) { + tds := tr.Find("td") + if tds.Length() != len(headers) { + return + } + row := make(map[string]string, len(headers)) + tds.Each(func(i int, td *goquery.Selection) { + // Later (visible) column wins on a duplicate header label -- + // see the same caveat noted in extractors/xe_page.py. + row[headers[i]] = extractCellValue(td) + }) + rows = append(rows, row) + }) + return Table{Caption: caption, Kind: "tabular", Rows: rows, ID: id} + } + + record := make(map[string]string) + fieldNames := make(map[string]string) + table.Find("tr").Each(func(_ int, tr *goquery.Selection) { + defleft := tr.Find("td.defleft").First() + defright := tr.Find("td.defright").First() + if defleft.Length() == 0 || defright.Length() == 0 { + return + } + label := strings.TrimSpace(defleft.Text()) + if label == "" { + return + } + record[label] = extractCellValue(defright) + if inp := defright.Find("input").First(); inp.Length() > 0 { + if name, exists := inp.Attr("name"); exists && name != "" { + fieldNames[label] = name + } + } + }) + + return Table{ + Caption: caption, + Kind: "scalar", + Record: record, + FieldNames: fieldNames, + ID: id, + } +} + +// ParseXETables parses every
in html, in document order. +func ParseXETables(html string) ([]Table, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + var tables []Table + doc.Find("table").Each(func(_ int, table *goquery.Selection) { + tables = append(tables, parseTable(table)) + }) + return tables, nil +} + +// FetchTables fetches path and returns {caption: table} for every captioned +// table. Tables with no/blank caption are dropped -- use FetchAllTables + +// Table.ID for those (e.g. the MAC Table entries table on FDBSearch.html). +func (c *Client) FetchTables(path string) (map[string]Table, error) { + html, err := c.Fetch(path) + if err != nil { + return nil, err + } + tables, err := ParseXETables(html) + if err != nil { + return nil, err + } + result := make(map[string]Table, len(tables)) + for _, t := range tables { + if t.Caption != "" { + result[t.Caption] = t + } + } + return result, nil +} + +// FetchAllTables fetches path and returns every table, including +// uncaptioned ones, in document order. +func (c *Client) FetchAllTables(path string) ([]Table, error) { + html, err := c.Fetch(path) + if err != nil { + return nil, err + } + return ParseXETables(html) +} diff --git a/terraform-provider-hpe1810/internal/provider/client/xepage_test.go b/terraform-provider-hpe1810/internal/provider/client/xepage_test.go new file mode 100644 index 0000000..83875e1 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/client/xepage_test.go @@ -0,0 +1,130 @@ +package client + +import ( + "os" + "testing" +) + +func loadFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("reading fixture %s: %v", name, err) + } + return string(data) +} + +// Expected values below were captured live from the real switch (see +// ~/code/experiments/hpe/page_snapshots/current_state.json) at the time +// these fixtures were saved -- this test is about the parser's correctness, +// not about the switch's current state. + +func TestParseXETables_ScalarShape_SysDescription(t *testing.T) { + tables, err := ParseXETables(loadFixture(t, "SysDescription.html")) + if err != nil { + t.Fatalf("ParseXETables: %v", err) + } + + var sysDesc *Table + for i := range tables { + if tables[i].Caption == "System Description" { + sysDesc = &tables[i] + } + } + if sysDesc == nil { + t.Fatalf("no table captioned %q found; got captions: %v", "System Description", captions(tables)) + } + if sysDesc.Kind != "scalar" { + t.Errorf("Kind = %q, want %q", sysDesc.Kind, "scalar") + } + + want := map[string]string{ + "System Name": "Draupnir01", + "System Location": "Lab", + "Software Version": "P.2.24", + } + for label, wantValue := range want { + if got := sysDesc.Record[label]; got != wantValue { + t.Errorf("Record[%q] = %q, want %q", label, got, wantValue) + } + } + + if fieldName := sysDesc.FieldNames["System Name"]; fieldName != "v_1_2_1" { + t.Errorf("FieldNames[%q] = %q, want %q", "System Name", fieldName, "v_1_2_1") + } +} + +func TestParseXETables_TabularShape_PortSummary(t *testing.T) { + tables, err := ParseXETables(loadFixture(t, "PortSummary.html")) + if err != nil { + t.Fatalf("ParseXETables: %v", err) + } + + var portSummary *Table + for i := range tables { + if tables[i].Caption == "Port Summary" { + portSummary = &tables[i] + } + } + if portSummary == nil { + t.Fatalf("no table captioned %q found; got captions: %v", "Port Summary", captions(tables)) + } + if portSummary.Kind != "tabular" { + t.Errorf("Kind = %q, want %q", portSummary.Kind, "tabular") + } + if len(portSummary.Rows) != 24 { + t.Fatalf("len(Rows) = %d, want 24", len(portSummary.Rows)) + } + + first := portSummary.Rows[0] + if first["Interface"] != "1" { + t.Errorf(`Rows[0]["Interface"] = %q, want "1"`, first["Interface"]) + } + if first["Physical Type"] != "RJ45" { + t.Errorf(`Rows[0]["Physical Type"] = %q, want "RJ45"`, first["Physical Type"]) + } +} + +func TestFetchAllTables_BlankCaptionTable_FDBSearch(t *testing.T) { + tables, err := ParseXETables(loadFixture(t, "FDBSearch.html")) + if err != nil { + t.Fatalf("ParseXETables: %v", err) + } + + var summary, entries *Table + for i := range tables { + if tables[i].Caption == "MAC Table" { + summary = &tables[i] + } + if tables[i].ID == "1_1" { + entries = &tables[i] + } + } + if summary == nil { + t.Fatalf("no table captioned %q found; got captions: %v", "MAC Table", captions(tables)) + } + if summary.Record["Current Entries"] != "2" { + t.Errorf(`summary.Record["Current Entries"] = %q, want "2"`, summary.Record["Current Entries"]) + } + + if entries == nil { + t.Fatalf("no table with id %q found", "1_1") + } + if entries.Kind != "tabular" { + t.Errorf("entries.Kind = %q, want %q", entries.Kind, "tabular") + } + if len(entries.Rows) != 2 { + t.Fatalf("len(entries.Rows) = %d, want 2", len(entries.Rows)) + } + if entries.Rows[0]["MAC Address"] != "4C:EA:41:63:04:1B" { + t.Errorf(`entries.Rows[0]["MAC Address"] = %q, want "4C:EA:41:63:04:1B"`, entries.Rows[0]["MAC Address"]) + } +} + +func captions(tables []Table) []string { + out := make([]string, len(tables)) + for i, t := range tables { + out[i] = t.Caption + } + return out +} diff --git a/terraform-provider-hpe1810/internal/provider/convert.go b/terraform-provider-hpe1810/internal/provider/convert.go new file mode 100644 index 0000000..bf16df5 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/convert.go @@ -0,0 +1,24 @@ +// Shared helpers for turning client.Table pieces (plain Go maps/slices) into +// the typed map/list values the Plugin Framework wants in state. +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var rowType = types.MapType{ElemType: types.StringType} + +func stringMapToTF(ctx context.Context, m map[string]string) (types.Map, diag.Diagnostics) { + return types.MapValueFrom(ctx, types.StringType, m) +} + +func rowsToTF(ctx context.Context, rows []map[string]string) (types.List, diag.Diagnostics) { + return types.ListValueFrom(ctx, rowType, rows) +} + +func tableOfRecordsToTF(ctx context.Context, tables map[string]map[string]string) (types.Map, diag.Diagnostics) { + return types.MapValueFrom(ctx, types.MapType{ElemType: types.StringType}, tables) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_backup_manager.go b/terraform-provider-hpe1810/internal/provider/data_source_backup_manager.go new file mode 100644 index 0000000..6996f12 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_backup_manager.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/backup_manager.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const backupManagerPath = "/UploadFile.html" + +var ( + _ datasource.DataSource = &backupManagerDataSource{} + _ datasource.DataSourceWithConfigure = &backupManagerDataSource{} +) + +func NewBackupManagerDataSource() datasource.DataSource { + return &backupManagerDataSource{} +} + +type backupManagerDataSource struct { + client *client.Client +} + +type backupManagerModel struct { + Values types.Map `tfsdk:"values"` +} + +func (d *backupManagerDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_backup_manager" +} + +func (d *backupManagerDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Backup/Upload File page's current form fields (method, server address, etc.). This is a config form, not a data table, so values reflect whatever was last configured or left blank.", + Attributes: map[string]schema.Attribute{ + "values": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (d *backupManagerDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *backupManagerDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(backupManagerPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Backup page", err.Error()) + return + } + table, ok := tables["Backup"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Backup" not found`) + return + } + + valuesValue, diags := stringMapToTF(ctx, table.Record) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := backupManagerModel{Values: valuesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_buffered_log.go b/terraform-provider-hpe1810/internal/provider/data_source_buffered_log.go new file mode 100644 index 0000000..8152aea --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_buffered_log.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/buffered_log.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const bufferedLogPath = "/BufferedLogs.html" + +var ( + _ datasource.DataSource = &bufferedLogDataSource{} + _ datasource.DataSourceWithConfigure = &bufferedLogDataSource{} +) + +func NewBufferedLogDataSource() datasource.DataSource { + return &bufferedLogDataSource{} +} + +type bufferedLogDataSource struct { + client *client.Client +} + +type bufferedLogModel struct { + Entries types.List `tfsdk:"entries"` +} + +func (d *bufferedLogDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_buffered_log" +} + +func (d *bufferedLogDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Buffered Log page: one entry per log message (Log Index, Severity, Log Time, Component, Description, Message Text).", + Attributes: map[string]schema.Attribute{ + "entries": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *bufferedLogDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *bufferedLogDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(bufferedLogPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Buffered Log page", err.Error()) + return + } + table, ok := tables["Log Message"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Log Message" not found`) + return + } + + entriesValue, diags := rowsToTF(ctx, table.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := bufferedLogModel{Entries: entriesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_clock.go b/terraform-provider-hpe1810/internal/provider/data_source_clock.go new file mode 100644 index 0000000..3b96244 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_clock.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/clock.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const clockPath = "/Clock_Detail.html" + +var ( + _ datasource.DataSource = &clockDataSource{} + _ datasource.DataSourceWithConfigure = &clockDataSource{} +) + +func NewClockDataSource() datasource.DataSource { + return &clockDataSource{} +} + +type clockDataSource struct { + client *client.Client +} + +type clockModel struct { + Tables types.Map `tfsdk:"tables"` +} + +func (d *clockDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_clock" +} + +func (d *clockDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Clock Detail page: Current Time, Time Zone and Daylight Saving Time, keyed by table caption.", + Attributes: map[string]schema.Attribute{ + "tables": schema.MapAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *clockDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *clockDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(clockPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Clock Detail page", err.Error()) + return + } + + records := make(map[string]map[string]string, len(tables)) + for caption, table := range tables { + records[caption] = table.Record + } + + tablesValue, diags := tableOfRecordsToTF(ctx, records) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := clockModel{Tables: tablesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_dual_image_status.go b/terraform-provider-hpe1810/internal/provider/data_source_dual_image_status.go new file mode 100644 index 0000000..490931f --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_dual_image_status.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/dual_image_status.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const dualImageStatusPath = "/dual_image_status.html" + +var ( + _ datasource.DataSource = &dualImageStatusDataSource{} + _ datasource.DataSourceWithConfigure = &dualImageStatusDataSource{} +) + +func NewDualImageStatusDataSource() datasource.DataSource { + return &dualImageStatusDataSource{} +} + +type dualImageStatusDataSource struct { + client *client.Client +} + +type dualImageStatusModel struct { + Units types.List `tfsdk:"units"` +} + +func (d *dualImageStatusDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dual_image_status" +} + +func (d *dualImageStatusDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Dual Image Status page: one entry per unit (Active image, Next-Active image). The page's separate \"Dual Image Descriptions\" table uses a header shape the generic parser doesn't handle and is skipped, matching the Python reference implementation.", + Attributes: map[string]schema.Attribute{ + "units": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *dualImageStatusDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *dualImageStatusDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(dualImageStatusPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Dual Image Status page", err.Error()) + return + } + table, ok := tables["Dual Image Status"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Dual Image Status" not found`) + return + } + + unitsValue, diags := rowsToTF(ctx, table.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := dualImageStatusModel{Units: unitsValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_lldp_statistics.go b/terraform-provider-hpe1810/internal/provider/data_source_lldp_statistics.go new file mode 100644 index 0000000..da4c425 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_lldp_statistics.go @@ -0,0 +1,94 @@ +// Port of ~/code/experiments/hpe/extractors/lldp_statistics.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const lldpStatisticsPath = "/LLDPStats.html" + +var ( + _ datasource.DataSource = &lldpStatisticsDataSource{} + _ datasource.DataSourceWithConfigure = &lldpStatisticsDataSource{} +) + +func NewLLDPStatisticsDataSource() datasource.DataSource { + return &lldpStatisticsDataSource{} +} + +type lldpStatisticsDataSource struct { + client *client.Client +} + +type lldpStatisticsModel struct { + Global types.Map `tfsdk:"global"` + Interfaces types.List `tfsdk:"interfaces"` +} + +func (d *lldpStatisticsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_lldp_statistics" +} + +func (d *lldpStatisticsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's LLDP Statistics page: global counters plus one entry per interface.", + Attributes: map[string]schema.Attribute{ + "global": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + }, + "interfaces": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *lldpStatisticsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *lldpStatisticsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(lldpStatisticsPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch LLDP Statistics page", err.Error()) + return + } + global, ok := tables["LLDP Global Statistics"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "LLDP Global Statistics" not found`) + return + } + interfaces, ok := tables["LLDP Interface Statistics"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "LLDP Interface Statistics" not found`) + return + } + + globalValue, diags := stringMapToTF(ctx, global.Record) + resp.Diagnostics.Append(diags...) + interfacesValue, diags := rowsToTF(ctx, interfaces.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := lldpStatisticsModel{Global: globalValue, Interfaces: interfacesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_log_configuration.go b/terraform-provider-hpe1810/internal/provider/data_source_log_configuration.go new file mode 100644 index 0000000..1fc4b04 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_log_configuration.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/log_configuration.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const logConfigurationPath = "/Logging.html" + +var ( + _ datasource.DataSource = &logConfigurationDataSource{} + _ datasource.DataSourceWithConfigure = &logConfigurationDataSource{} +) + +func NewLogConfigurationDataSource() datasource.DataSource { + return &logConfigurationDataSource{} +} + +type logConfigurationDataSource struct { + client *client.Client +} + +type logConfigurationModel struct { + Values types.Map `tfsdk:"values"` +} + +func (d *logConfigurationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_log_configuration" +} + +func (d *logConfigurationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Logging Configuration page.", + Attributes: map[string]schema.Attribute{ + "values": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (d *logConfigurationDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *logConfigurationDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(logConfigurationPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Logging page", err.Error()) + return + } + table, ok := tables["Logging"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Logging" not found`) + return + } + + valuesValue, diags := stringMapToTF(ctx, table.Record) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := logConfigurationModel{Values: valuesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_loop_protection_status.go b/terraform-provider-hpe1810/internal/provider/data_source_loop_protection_status.go new file mode 100644 index 0000000..ed90fea --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_loop_protection_status.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/loop_protection_status.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const loopProtectionStatusPath = "/LoopProtectionStatus.html" + +var ( + _ datasource.DataSource = &loopProtectionStatusDataSource{} + _ datasource.DataSourceWithConfigure = &loopProtectionStatusDataSource{} +) + +func NewLoopProtectionStatusDataSource() datasource.DataSource { + return &loopProtectionStatusDataSource{} +} + +type loopProtectionStatusDataSource struct { + client *client.Client +} + +type loopProtectionStatusModel struct { + Ports types.List `tfsdk:"ports"` +} + +func (d *loopProtectionStatusDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_loop_protection_status" +} + +func (d *loopProtectionStatusDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Loop Protection Status page: one entry per port (Interface, Configured Action Taken, Protection Feature Mode, Tx Mode, counters, Loop Detected).", + Attributes: map[string]schema.Attribute{ + "ports": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *loopProtectionStatusDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *loopProtectionStatusDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(loopProtectionStatusPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Loop Protection Status page", err.Error()) + return + } + table, ok := tables["Loop Protection Status"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Loop Protection Status" not found`) + return + } + + portsValue, diags := rowsToTF(ctx, table.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := loopProtectionStatusModel{Ports: portsValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_mac_table.go b/terraform-provider-hpe1810/internal/provider/data_source_mac_table.go new file mode 100644 index 0000000..af786f1 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_mac_table.go @@ -0,0 +1,103 @@ +// Port of ~/code/experiments/hpe/extractors/mac_table.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const macTablePath = "/FDBSearch.html" + +var ( + _ datasource.DataSource = &macTableDataSource{} + _ datasource.DataSourceWithConfigure = &macTableDataSource{} +) + +func NewMACTableDataSource() datasource.DataSource { + return &macTableDataSource{} +} + +type macTableDataSource struct { + client *client.Client +} + +type macTableModel struct { + Summary types.Map `tfsdk:"summary"` + Entries types.List `tfsdk:"entries"` +} + +func (d *macTableDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_mac_table" +} + +func (d *macTableDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's MAC Address Table (FDB Search) page: summary counters plus one entry per learned MAC. The per-entry table has a blank
, so it's looked up by table id (\"1_1\") instead of caption.", + Attributes: map[string]schema.Attribute{ + "summary": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + }, + "entries": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *macTableDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *macTableDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchAllTables(macTablePath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch MAC Table page", err.Error()) + return + } + + var summary map[string]string + var entries []map[string]string + for _, t := range tables { + if t.Caption == "MAC Table" { + summary = t.Record + } + if t.ID == "1_1" { + entries = t.Rows + } + } + if summary == nil { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "MAC Table" not found`) + return + } + if entries == nil { + resp.Diagnostics.AddError("Unexpected page layout", `table with id "1_1" not found`) + return + } + + summaryValue, diags := stringMapToTF(ctx, summary) + resp.Diagnostics.Append(diags...) + entriesValue, diags := rowsToTF(ctx, entries) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := macTableModel{Summary: summaryValue, Entries: entriesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_network_setup.go b/terraform-provider-hpe1810/internal/provider/data_source_network_setup.go new file mode 100644 index 0000000..56fd71f --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_network_setup.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/network_setup.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const networkSetupPath = "/Network.html" + +var ( + _ datasource.DataSource = &networkSetupDataSource{} + _ datasource.DataSourceWithConfigure = &networkSetupDataSource{} +) + +func NewNetworkSetupDataSource() datasource.DataSource { + return &networkSetupDataSource{} +} + +type networkSetupDataSource struct { + client *client.Client +} + +type networkSetupModel struct { + Tables types.Map `tfsdk:"tables"` +} + +func (d *networkSetupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_setup" +} + +func (d *networkSetupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Network Setup page: Network Details, Web Parameters, Management Access and SNMP, keyed by table caption.", + Attributes: map[string]schema.Attribute{ + "tables": schema.MapAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *networkSetupDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *networkSetupDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(networkSetupPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Network Setup page", err.Error()) + return + } + + records := make(map[string]map[string]string, len(tables)) + for caption, table := range tables { + records[caption] = table.Record + } + + tablesValue, diags := tableOfRecordsToTF(ctx, records) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := networkSetupModel{Tables: tablesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_port_summary.go b/terraform-provider-hpe1810/internal/provider/data_source_port_summary.go new file mode 100644 index 0000000..66e699f --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_port_summary.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/port_summary.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const portSummaryPath = "/PortSummary.html" + +var ( + _ datasource.DataSource = &portSummaryDataSource{} + _ datasource.DataSourceWithConfigure = &portSummaryDataSource{} +) + +func NewPortSummaryDataSource() datasource.DataSource { + return &portSummaryDataSource{} +} + +type portSummaryDataSource struct { + client *client.Client +} + +type portSummaryModel struct { + Ports types.List `tfsdk:"ports"` +} + +func (d *portSummaryDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_port_summary" +} + +func (d *portSummaryDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Port Summary page: one entry per port (Interface, Physical Type, Port Status, AutoNeg Status, Link Speed, MTU).", + Attributes: map[string]schema.Attribute{ + "ports": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *portSummaryDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *portSummaryDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(portSummaryPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Port Summary page", err.Error()) + return + } + table, ok := tables["Port Summary"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Port Summary" not found`) + return + } + + portsValue, diags := rowsToTF(ctx, table.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := portSummaryModel{Ports: portsValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_sntp.go b/terraform-provider-hpe1810/internal/provider/data_source_sntp.go new file mode 100644 index 0000000..5951ad6 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_sntp.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/sntp.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const sntpPath = "/SNTP.html" + +var ( + _ datasource.DataSource = &sntpDataSource{} + _ datasource.DataSourceWithConfigure = &sntpDataSource{} +) + +func NewSNTPDataSource() datasource.DataSource { + return &sntpDataSource{} +} + +type sntpDataSource struct { + client *client.Client +} + +type sntpModel struct { + Values types.Map `tfsdk:"values"` +} + +func (d *sntpDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_sntp" +} + +func (d *sntpDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's SNTP Configuration page.", + Attributes: map[string]schema.Attribute{ + "values": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (d *sntpDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *sntpDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(sntpPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch SNTP page", err.Error()) + return + } + table, ok := tables["SNTP Configuration"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "SNTP Configuration" not found`) + return + } + + valuesValue, diags := stringMapToTF(ctx, table.Record) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := sntpModel{Values: valuesValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_system_description.go b/terraform-provider-hpe1810/internal/provider/data_source_system_description.go new file mode 100644 index 0000000..0e941bb --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_system_description.go @@ -0,0 +1,101 @@ +// Port of ~/code/experiments/hpe/extractors/system_description.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const systemDescriptionPath = "/SysDescription.html" + +var ( + _ datasource.DataSource = &systemDescriptionDataSource{} + _ datasource.DataSourceWithConfigure = &systemDescriptionDataSource{} +) + +func NewSystemDescriptionDataSource() datasource.DataSource { + return &systemDescriptionDataSource{} +} + +type systemDescriptionDataSource struct { + client *client.Client +} + +type systemDescriptionModel struct { + Description types.String `tfsdk:"description"` + Name types.String `tfsdk:"name"` + Location types.String `tfsdk:"location"` + Contact types.String `tfsdk:"contact"` + SoftwareVersion types.String `tfsdk:"software_version"` + ObjectID types.String `tfsdk:"object_id"` + UpTime types.String `tfsdk:"up_time"` + CurrentTime types.String `tfsdk:"current_time"` + Date types.String `tfsdk:"date"` +} + +func (d *systemDescriptionDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_system_description" +} + +func (d *systemDescriptionDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's System Description page (name, location, contact, uptime, ...).", + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "location": schema.StringAttribute{Computed: true}, + "contact": schema.StringAttribute{Computed: true}, + "software_version": schema.StringAttribute{Computed: true}, + "object_id": schema.StringAttribute{Computed: true}, + "up_time": schema.StringAttribute{Computed: true}, + "current_time": schema.StringAttribute{Computed: true}, + "date": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *systemDescriptionDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *systemDescriptionDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(systemDescriptionPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch System Description page", err.Error()) + return + } + table, ok := tables["System Description"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "System Description" not found`) + return + } + record := table.Record + + state := systemDescriptionModel{ + Description: types.StringValue(record["System Description"]), + Name: types.StringValue(record["System Name"]), + Location: types.StringValue(record["System Location"]), + Contact: types.StringValue(record["System Contact"]), + SoftwareVersion: types.StringValue(record["Software Version"]), + ObjectID: types.StringValue(record["System Object ID"]), + UpTime: types.StringValue(record["System Up Time"]), + CurrentTime: types.StringValue(record["Current Time"]), + Date: types.StringValue(record["Date"]), + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/data_source_trunk_status.go b/terraform-provider-hpe1810/internal/provider/data_source_trunk_status.go new file mode 100644 index 0000000..fd3450d --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/data_source_trunk_status.go @@ -0,0 +1,82 @@ +// Port of ~/code/experiments/hpe/extractors/trunk_status.py +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +const trunkStatusPath = "/TrunkStatus.html" + +var ( + _ datasource.DataSource = &trunkStatusDataSource{} + _ datasource.DataSourceWithConfigure = &trunkStatusDataSource{} +) + +func NewTrunkStatusDataSource() datasource.DataSource { + return &trunkStatusDataSource{} +} + +type trunkStatusDataSource struct { + client *client.Client +} + +type trunkStatusModel struct { + Trunks types.List `tfsdk:"trunks"` +} + +func (d *trunkStatusDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_trunk_status" +} + +func (d *trunkStatusDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads the switch's Trunk Status page: one entry per configured trunk. Empty list if no trunks exist.", + Attributes: map[string]schema.Attribute{ + "trunks": schema.ListAttribute{ + Computed: true, + ElementType: types.MapType{ElemType: types.StringType}, + }, + }, + } +} + +func (d *trunkStatusDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("expected *client.Client, got %T", req.ProviderData)) + return + } + d.client = c +} + +func (d *trunkStatusDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + tables, err := d.client.FetchTables(trunkStatusPath) + if err != nil { + resp.Diagnostics.AddError("Unable to fetch Trunk Status page", err.Error()) + return + } + table, ok := tables["Trunk Status"] + if !ok { + resp.Diagnostics.AddError("Unexpected page layout", `table captioned "Trunk Status" not found`) + return + } + + trunksValue, diags := rowsToTF(ctx, table.Rows) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := trunkStatusModel{Trunks: trunksValue} + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/terraform-provider-hpe1810/internal/provider/provider.go b/terraform-provider-hpe1810/internal/provider/provider.go new file mode 100644 index 0000000..6eb86d8 --- /dev/null +++ b/terraform-provider-hpe1810/internal/provider/provider.go @@ -0,0 +1,128 @@ +package provider + +import ( + "context" + "os" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-hpe1810/internal/provider/client" +) + +var _ provider.Provider = &hpe1810Provider{} + +type hpe1810Provider struct { + version string +} + +// hpe1810ProviderModel mirrors config.py's three settings (SWITCH_HOST, +// SWITCH_HTTPS, SWITCH_PASSWORD) from the Python reference implementation. +type hpe1810ProviderModel struct { + Host types.String `tfsdk:"host"` + HTTPS types.Bool `tfsdk:"https"` + Password types.String `tfsdk:"password"` +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &hpe1810Provider{version: version} + } +} + +func (p *hpe1810Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "hpe1810" + resp.Version = p.version +} + +func (p *hpe1810Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages an HP ProCurve/OfficeConnect 1810 switch over its built-in web UI.", + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Optional: true, + Description: "Switch IP or hostname. Falls back to the HPE1810_HOST env var, then \"192.168.2.10\".", + }, + "https": schema.BoolAttribute{ + Optional: true, + Description: "Use HTTPS instead of HTTP. Falls back to the HPE1810_HTTPS env var, then false.", + }, + "password": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "Switch login password. Falls back to the HPE1810_PASSWORD env var, then empty.", + }, + }, + } +} + +func (p *hpe1810Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config hpe1810ProviderModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + host := os.Getenv("HPE1810_HOST") + if host == "" { + host = "192.168.2.10" + } + if !config.Host.IsNull() { + host = config.Host.ValueString() + } + + https := false + if envHTTPS := os.Getenv("HPE1810_HTTPS"); envHTTPS != "" { + https, _ = strconv.ParseBool(envHTTPS) + } + if !config.HTTPS.IsNull() { + https = config.HTTPS.ValueBool() + } + + password := os.Getenv("HPE1810_PASSWORD") + if !config.Password.IsNull() { + password = config.Password.ValueString() + } + + c, err := client.NewClient(host, https, password) + if err != nil { + resp.Diagnostics.AddError("Unable to create switch client", err.Error()) + return + } + if err := c.Login(); err != nil { + resp.Diagnostics.AddError("Unable to log in to switch", err.Error()) + return + } + + resp.DataSourceData = c + resp.ResourceData = c +} + +// DataSources: one entry per extractors/*.py equivalent. +func (p *hpe1810Provider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewSystemDescriptionDataSource, + NewNetworkSetupDataSource, + NewPortSummaryDataSource, + NewLLDPStatisticsDataSource, + NewBufferedLogDataSource, + NewMACTableDataSource, + NewTrunkStatusDataSource, + NewLoopProtectionStatusDataSource, + NewDualImageStatusDataSource, + NewClockDataSource, + NewSNTPDataSource, + NewLogConfigurationDataSource, + NewBackupManagerDataSource, + } +} + +// Resources will grow one entry per actions/*.py equivalent (locator first) +// as they're ported. +func (p *hpe1810Provider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{} +} diff --git a/terraform-provider-hpe1810/main.go b/terraform-provider-hpe1810/main.go new file mode 100644 index 0000000..769f294 --- /dev/null +++ b/terraform-provider-hpe1810/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "terraform-provider-hpe1810/internal/provider" +) + +// version is overridden at build time via -ldflags, e.g.: +// +// go build -ldflags "-X main.version=0.1.0" +var version = "dev" + +func main() { + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + // Only meaningful once/if this is ever published; for local + // development with dev_overrides (~/.tofurc) this address just + // needs to match what's referenced there. + Address: "local/joachimfriberg/hpe1810", + }) + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..34939a4 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..a6812e5 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,42 @@ +# Example OpenTofu config for the hpe1810 provider + +Run this once the switch is reachable again. + +## 1. Build the provider + +```bash +cd ../terraform-provider-hpe1810 +go build -o terraform-provider-hpe1810 . +``` + +`~/.tofurc` already has a `dev_overrides` entry pointing at that directory, so `tofu` +picks up this locally-built binary instead of trying to fetch anything from a registry. + +## 2. Plan + +```bash +cd ../terraform # this directory +tofu plan +``` + +No `tofu init` needed (and it would fail anyway — `local/joachimfriberg/hpe1810` isn't a +real registry source) since `dev_overrides` bypasses that step entirely. + +You'll see a warning about dev overrides being in effect — that's expected during local +development, not an error. + +## Files here + +- `versions.tf` — `required_providers` block, points at the provider source name. +- `provider.tf` — `provider "hpe1810"` block; all fields optional (see comments for the + env-var fallback chain). +- `main.tf` — the actual data source usage. +- `outputs.tf` — prints what came back. + +## Remember + +- Only one login session to the switch at a time (see AGENT.md) — close the switch's web + UI in your browser before running `tofu plan`. +- Same proxy issue as the Python project: Go's `net/http` also respects `HTTP_PROXY`/ + `NO_PROXY`. If you get `login request returned status 502`, run + `export NO_PROXY=192.168.2.10` (or `no_proxy=192.168.2.10`) before `tofu plan`. diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..65259e0 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,17 @@ +# All 13 read-only extractors are now ported as data sources -- see +# ~/code/experiments/hpe/terraform-provider-hpe1810/internal/provider/data_source_*.go +# Eventually resource "hpe1810_locator" once actions/locator.py is ported. + +data "hpe1810_system_description" "this" {} +data "hpe1810_network_setup" "this" {} +data "hpe1810_port_summary" "this" {} +data "hpe1810_lldp_statistics" "this" {} +data "hpe1810_buffered_log" "this" {} +data "hpe1810_mac_table" "this" {} +data "hpe1810_trunk_status" "this" {} +data "hpe1810_loop_protection_status" "this" {} +data "hpe1810_dual_image_status" "this" {} +data "hpe1810_clock" "this" {} +data "hpe1810_sntp" "this" {} +data "hpe1810_log_configuration" "this" {} +data "hpe1810_backup_manager" "this" {} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..98fef91 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,21 @@ +output "system_description" { + description = "Everything read back from the switch's System Description page." + value = data.hpe1810_system_description.this +} + +output "system_name" { + value = data.hpe1810_system_description.this.name +} + +output "port_summary" { + value = data.hpe1810_port_summary.this.ports +} + +output "port_24" { + description = "The uplink port -- verifying it's readable without touching it." + value = [for p in data.hpe1810_port_summary.this.ports : p if p.Interface == "24"] +} + +output "mac_table" { + value = data.hpe1810_mac_table.this +} diff --git a/terraform/provider.tf b/terraform/provider.tf new file mode 100644 index 0000000..36e263f --- /dev/null +++ b/terraform/provider.tf @@ -0,0 +1,20 @@ +# All three fields are optional. If omitted, each falls back to an env var, +# then to a default -- same pattern as config.py in the Python reference +# project (SWITCH_HOST / SWITCH_HTTPS / SWITCH_PASSWORD): +# +# host <- HPE1810_HOST <- "192.168.2.10" +# https <- HPE1810_HTTPS <- false +# password <- HPE1810_PASSWORD <- "" +# +# So for the switch as currently configured (no password, plain HTTP, at its +# default IP), an empty provider block is enough: +# +# provider "hpe1810" {} +# +# The explicit values below are just to show what's available. + +provider "hpe1810" { + host = "192.168.2.10" + https = false + password = "" # switch currently has no password set +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..fed8934 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,23 @@ +# Points at the locally-built provider binary, not a real registry -- +# this only resolves because of the dev_overrides block in ~/.tofurc: +# +# provider_installation { +# dev_overrides { +# "local/joachimfriberg/hpe1810" = "/home/joachimfriberg/code/experiments/hpe/terraform-provider-hpe1810" +# } +# direct {} +# } +# +# With dev_overrides in effect, "tofu init" is not needed (and would fail, +# since this source doesn't exist in any real registry) -- go straight to +# "tofu plan". Before that, build the provider binary: +# +# cd ../terraform-provider-hpe1810 && go build -o terraform-provider-hpe1810 . + +terraform { + required_providers { + hpe1810 = { + source = "local/joachimfriberg/hpe1810" + } + } +}