0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-29 18:21:10 +00:00

feat(node): add support for node config API (#1482)

* feat(node): implement CRUD API for proxmox node config

Signed-off-by: Björn Brauer <zaubernerd@zaubernerd.de>

* fix: add unit tests, fix UnmarshalJSON

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

---------

Signed-off-by: Björn Brauer <zaubernerd@zaubernerd.de>
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
Björn Brauer 2024-11-26 01:50:01 +01:00 committed by GitHub
parent dd50873e2a
commit 3e025fd6c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 690 additions and 0 deletions

41
proxmox/nodes/config.go Normal file
View File

@ -0,0 +1,41 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package nodes
import (
"context"
"fmt"
"net/http"
"github.com/bpg/terraform-provider-proxmox/proxmox/api"
)
// GetConfig retrieves the config for a node.
func (c *Client) GetConfig(ctx context.Context) (*[]ConfigGetResponseData, error) {
resBody := &ConfigGetResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath("config"), nil, resBody)
if err != nil {
return nil, fmt.Errorf("error retrieving node config: %w", err)
}
if resBody.Data == nil {
return nil, api.ErrNoDataObjectInResponse
}
return resBody.Data, nil
}
// UpdateConfig updates the config for a node.
func (c *Client) UpdateConfig(ctx context.Context, d *ConfigUpdateRequestBody) error {
err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath("config"), d, nil)
if err != nil {
return fmt.Errorf("error updating node config: %w", err)
}
return nil
}

View File

