diff --git a/proxmox/client.go b/proxmox/client.go index ce9c5321..7619fa7d 100644 --- a/proxmox/client.go +++ b/proxmox/client.go @@ -41,6 +41,10 @@ const ( Client_Error_Nil string = "client may not be nil" ) +const ( + noSuchResource = "500 no such resource" +) + const ( VmRef_Error_Nil string = "vm reference may not be nil" ) @@ -52,8 +56,8 @@ type VmRef struct { node string pool string vmType string - haState string - haGroup string + haState HaState + haGroup HaGroupName } func (vmr *VmRef) SetNode(node string) { @@ -84,11 +88,11 @@ func (vmr *VmRef) Pool() string { return vmr.pool } -func (vmr *VmRef) HaState() string { +func (vmr *VmRef) HaState() HaState { return vmr.haState } -func (vmr *VmRef) HaGroup() string { +func (vmr *VmRef) HaGroup() HaGroupName { return vmr.haGroup } @@ -159,7 +163,7 @@ func (c *Client) GetJsonRetryable(url string, data *map[string]interface{}, trie if statErr == nil { return nil } - if strings.Contains(statErr.Error(), "500 no such resource") { + if strings.Contains(statErr.Error(), noSuchResource) { return statErr } // fmt.Printf("[DEBUG][GetJsonRetryable] Sleeping for %d seconds before asking url %s", ii+1, url) @@ -219,7 +223,7 @@ func (c *Client) GetVmInfo(vmr *VmRef) (vmInfo map[string]interface{}, err error vmr.pool = vmInfo["pool"].(string) } if vmInfo["hastate"] != nil { - vmr.haState = vmInfo["hastate"].(string) + vmr.haState = HaState(vmInfo["hastate"].(string)) } return } @@ -252,7 +256,7 @@ func (c *Client) GetVmRefsByName(vmName string) (vmrs []*VmRef, err error) { vmr.pool = vm["pool"].(string) } if vm["hastate"] != nil { - vmr.haState = vm["hastate"].(string) + vmr.haState = HaState(vm["hastate"].(string)) } vmrs = append(vmrs, vmr) } @@ -281,7 +285,7 @@ func (c *Client) GetVmRefById(vmId int) (vmr *VmRef, err error) { vmr.pool = vm["pool"].(string) } if vm["hastate"] != nil { - vmr.haState = vm["hastate"].(string) + vmr.haState = HaState(vm["hastate"].(string)) } return } @@ -1580,6 +1584,7 @@ func (c *Client) UpdateVMPool(vmr *VmRef, pool string) (exitStatus interface{}, return } +// deprecated use NewGuestHAFromApi() instead func (c *Client) ReadVMHA(vmr *VmRef) (err error) { var list map[string]interface{} url := fmt.Sprintf("/cluster/ha/resources/%d", vmr.vmId) @@ -1588,17 +1593,18 @@ func (c *Client) ReadVMHA(vmr *VmRef) (err error) { list = list["data"].(map[string]interface{}) for elem, value := range list { if elem == "group" { - vmr.haGroup = value.(string) + vmr.haGroup = HaGroupName(value.(string)) } if elem == "state" { - vmr.haState = value.(string) + vmr.haState = HaState(value.(string)) } } } return } -func (c *Client) UpdateVMHA(vmr *VmRef, haState string, haGroup string) (exitStatus interface{}, err error) { +// deprecated use GuestHA.Set() instead +func (c *Client) UpdateVMHA(vmr *VmRef, haState HaState, haGroup HaGroupName) (exitStatus interface{}, err error) { // Same hastate & hagroup if vmr.haState == haState && vmr.haGroup == haGroup { return diff --git a/proxmox/config_ha.go b/proxmox/config_ha.go new file mode 100644 index 00000000..263f353f --- /dev/null +++ b/proxmox/config_ha.go @@ -0,0 +1,238 @@ +package proxmox + +import ( + "errors" + "strconv" + "strings" +) + +type GuestHA struct { + Comment *string `json:"comment"` // Description. + Delete bool `json:"remove,omitempty"` // When true, remove HA settings for the Guest. + Group *HaGroupName `json:"group"` // May be empty, in which case the guest is not part of a group. + Relocates *HaRelocate `json:"relocates"` + Restarts *HaRestart `json:"restarts"` + State *HaState `json:"state"` +} + +// TODO change type when we have a custom type for guestID +func (g GuestHA) mapToApi(guestID int) map[string]interface{} { + params := map[string]interface{}{} + if g.State != nil { + params["state"] = string(*g.State) + } + if g.Restarts != nil { + params["max_restart"] = int(*g.Restarts) + } + if g.Relocates != nil { + params["max_relocate"] = int(*g.Relocates) + } + if guestID > 0 { // Create + if g.Comment != nil && *g.Comment != "" { + params["comment"] = *g.Comment + } + params["sid"] = guestID + if g.Group != nil && *g.Group != "" { + params["group"] = string(*g.Group) + } + } else { // Update + if g.Comment != nil { + params["comment"] = *g.Comment + } + if g.Group != nil { + if *g.Group != "" { + params["group"] = string(*g.Group) + } else { + params["delete"] = "group" + } + } + } + return params +} + +func (GuestHA) mapToSDK(params map[string]interface{}) (config GuestHA) { + if itemValue, isSet := params["comment"]; isSet { + comment := itemValue.(string) + config.Comment = &comment + } + if itemValue, isSet := params["group"]; isSet { + group := HaGroupName(itemValue.(string)) + config.Group = &group + } + if itemValue, isSet := params["max_relocate"]; isSet { + relocate := HaRelocate(itemValue.(float64)) + config.Relocates = &relocate + } + if itemValue, isSet := params["max_restart"]; isSet { + restarts := HaRestart(itemValue.(float64)) + config.Restarts = &restarts + } + if itemValue, isSet := params["state"]; isSet { + state := HaState(itemValue.(string)) + config.State = &state + } + return +} + +func (g GuestHA) Set(vmr *VmRef, client *Client) (err error) { + if err = g.Validate(); err != nil { + return + } + ha, err := NewGuestHAFromApi(vmr, client) + if err != nil { + return + } + return g.Set_Unsafe(ha, vmr, client) +} + +func (g GuestHA) Set_Unsafe(current *GuestHA, vmr *VmRef, client *Client) (err error) { + if current == nil { // create + if err = client.Post(g.mapToApi(vmr.vmId), "/cluster/ha/resources"); err != nil { + return + } + if g.State != nil { + vmr.haState = *g.State + } + if g.Group != nil { + vmr.haGroup = *g.Group + } + } + if g.Delete { // delete + if err = client.Delete("/cluster/ha/resources/" + strconv.FormatInt(int64(vmr.vmId), 10)); err != nil { + return + } + vmr.haState = "" + vmr.haGroup = "" + return + } + // update + if err = client.Put(g.mapToApi(0), "/cluster/ha/resources/"+strconv.FormatInt(int64(vmr.vmId), 10)); err != nil { + return + } + if g.State != nil { + vmr.haState = *g.State + } + if g.Group != nil { + vmr.haGroup = *g.Group + } + return +} + +func (g GuestHA) Validate() (err error) { + if g.Group != nil && *g.Group != "" { + if err = g.Group.Validate(); err != nil { + return + } + } + if g.State != nil { + if err = g.State.Validate(); err != nil { + return + } + } + if g.Relocates != nil { + if err = g.Relocates.Validate(); err != nil { + return + } + } + if g.Restarts != nil { + if err = g.Restarts.Validate(); err != nil { + return + } + } + return +} + +type HaGroupName string + +const ( + HaGroupName_Error_Length string = "HaGroupName should be at least 2 characters long" + HaGroupName_Error_Illegal_Start string = "HaGroupName may only start with the following characters:" + haGroupName_Characters_Legal_Starting + HaGroupName_Error_Illegal_End string = "HaGroupName may only end with the following characters:" + haGroupName_Characters_Legal_Ending + HaGroupName_Error_Illegal string = "HaGroupName may only contain the following characters:" + haGroupName_Characters_Legal + haGroupName_Characters_Legal_Starting string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + haGroupName_Characters_Legal_Ending string = haGroupName_Characters_Legal_Starting + "12345678890" + haGroupName_Characters_Legal string = haGroupName_Characters_Legal_Starting + ".-_" + haGroupName_Characters_Legal_Ending +) + +func (g HaGroupName) Validate() error { + if len(g) < 2 { + return errors.New(HaGroupName_Error_Length) + } + if !strings.Contains(haGroupName_Characters_Legal_Starting, string(g[0])) { + return errors.New(HaGroupName_Error_Illegal_Start) + } + if !strings.Contains(haGroupName_Characters_Legal_Ending, string(g[len(g)-1])) { + return errors.New(HaGroupName_Error_Illegal_End) + } + for _, c := range g { + if !strings.Contains(haGroupName_Characters_Legal, string(c)) { + return errors.New(HaGroupName_Error_Illegal) + } + } + return nil +} + +type HaRelocate uint8 + +const HaRelocate_Error_UpperBound string = "HaRelocate should be less or equal to 10" + +func (r HaRelocate) Validate() error { + if r > 10 { + return errors.New(HaRelocate_Error_UpperBound) + } + return nil +} + +type HaRestart uint8 + +const HaRestart_Error_UpperBound string = "HaRestart should be less or equal to 10" + +func (r HaRestart) Validate() error { + if r > 10 { + return errors.New(HaRestart_Error_UpperBound) + } + return nil +} + +type HaState string // enum + +const ( + HaState_Disabled HaState = "disabled" + HaState_Ignored HaState = "ignored" + HaState_Started HaState = "started" + HaState_Stopped HaState = "stopped" + HaState_Error_Invalid string = string("HaState should be one of: " + HaState_Disabled + "," + HaState_Ignored + "," + HaState_Started + "," + HaState_Stopped) +) + +func (s HaState) Validate() error { + switch s { + case HaState_Disabled, HaState_Ignored, HaState_Started, HaState_Stopped: + return nil + } + return errors.New(HaState_Error_Invalid) +} + +func NewGuestHAFromApi(vmr *VmRef, client *Client) (*GuestHA, error) { + if err := vmr.nilCheck(); err != nil { + return nil, err + } + if client == nil { + return nil, errors.New(Client_Error_Nil) + } + guestID := strconv.FormatInt(int64(vmr.vmId), 10) + params, err := client.GetItemConfigMapStringInterface("/cluster/ha/resources/"+guestID, "", "") + if err != nil { + if err.Error() == noSuchResource+" 'vm:"+guestID+"'" || err.Error() == noSuchResource+" 'ct:"+guestID+"'" { + return nil, nil + } + return nil, err + } + ha := GuestHA{}.mapToSDK(params) + vmr.haState = *ha.State + if ha.Group != nil { + vmr.haGroup = *ha.Group + } else { + vmr.haGroup = "" + } + return &ha, nil +} diff --git a/proxmox/config_ha_test.go b/proxmox/config_ha_test.go new file mode 100644 index 00000000..1f2b1dc0 --- /dev/null +++ b/proxmox/config_ha_test.go @@ -0,0 +1,331 @@ +package proxmox + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_GuestHA_mapToApi(t *testing.T) { + PointerHaState := func(i HaState) *HaState { return &i } // TODO remove when we have a generic pointer function + PointerHaGroupName := func(i HaGroupName) *HaGroupName { return &i } // TODO remove when we have a generic pointer function + PointerHaRelocate := func(i HaRelocate) *HaRelocate { return &i } // TODO remove when we have a generic pointer function + PointerHaRestart := func(i HaRestart) *HaRestart { return &i } // TODO remove when we have a generic pointer function + PointerString := func(i string) *string { return &i } // TODO remove when we have a generic pointer function + type testInput struct { + guestID int + config GuestHA + } + tests := []struct { + name string + input testInput + output map[string]interface{} + }{ + {name: `Test Comment Create`, + input: testInput{guestID: 100, config: GuestHA{Comment: PointerString("abc")}}, + output: map[string]interface{}{ + "sid": int(100), + "comment": string("abc")}}, + {name: `Test Comment Create ""`, + input: testInput{guestID: 100, config: GuestHA{Comment: PointerString("")}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Comment Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Comment Update`, + input: testInput{config: GuestHA{Comment: PointerString("abc")}}, + output: map[string]interface{}{"comment": string("abc")}}, + {name: `Test Comment Update ""`, + input: testInput{config: GuestHA{Comment: PointerString("")}}, + output: map[string]interface{}{"comment": string("")}}, + {name: `Test Comment Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + {name: `Test Full Create`, + input: testInput{guestID: 100, config: GuestHA{ + Comment: PointerString("test"), + Delete: true, + Group: PointerHaGroupName("test-group"), + Relocates: PointerHaRelocate(1), + Restarts: PointerHaRestart(10), + State: PointerHaState(HaState_Started)}}, + output: map[string]interface{}{ + "comment": string("test"), + "group": string("test-group"), + "max_relocate": int(1), + "max_restart": int(10), + "sid": int(100), + "state": string("started")}}, + {name: `Test Full Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Full Update`, + input: testInput{config: GuestHA{ + Comment: PointerString("test"), + Delete: true, + Group: PointerHaGroupName("test-group"), + Relocates: PointerHaRelocate(10), + Restarts: PointerHaRestart(1), + State: PointerHaState(HaState_Stopped)}}, + output: map[string]interface{}{ + "comment": string("test"), + "group": string("test-group"), + "max_relocate": int(10), + "max_restart": int(1), + "state": string("stopped")}}, + {name: `Test Full Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + {name: `Test Group Create`, + input: testInput{guestID: 100, config: GuestHA{Group: PointerHaGroupName("test-group")}}, + output: map[string]interface{}{ + "group": string("test-group"), + "sid": int(100)}}, + {name: `Test Group Create ""`, + input: testInput{guestID: 100, config: GuestHA{Group: PointerHaGroupName("")}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Group Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Group Update`, + input: testInput{config: GuestHA{Group: PointerHaGroupName("test-group")}}, + output: map[string]interface{}{"group": string("test-group")}}, + {name: `Test Group Update ""`, + input: testInput{config: GuestHA{Group: PointerHaGroupName("")}}, + output: map[string]interface{}{"delete": string("group")}}, + {name: `Test Group Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + {name: `Test Relocates Create`, + input: testInput{guestID: 100, config: GuestHA{Relocates: PointerHaRelocate(10)}}, + output: map[string]interface{}{ + "max_relocate": int(10), + "sid": int(100)}}, + {name: `Test Relocates Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Relocates Update`, + input: testInput{config: GuestHA{Relocates: PointerHaRelocate(10)}}, + output: map[string]interface{}{"max_relocate": int(10)}}, + {name: `Test Relocates Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + {name: `Test Restarts Create`, + input: testInput{guestID: 100, config: GuestHA{Restarts: PointerHaRestart(10)}}, + output: map[string]interface{}{ + "max_restart": int(10), + "sid": int(100)}}, + {name: `Test Restarts Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test Restarts Update`, + input: testInput{config: GuestHA{Restarts: PointerHaRestart(10)}}, + output: map[string]interface{}{"max_restart": int(10)}}, + {name: `Test Restarts Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + {name: `Test State Create`, + input: testInput{guestID: 100, config: GuestHA{State: PointerHaState(HaState_Started)}}, + output: map[string]interface{}{ + "sid": int(100), + "state": string("started")}}, + {name: `Test State Create nil`, + input: testInput{guestID: 100, config: GuestHA{}}, + output: map[string]interface{}{"sid": int(100)}}, + {name: `Test State Update`, + input: testInput{config: GuestHA{State: PointerHaState(HaState_Stopped)}}, + output: map[string]interface{}{"state": string("stopped")}}, + {name: `Test State Update nil`, + input: testInput{config: GuestHA{}}, + output: map[string]interface{}{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.config.mapToApi(test.input.guestID), test.name) + }) + } +} + +func Test_GuestHA_mapToSDK(t *testing.T) { + PointerHaState := func(i HaState) *HaState { return &i } // TODO remove when we have a generic pointer function + PointerHaGroupName := func(i HaGroupName) *HaGroupName { return &i } // TODO remove when we have a generic pointer function + PointerHaRelocate := func(i HaRelocate) *HaRelocate { return &i } // TODO remove when we have a generic pointer function + PointerHaRestart := func(i HaRestart) *HaRestart { return &i } // TODO remove when we have a generic pointer function + PointerString := func(i string) *string { return &i } // TODO remove when we have a generic pointer function + tests := []struct { + name string + input map[string]interface{} + output GuestHA + }{ + {name: "Test Comment", + input: map[string]interface{}{"comment": "abc"}, + output: GuestHA{Comment: PointerString("abc")}}, + {name: "Test Full", + input: map[string]interface{}{ + "comment": "test", + "group": "test-group", + "max_relocate": float64(10), + "max_restart": float64(1), + "state": "stopped"}, + output: GuestHA{ + Comment: PointerString("test"), + Group: PointerHaGroupName("test-group"), + Relocates: PointerHaRelocate(10), + Restarts: PointerHaRestart(1), + State: PointerHaState(HaState_Stopped)}}, + {name: "Test Group", + input: map[string]interface{}{"group": "test-group"}, + output: GuestHA{Group: PointerHaGroupName("test-group")}}, + {name: "Test Reallocates", + input: map[string]interface{}{"max_relocate": float64(10)}, + output: GuestHA{Relocates: PointerHaRelocate(10)}}, + {name: "Test Restarts", + input: map[string]interface{}{"max_restart": float64(10)}, + output: GuestHA{Restarts: PointerHaRestart(10)}}, + {name: "Test State", + input: map[string]interface{}{"state": "started"}, + output: GuestHA{State: PointerHaState(HaState_Started)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, GuestHA{}.mapToSDK(test.input), test.name) + }) + } +} + +func Test_GuestHA_Set(t *testing.T) { + type testInput struct { + vmr *VmRef + c *Client + } + tests := []struct { + name string + input testInput + }{ + {name: "* nil", input: testInput{vmr: &VmRef{}}}, + {name: "nil *", input: testInput{c: &Client{}}}, + {name: "nil nil", input: testInput{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { GuestHA{}.Set(test.input.vmr, test.input.c) }, test.name) + }) + } +} + +func Test_GuestHA_Validate(t *testing.T) { + PointerHaState := func(i HaState) *HaState { return &i } // TODO remove when we have a generic pointer function + PointerHaGroupName := func(i HaGroupName) *HaGroupName { return &i } // TODO remove when we have a generic pointer function + PointerHaRelocate := func(i HaRelocate) *HaRelocate { return &i } // TODO remove when we have a generic pointer function + PointerHaRestart := func(i HaRestart) *HaRestart { return &i } // TODO remove when we have a generic pointer function + tests := []struct { + name string + input GuestHA + output error + }{ + {name: "Invalid Group", input: GuestHA{Group: PointerHaGroupName("inv&lid")}, output: errors.New(HaGroupName_Error_Illegal)}, + {name: "Invalid Reallocates", input: GuestHA{Relocates: PointerHaRelocate(11)}, output: errors.New(HaRelocate_Error_UpperBound)}, + {name: "Invalid Restarts", input: GuestHA{Restarts: PointerHaRestart(11)}, output: errors.New(HaRestart_Error_UpperBound)}, + {name: "Invalid State", input: GuestHA{State: PointerHaState("invalid")}, output: errors.New(HaState_Error_Invalid)}, + {name: "Valid", input: GuestHA{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate(), test.name) + }) + } +} + +func Test_HaGroupName_Validate(t *testing.T) { + tests := []struct { + name string + input HaGroupName + output error + }{ + {name: "Invalid Length", input: "i", output: errors.New(HaGroupName_Error_Length)}, + {name: "Invalid Illegal", input: "inv&^%lid", output: errors.New(HaGroupName_Error_Illegal)}, + {name: "Invalid Illegal_End", input: "invalid&", output: errors.New(HaGroupName_Error_Illegal_End)}, + {name: "Invalid Illegal_Start", input: "0invalid", output: errors.New(HaGroupName_Error_Illegal_Start)}, + {name: "Valid", input: "valid"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate(), test.name) + }) + } +} + +func Test_HaRelocate_Validate(t *testing.T) { + tests := []struct { + name string + input HaRelocate + output error + }{ + {name: "Invalid Upper Bound", input: 11, output: errors.New(HaRelocate_Error_UpperBound)}, + {name: "Valid Lower Bound", input: 0}, + {name: "Valid Upper Bound", input: 10}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate(), test.name) + }) + } +} + +func Test_HaRestart_Validate(t *testing.T) { + tests := []struct { + name string + input HaRestart + output error + }{ + {name: "Invalid Upper Bound", input: 11, output: errors.New(HaRestart_Error_UpperBound)}, + {name: "Valid Lower Bound", input: 0}, + {name: "Valid Upper Bound", input: 10}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate(), test.name) + }) + } +} + +func Test_HaState_Validate(t *testing.T) { + tests := []struct { + name string + input HaState + output error + }{ + {name: "Invalid", input: "invalid", output: errors.New(HaState_Error_Invalid)}, + {name: "Valid Disabled", input: HaState_Disabled}, + {name: "Valid Ignored", input: HaState_Ignored}, + {name: "Valid Started", input: HaState_Started}, + {name: "Valid Stopped", input: HaState_Stopped}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.output, test.input.Validate(), test.name) + }) + } +} + +func Test_NewGuestHAFromApi(t *testing.T) { + type testInput struct { + vmr *VmRef + c *Client + } + tests := []struct { + name string + input testInput + output GuestHA + }{ + {name: "* nil", input: testInput{vmr: &VmRef{}}, output: GuestHA{}}, + {name: "nil *", input: testInput{c: &Client{}}, output: GuestHA{}}, + {name: "nil nil", input: testInput{}, output: GuestHA{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { NewGuestHAFromApi(test.input.vmr, test.input.c) }, test.name) + }) + } +} diff --git a/proxmox/config_hagroup.go b/proxmox/config_hagroup.go index 67adfea1..158ad01a 100644 --- a/proxmox/config_hagroup.go +++ b/proxmox/config_hagroup.go @@ -6,12 +6,12 @@ import ( ) type HAGroup struct { - Comment string // Description. - Group string // The HA group identifier. - Nodes []string // List of cluster node names with optional priority. LIKE: [:]{,[:]}* - NoFailback bool // The CRM tries to run services on the node with the highest priority. If a node with higher priority comes online, the CRM migrates the service to that node. Enabling nofailback prevents that behavior. - Restricted bool // Resources bound to restricted groups may only run on nodes defined by the group. - Type string // Group type + Comment string // Description. + Group HaGroupName // The HA group identifier. + Nodes []string // List of cluster node names with optional priority. LIKE: [:]{,[:]}* + NoFailback bool // The CRM tries to run services on the node with the highest priority. If a node with higher priority comes online, the CRM migrates the service to that node. Enabling nofailback prevents that behavior. + Restricted bool // Resources bound to restricted groups may only run on nodes defined by the group. + Type string // Group type } func (c *Client) GetHAGroupList() (haGroups []HAGroup, err error) { @@ -28,7 +28,7 @@ func (c *Client) GetHAGroupList() (haGroups []HAGroup, err error) { haGroups = append(haGroups, HAGroup{ Comment: itemMap["comment"].(string), - Group: itemMap["group"].(string), + Group: HaGroupName(itemMap["group"].(string)), Nodes: strings.Split(itemMap["nodes"].(string), ","), NoFailback: itemMap["nofailback"].(float64) == 1, Restricted: itemMap["restricted"].(float64) == 1, @@ -39,7 +39,7 @@ func (c *Client) GetHAGroupList() (haGroups []HAGroup, err error) { return haGroups, nil } -func (c *Client) GetHAGroupByName(GroupName string) (*HAGroup, error) { +func (c *Client) GetHAGroupByName(GroupName HaGroupName) (*HAGroup, error) { groups, err := c.GetHAGroupList() if err != nil { @@ -52,5 +52,5 @@ func (c *Client) GetHAGroupByName(GroupName string) (*HAGroup, error) { } } - return nil, errors.New("cannot find HaGroup by name " + GroupName) + return nil, errors.New("cannot find HaGroup by name " + string(GroupName)) } diff --git a/proxmox/config_lxc.go b/proxmox/config_lxc.go index 40f0ccca..ef214a2c 100644 --- a/proxmox/config_lxc.go +++ b/proxmox/config_lxc.go @@ -24,8 +24,7 @@ type ConfigLxc struct { Features QemuDevice `json:"features,omitempty"` Force bool `json:"force,omitempty"` Full bool `json:"full,omitempty"` - HaState string `json:"hastate,omitempty"` - HaGroup string `json:"hagroup,omitempty"` + HA *GuestHA `json:"ha,omitempty"` Hookscript string `json:"hookscript,omitempty"` Hostname string `json:"hostname,omitempty"` IgnoreUnpackErrors bool `json:"ignore-unpack-errors,omitempty"` @@ -318,15 +317,10 @@ func NewConfigLxcFromApi(vmr *VmRef, client *Client) (config *ConfigLxc, err err config.Unused = unused config.Tags = tags - err = client.ReadVMHA(vmr) - if err == nil { - config.HaState = vmr.HaState() - config.HaGroup = vmr.HaGroup() - } else { - //log.Printf("[DEBUG] Container %d(%s) has no HA config", vmr.vmId, lxcConfig["hostname"]) - return config, nil + config.HA, err = NewGuestHAFromApi(vmr, client) + if err != nil { + return nil, err } - return } @@ -344,11 +338,12 @@ func (config ConfigLxc) CreateLxc(vmr *VmRef, client *Client) (err error) { return fmt.Errorf("error creating LXC container: %v, error status: %s (params: %v)", err, exitStatus, string(params)) } - _, err = client.UpdateVMHA(vmr, config.HaState, config.HaGroup) - if err != nil { - return fmt.Errorf("[ERROR] %q", err) + if config.HA != nil { + err = config.HA.Set(vmr, client) + if err != nil { + return + } } - return } @@ -390,11 +385,12 @@ func (config ConfigLxc) CloneLxc(vmr *VmRef, client *Client) (err error) { return fmt.Errorf("error cloning LXC container: %v, error status: %s (params: %v)", err, exitStatus, string(params)) } - _, err = client.UpdateVMHA(vmr, config.HaState, config.HaGroup) - if err != nil { - return fmt.Errorf("[ERROR] %q", err) + if config.HA != nil { + err = config.HA.Set(vmr, client) + if err != nil { + return + } } - return } @@ -415,9 +411,11 @@ func (config ConfigLxc) UpdateConfig(vmr *VmRef, client *Client) (err error) { // also, error "500 unable to modify read-only option: 'unprivileged'" delete(paramMap, "unprivileged") - _, err = client.UpdateVMHA(vmr, config.HaState, config.HaGroup) - if err != nil { - return err + if config.HA != nil { + err = config.HA.Set(vmr, client) + if err != nil { + return + } } _, err = client.SetLxcConfig(vmr, paramMap) diff --git a/proxmox/config_qemu.go b/proxmox/config_qemu.go index 1a0de4c6..5b5b36df 100644 --- a/proxmox/config_qemu.go +++ b/proxmox/config_qemu.go @@ -49,8 +49,7 @@ type ConfigQemu struct { Disks *QemuStorages `json:"disks,omitempty"` EFIDisk QemuDevice `json:"efidisk,omitempty"` // TODO should be a struct FullClone *int `json:"fullclone,omitempty"` // TODO should probably be a bool - HaGroup string `json:"hagroup,omitempty"` - HaState string `json:"hastate,omitempty"` // TODO should be custom type with enum + HA *GuestHA `json:"ha,omitempty"` Hookscript string `json:"hookscript,omitempty"` Hotplug string `json:"hotplug,omitempty"` // TODO should be a struct Ipconfig IpconfigMap `json:"ipconfig,omitempty"` // TODO should be part of a cloud-init struct (cloud-init option) @@ -212,9 +211,11 @@ func (config ConfigQemu) CreateVm(vmr *VmRef, client *Client) (err error) { return fmt.Errorf("error creating VM: %v, error status: %s (params: %v)", err, exitStatus, params) } - _, err = client.UpdateVMHA(vmr, config.HaState, config.HaGroup) - if err != nil { - return fmt.Errorf("[ERROR] %q", err) + if config.HA != nil { + err = config.HA.Set(vmr, client) + if err != nil { + return fmt.Errorf("error setting HA: %v", err) + } } return @@ -952,6 +953,11 @@ func (newConfig ConfigQemu) setAdvanced(currentConfig *ConfigQemu, rebootIfNeede return rebootRequired, nil } } + if newConfig.HA != nil { // Set HA + if err = newConfig.HA.Set_Unsafe(currentConfig.HA, vmr, client); err != nil { + return + } + } } else { // Create _, params, err = newConfig.mapToApiValues(ConfigQemu{}) if err != nil { @@ -964,11 +970,12 @@ func (newConfig ConfigQemu) setAdvanced(currentConfig *ConfigQemu, rebootIfNeede if err = resizeNewDisks(vmr, client, newConfig.Disks, nil); err != nil { return } - } - _, err = client.UpdateVMHA(vmr, newConfig.HaState, newConfig.HaGroup) - if err != nil { - return + if newConfig.HA != nil { // Create HA + if err = newConfig.HA.Set_Unsafe(nil, vmr, client); err != nil { + return + } + } } _, err = client.UpdateVMPool(vmr, newConfig.Pool) @@ -979,8 +986,12 @@ func (config ConfigQemu) Validate() (err error) { // TODO test all other use cases // TODO has no context about changes caused by updating the vm if config.Disks != nil { - err = config.Disks.Validate() - if err != nil { + if err = config.Disks.Validate(); err != nil { + return + } + } + if config.HA != nil { + if err = config.HA.Validate(); err != nil { return } } @@ -1221,9 +1232,11 @@ func (config ConfigQemu) UpdateConfig(vmr *VmRef, client *Client) (err error) { return err } - _, err = client.UpdateVMHA(vmr, config.HaState, config.HaGroup) - if err != nil { - log.Printf("[ERROR] %q", err) + if config.HA != nil { + err = config.HA.Set(vmr, client) + if err != nil { + log.Printf("[ERROR] %q", err) + } } _, err = client.UpdateVMPool(vmr, config.Pool) @@ -1279,14 +1292,9 @@ func NewConfigQemuFromApi(vmr *VmRef, client *Client) (config *ConfigQemu, err e config.defaults() - // HAstate is return by the api for a vm resource type but not the HAgroup - err = client.ReadVMHA(vmr) - if err == nil { - config.HaState = vmr.HaState() - config.HaGroup = vmr.HaGroup() - } else { - //log.Printf("[DEBUG] VM %d(%s) has no HA config", vmr.vmId, vmConfig["hostname"]) - return config, nil + config.HA, err = NewGuestHAFromApi(vmr, client) + if err != nil { + return } return } diff --git a/proxmox/config_qemu_test.go b/proxmox/config_qemu_test.go index 886643fe..ee423f5a 100644 --- a/proxmox/config_qemu_test.go +++ b/proxmox/config_qemu_test.go @@ -5812,6 +5812,10 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) { } } func Test_ConfigQemu_Validate(t *testing.T) { + PointerHaState := func(i HaState) *HaState { return &i } // TODO remove when we have a generic pointer function + PointerHaGroupName := func(i HaGroupName) *HaGroupName { return &i } // TODO remove when we have a generic pointer function + PointerHaRelocate := func(i HaRelocate) *HaRelocate { return &i } // TODO remove when we have a generic pointer function + PointerHaRestart := func(i HaRestart) *HaRestart { return &i } // TODO remove when we have a generic pointer function BandwidthValid0 := QemuDiskBandwidth{ MBps: QemuDiskBandwidthMBps{ ReadLimit: QemuDiskBandwidthMBpsLimit{ @@ -6015,6 +6019,18 @@ func Test_ConfigQemu_Validate(t *testing.T) { }}}, }}, }, + // Valid HA + {name: "Valid HA", + input: ConfigQemu{HA: &GuestHA{ + Group: PointerHaGroupName("test"), + Relocates: PointerHaRelocate(10), + Restarts: PointerHaRestart(0), + State: PointerHaState("started"), + }}, + }, + {name: "Valid HA Minimum", + input: ConfigQemu{HA: &GuestHA{}}, + }, // Invalid // Invalid Disks Mutually exclusive Ide {name: "Invalid Disks MutuallyExclusive Ide 0", @@ -7094,14 +7110,27 @@ func Test_ConfigQemu_Validate(t *testing.T) { input: ConfigQemu{Disks: &QemuStorages{VirtIO: &QemuVirtIODisks{Disk_13: &QemuVirtIOStorage{Passthrough: &QemuVirtIOPassthrough{File: "/dev/disk/by-id/scsi1", WorldWideName: "0x5004A3B2C1D0E0F1#"}}}}}, err: errors.New(Error_QemuWorldWideName_Invalid), }, + // Invalid HA + {name: "Invalid HA Group", + input: ConfigQemu{HA: &GuestHA{Group: PointerHaGroupName("^$#&@bdsh")}}, + err: errors.New(HaGroupName_Error_Illegal_Start), + }, + {name: "Invalid HA Reallocates", + input: ConfigQemu{HA: &GuestHA{Relocates: PointerHaRelocate(11)}}, + err: errors.New(HaRelocate_Error_UpperBound), + }, + {name: "Invalid HA Restart", + input: ConfigQemu{HA: &GuestHA{Restarts: PointerHaRestart(11)}}, + err: errors.New(HaRestart_Error_UpperBound), + }, + {name: "Invalid HA State", + input: ConfigQemu{HA: &GuestHA{State: PointerHaState("invalid")}}, + err: errors.New(HaState_Error_Invalid), + }, } for _, test := range testData { t.Run(test.name, func(*testing.T) { - if test.err != nil { - require.Equal(t, test.input.Validate(), test.err, test.name) - } else { - require.NoError(t, test.input.Validate(), test.name) - } + require.Equal(t, test.err, test.input.Validate(), test.name) }) } }