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