@ -0,0 +1,240 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package nodes
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// ConfigGetResponseBody contains the body from a config get response.
type ConfigGetResponseBody struct {
Data *[]ConfigGetResponseData `json:"data,omitempty"`
}
// ConfigGetResponseData contains the data from a config get response.
type ConfigGetResponseData struct {
// Node specific ACME settings.
ACME *ACMEConfig `json:"acme,omitempty"`
// ACME domain and validation plugin
ACMEDomain0 *ACMEDomainConfig `json:"acmedomain0,omitempty"`
// ACME domain and validation plugin
ACMEDomain1 *ACMEDomainConfig `json:"acmedomain1,omitempty"`
// ACME domain and validation plugin
ACMEDomain2 *ACMEDomainConfig `json:"acmedomain2,omitempty"`
// ACME domain and validation plugin
ACMEDomain3 *ACMEDomainConfig `json:"acmedomain3,omitempty"`
// ACME domain and validation plugin
ACMEDomain4 *ACMEDomainConfig `json:"acmedomain4,omitempty"`
// ACME domain and validation plugin
ACMEDomain5 *ACMEDomainConfig `json:"acmedomain5,omitempty"`
// Description for the Node. Shown in the web-interface node notes panel. This is saved as comment inside the configuration file.
Description *string `json:"description,omitempty"`
// Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.
Digest *string `json:"digest,omitempty"`
// Initial delay in seconds, before starting all the Virtual Guests with on-boot enabled.
StartAllOnbootDelay *int `json:"startall-onboot-delay,omitempty"`
// Node specific wake on LAN settings.
WakeOnLan *WakeOnLandConfig `json:"wakeonlan,omitempty"`
}
// ConfigUpdateRequestBody contains the body for a config update request.
type ConfigUpdateRequestBody struct {
// Node specific ACME settings.
ACME *ACMEConfig `json:"acme,omitempty"`
// ACME domain and validation plugin
ACMEDomain0 *ACMEDomainConfig `json:"acmedomain0,omitempty"`
// ACME domain and validation plugin
ACMEDomain1 *ACMEDomainConfig `json:"acmedomain1,omitempty"`
// ACME domain and validation plugin
ACMEDomain2 *ACMEDomainConfig `json:"acmedomain2,omitempty"`
// ACME domain and validation plugin
ACMEDomain3 *ACMEDomainConfig `json:"acmedomain3,omitempty"`
// ACME domain and validation plugin
ACMEDomain4 *ACMEDomainConfig `json:"acmedomain4,omitempty"`
Delete *string `json:"delete,omitempty"`
// Description for the Node. Shown in the web-interface node notes panel. This is saved as comment inside the configuration file.
Description *string `json:"description,omitempty"`
// Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.
Digest *string `json:"digest,omitempty"`
// Initial delay in seconds, before starting all the Virtual Guests with on-boot enabled.
StartAllOnbootDelay *int `json:"startall-onboot-delay,omitempty"`
// Node specific wake on LAN settings.
WakeOnLan *WakeOnLandConfig `json:"wakeonlan,omitempty"`
}
// ACMEConfig contains the ACME account / domains configuration that use the "standalone" plugin (http challenge).
type ACMEConfig struct {
// account name
Account *string
// domains
Domains []string
}
// UnmarshalJSON unmarshals a ACMEConfig struct from JSON.
func (a *ACMEConfig) UnmarshalJSON(b []byte) error {
config := ACMEConfig{}
s := ""
if err := json.Unmarshal(b, &s); err != nil {
return fmt.Errorf("error unmarshaling json: %w", err)
}
parts := strings.Split(s, ",")
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) != 2 {
return fmt.Errorf("invalid key-value pair: %s", part)
}
switch kv[0] {
case "account":
config.Account = &kv[1]
case "domains":
config.Domains = strings.Split(kv[1], ";")
default:
return fmt.Errorf("unknown key: %s", kv[0])
}
}
*a = config
return nil
}
// EncodeValues encodes a ACMEConfig struct into a string.
func (a *ACMEConfig) EncodeValues(key string, v *url.Values) error {
value := ""
if a.Account != nil {
value = fmt.Sprintf("account=%s", *a.Account)
}
value = fmt.Sprintf("%s,%s", value, strings.Join(a.Domains, ";"))
v.Add(key, value)
return nil
}
// ACMEDomainConfig contains the ACME domain configuration for domains using the dns challenge plugin.
type ACMEDomainConfig struct {
// domain name
Domain string
// alias for the domain
Alias *string
// name of the plugin configuration
Plugin *string
}
// UnmarshalJSON unmarshals a ACMEDomainConfig struct from JSON.
func (a *ACMEDomainConfig) UnmarshalJSON(b []byte) error {
config := ACMEDomainConfig{}
s := ""
if err := json.Unmarshal(b, &s); err != nil {
return fmt.Errorf("error unmarshaling json: %w", err)
}
parts := strings.Split(s, ",")
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) == 1 {
config.Domain = kv[0]
} else {
switch kv[0] {
case "domain":
config.Domain = kv[1]
case "alias":
config.Alias = &kv[1]
case "plugin":
config.Plugin = &kv[1]
default:
return fmt.Errorf("unknown key: %s", kv[0])
}
}
}
*a = config
return nil
}
// EncodeValues encodes a ACMEDomainConfig struct into a string.
func (a *ACMEDomainConfig) EncodeValues(key string, v *url.Values) error {
value := a.Domain
if a.Alias != nil {
value = fmt.Sprintf("%s,alias=%s", value, *a.Alias)
}
if a.Plugin != nil {
value = fmt.Sprintf("%s,plugin=%s", value, *a.Plugin)
}
v.Add(key, value)
return nil
}
// WakeOnLandConfig contains the wake on LAN configuration.
type WakeOnLandConfig struct {
// MAC address
MACAddress string
// bind interface
BindInterface *string
// IPv4 broadcast address
BroadcastAddress *string
}
// UnmarshalJSON unmarshals a WakeOnLandConfig struct from JSON.
func (a *WakeOnLandConfig) UnmarshalJSON(b []byte) error {
config := WakeOnLandConfig{}
s := ""
if err := json.Unmarshal(b, &s); err != nil {
return fmt.Errorf("error unmarshaling json: %w", err)
}
parts := strings.Split(s, ",")
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) == 1 {
config.MACAddress = kv[0]
} else {
switch kv[0] {
case "mac":
config.MACAddress = kv[1]
case "bind-interface":
config.BindInterface = &kv[1]
case "broadcast-address":
config.BroadcastAddress = &kv[1]
default:
return fmt.Errorf("unknown key: %s", kv[0])
}
}
}
*a = config
return nil
}
// EncodeValues encodes a WakeOnLandConfig struct into a string.
func (a *WakeOnLandConfig) EncodeValues(key string, v *url.Values) error {
value := a.MACAddress
if a.BindInterface != nil {
value = fmt.Sprintf("%s,bind-interface=%s", value, *a.BindInterface)
}
if a.BroadcastAddress != nil {
value = fmt.Sprintf("%s,broadcast-address=%s", value, *a.BroadcastAddress)
}
v.Add(key, value)
return nil
}

View File

@ -0,0 +1,409 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package nodes
import (
"net/url"
"testing"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
)
func TestACMEConfig_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config ACMEConfig
str string
wantErr bool
}{
{
name: "account only",
config: ACMEConfig{
Account: ptr.Ptr("foo"),
Domains: nil,
},
str: `"account=foo"`,
},
{
name: "account and domains",
config: ACMEConfig{
Account: ptr.Ptr("foo"),
Domains: []string{"bar", "baz"},
},
str: `"account=foo,domains=bar;baz"`,
},
{
name: "domains only",
config: ACMEConfig{
Account: nil,
Domains: []string{"bar", "baz"},
},
str: `"domains=bar;baz"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.UnmarshalJSON([]byte(tt.str)); (err != nil) != tt.wantErr {
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestACMEConfig_EncodeValues(t *testing.T) {
t.Parallel()
type args struct {
key string
v *url.Values
}
tests := []struct {
name string
config ACMEConfig
args args
wantErr bool
}{
{
name: "account only",
config: ACMEConfig{
Account: ptr.Ptr("foo"),
Domains: nil,
},
args: args{
"acme",
&url.Values{
"account": {"foo"},
},
},
},
{
name: "account and domains",
config: ACMEConfig{
Account: ptr.Ptr("foo"),
Domains: []string{"bar", "baz"},
},
args: args{
"acme",
&url.Values{
"account": {"foo"},
"domains": {"bar;baz"},
},
},
},
{
name: "domains only",
config: ACMEConfig{
Account: nil,
Domains: []string{"bar", "baz"},
},
args: args{
"acme",
&url.Values{
"domains": {"bar;baz"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.EncodeValues(tt.args.key, tt.args.v); (err != nil) != tt.wantErr {
t.Errorf("EncodeValues() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestACMEDomainConfig_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config ACMEDomainConfig
str string
wantErr bool
}{
{
name: "domain only",
config: ACMEDomainConfig{
Domain: "foo",
},
str: `"foo"`,
},
{
name: "domain only with key",
config: ACMEDomainConfig{
Domain: "foo",
},
str: `"domain=foo"`,
},
{
name: "domain and alias",
config: ACMEDomainConfig{
Domain: "foo",
Alias: ptr.Ptr("bar"),
},
str: `"domain=foo,alias=bar"`,
},
{
name: "domain and plugin",
config: ACMEDomainConfig{
Domain: "foo",
Plugin: ptr.Ptr("bar"),
},
str: `"domain=foo,plugin=bar"`,
},
{
name: "domain, alias, and plugin",
config: ACMEDomainConfig{
Domain: "foo",
Alias: ptr.Ptr("bar"),
Plugin: ptr.Ptr("baz"),
},
str: `"domain=foo,alias=bar,plugin=baz"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.UnmarshalJSON([]byte(tt.str)); (err != nil) != tt.wantErr {
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestACMEDomainConfig_EncodeValues(t *testing.T) {
t.Parallel()
type args struct {
key string
v *url.Values
}
tests := []struct {
name string
config ACMEDomainConfig
args args
wantErr bool
}{
{
name: "domain only",
config: ACMEDomainConfig{
Domain: "foo",
},
args: args{
"acme",
&url.Values{
"domain": {"foo"},
},
},
},
{
name: "domain and alias",
config: ACMEDomainConfig{
Domain: "foo",
Alias: ptr.Ptr("bar"),
},
args: args{
"acme",
&url.Values{
"domain": {"foo"},
"alias": {"bar"},
},
},
},
{
name: "domain and plugin",
config: ACMEDomainConfig{
Domain: "foo",
Plugin: ptr.Ptr("bar"),
},
args: args{
"acme",
&url.Values{
"domain": {"foo"},
"plugin": {"bar"},
},
},
},
{
name: "domain, alias, and plugin",
config: ACMEDomainConfig{
Domain: "foo",
Alias: ptr.Ptr("bar"),
Plugin: ptr.Ptr("baz"),
},
args: args{
"acme",
&url.Values{
"domain": {"foo"},
"alias": {"bar"},
"plugin": {"baz"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.EncodeValues(tt.args.key, tt.args.v); (err != nil) != tt.wantErr {
t.Errorf("EncodeValues() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestWakeOnLandConfig_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config WakeOnLandConfig
str string
wantErr bool
}{
{
name: "mac only",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
},
str: `"00:11:22:33:44:55"`,
},
{
name: "mac only with key",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
},
str: `"mac=00:11:22:33:44:55"`,
},
{
name: "mac and bind interface",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BindInterface: ptr.Ptr("eth0"),
},
str: `"mac=00:11:22:33:44:55,bind-interface=eth0"`,
},
{
name: "mac and broadcast address",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BroadcastAddress: ptr.Ptr("192.168.0.155"),
},
str: `"mac=00:11:22:33:44:55,broadcast-address=192.168.0.255"`,
},
{
name: "mac, bind interface, and broadcast address",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BindInterface: ptr.Ptr("eth0"),
BroadcastAddress: ptr.Ptr("192.168.0.255"),
},
str: `"mac=00:11:22:33:44:55,bind-interface=eth0,broadcast-address=192.168.0.255"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.UnmarshalJSON([]byte(tt.str)); (err != nil) != tt.wantErr {
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestWakeOnLandConfig_EncodeValues(t *testing.T) {
t.Parallel()
type args struct {
key string
v *url.Values
}
tests := []struct {
name string
config WakeOnLandConfig
args args
wantErr bool
}{
{
name: "mac only",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
},
args: args{
"wakeonlan",
&url.Values{
"mac": {"00:11:22:33:44:55"},
},
},
},
{
name: "mac and bind interface",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BindInterface: ptr.Ptr("eth0"),
},
args: args{
"wakeonlan",
&url.Values{
"mac": {"00:11:22:33:44:55"},
"bind-interface": {"eth0"},
},
},
},
{
name: "mac and broadcast address",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BroadcastAddress: ptr.Ptr("192.168.0.255"),
},
args: args{
"wakeonlan",
&url.Values{
"mac": {"00:11:22:33:44:55"},
"broadcast-address": {"192.168.0.255"},
},
},
},
{
name: "mac, bind interface, and broadcast address",
config: WakeOnLandConfig{
MACAddress: "00:11:22:33:44:55",
BindInterface: ptr.Ptr("eth0"),
BroadcastAddress: ptr.Ptr("10.255.255.255"),
},
args: args{
"wakeonlan",
&url.Values{
"mac": {"00:11:22:33:44:55"},
"bind-interface": {"eth0"},
"broadcast-address": {"10.255.255.255"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.config.EncodeValues(tt.args.key, tt.args.v); (err != nil) != tt.wantErr {
t.Errorf("EncodeValues() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}