1240 lines
50 KiB
Go
1240 lines
50 KiB
Go
// Mgmt
|
|
// Copyright (C) 2013-2024+ James Shubin and the project contributors
|
|
// Written by James Shubin <james@shubin.ca> and the project contributors
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// Additional permission under GNU GPL version 3 section 7
|
|
//
|
|
// If you modify this program, or any covered work, by linking or combining it
|
|
// with embedded mcl code and modules (and that the embedded mcl code and
|
|
// modules which link with this program, contain a copy of their source code in
|
|
// the authoritative form) containing parts covered by the terms of any other
|
|
// license, the licensors of this program grant you additional permission to
|
|
// convey the resulting work. Furthermore, the licensors of this program grant
|
|
// the original author, James Shubin, additional permission to update this
|
|
// additional permission if he deems it necessary to achieve the goals of this
|
|
// additional permission.
|
|
|
|
package resources
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/purpleidea/mgmt/engine"
|
|
"github.com/purpleidea/mgmt/engine/traits"
|
|
"github.com/purpleidea/mgmt/util/errwrap"
|
|
|
|
"github.com/hetznercloud/hcloud-go/hcloud"
|
|
)
|
|
|
|
const (
|
|
// HetznerStateUndefined leaves the state undefined by default. This state
|
|
// is always treated as converged. Changes to other params are only applied
|
|
// when the server is in a state that is compatible with the operations
|
|
// needed to make that change.
|
|
HetznerStateUndefined = ""
|
|
// HetznerStateExists indicates that the server must exist, without
|
|
// differentiation between "off", "running" or any transient states.
|
|
// If the server was absent, a new server is created in "off" state, with
|
|
// one exception: if the last observed state before a rebuild was "running"
|
|
// or "starting", rebuildServer will set the new server to "running".
|
|
HetznerStateExists = "exists"
|
|
// HetznerStateRunning indicates that the server must be powered on. If the
|
|
// server was absent, a new server is created in "running" state.
|
|
HetznerStateRunning = "running"
|
|
// HetznerStateOff indicates that the server must be powered off. If the
|
|
// server was absent, a new server is created in "off" state.
|
|
HetznerStateOff = "off"
|
|
// HetznerStateAbsent indicates that the server must be deleted/absent. If
|
|
// the server already existed, it is deleted. Note that this deletion is
|
|
// always executed if the "absent" state is explicitly specified!
|
|
HetznerStateAbsent = "absent"
|
|
|
|
// HetznerAllowRebuildError blocks any server rebuild requests in CheckApply
|
|
// and exits with an error. These rebuild requests occur when other resource
|
|
// params require a destructive rebuild to reach resource convergence. The
|
|
// error option is used by default to prevent unexpected server deletions.
|
|
HetznerAllowRebuildError = ""
|
|
// HetznerAllowRebuildIgnore blocks any server rebuild requests in
|
|
// CheckApply, but does not throw any errors. Instead, CheckApply must skip
|
|
// this rebuild, and continue further steps if possible. Use this option to
|
|
// prevent unexpected server deletions, without disrupting the mcl script.
|
|
HetznerAllowRebuildIgnore = "ignore"
|
|
// HetznerAllowRebuildIfNeeded allows server rebuilds within CheckApply.
|
|
// This is needed when the specified serverspecs are not (yet) aligned with
|
|
// the active instance. Use this option only if you are sure that you are
|
|
// not destroying any critical data or services!
|
|
HetznerAllowRebuildIfNeeded = "ifneeded"
|
|
|
|
// HetznerServerRescueDisabled disables rescue mode by default.
|
|
HetznerServerRescueDisabled = ""
|
|
// HetznerServerRescueTypeLinux32 is used to enable rescue mode with a
|
|
// linux32 image type.
|
|
HetznerServerRescueTypeLinux32 = "linux32"
|
|
// HetznerServerRescueTypeLinux64 is used to enable rescue mode with a
|
|
// linux64 image type.
|
|
HetznerServerRescueTypeLinux64 = "linux64"
|
|
// HetznerServerRescueTypeFreeBSD64 is used to enable rescue mode with a
|
|
// freebsd64 image type.
|
|
HetznerServerRescueTypeFreeBSD64 = "freebsd64"
|
|
|
|
// HetznerPollLimit sets a lower limit on polling interval in seconds.
|
|
// Since the Hetzner API supports requests at up to 3600 requests per hour,
|
|
// this limit is set to prevent rate limit errors in long term operation.
|
|
// NOTE: polling the same Hetzner project from multiple clients will require
|
|
// a larger polling interval to prevent the same rate limit error, since
|
|
// these requests all add to the query count of their shared project. It is
|
|
// recommended to use a polling interval of at least N seconds, with N the
|
|
// number of active hetzner:vm instances of the same project.
|
|
// NOTE: high rates of change to other params will require additional API
|
|
// queries at CheckApply. Increase the polling interval again to prevent
|
|
// rate limit errors if frequent updates are expected.
|
|
HetznerPollLimit = 1
|
|
|
|
// HetznerWaitIntervalLimit sets a lower limit on wait intervals in seconds.
|
|
// High request rates are allowed, but risk causing rate limit errors.
|
|
HetznerWaitIntervalLimit = 0
|
|
|
|
// HetznerWaitIntervalDefault sets a default wait interval in seconds.
|
|
// NOTE: use larger intervals when using many resources under the same
|
|
// Hetzner project, or when expecting consistently high rates of change to
|
|
// other resource parameters.
|
|
HetznerWaitIntervalDefault = 5
|
|
|
|
// HetznerWaitTimeoutDefault sets a default timeout limit in seconds.
|
|
HetznerWaitTimeoutDefault = 60 * 5
|
|
)
|
|
|
|
func init() {
|
|
engine.RegisterResource("hetzner:vm", func() engine.Res { return &HetznerVMRes{} })
|
|
}
|
|
|
|
// HetznerVMRes is a Hetzner cloud resource (1). It connects with the cloud API
|
|
// using the hcloud-go package provided by Hetzner. The API token for a new
|
|
// project must be generated manually, via the cloud console (2), before this
|
|
// resource can establish a connection with the API. One Hetzner resource
|
|
// represents one server instance, and multiple instances can be registered
|
|
// under the same project. A resource in the "absent" state only exists as a
|
|
// local mcl struct, and does not exist as server instance on Hetzner's side.
|
|
// NOTE: the Hetzner cloud console must be used to create a new project,
|
|
// generate the corresponding API token, and initialize the desired SSH keys.
|
|
// All registered SSH keys are used when creating a server, and a subset of
|
|
// those can be enabled for rescue mode via the "serverrescuekeys" param.
|
|
// NOTE: complete and up-to-date serverconfig options must be requested from the
|
|
// Hetzner API, but hcloud-go-getopts (3) provides a static reference.
|
|
// NOTE: this resources requires polling, via the "Meta:poll" param. The Hetzner
|
|
// API imposes a maximum rate of 3600 requests per hour that must be taken into
|
|
// account for intensive and/or long term operations. When running N hetzner:vm
|
|
// resources under the same Hetzner project, it is recommended to use a polling
|
|
// interval of at least N seconds. High rates of change to other params will
|
|
// require additional API requests at CheckApply. When frequent param updates
|
|
// are expected for long term operations, it is reommended to increase the
|
|
// polling interval again to prevent rate limit errors.
|
|
// NOTE: running multiple concurrent mcl scripts on the same resource might
|
|
// cause unexpected behavior in the API or the resource state. Use with care.
|
|
// TODO: build tests for hetzner:vm? But hcloud-go has no mocking package.
|
|
// 1) https://docs.hetzner.cloud/
|
|
// 2) https://console.hetzner.cloud/
|
|
// 3) https://github.com/jefmasereel/hcloud-go-getopts
|
|
type HetznerVMRes struct {
|
|
traits.Base
|
|
init *engine.Init
|
|
|
|
// APIToken specifies the unique API token corresponding to a Hetzner
|
|
// project. Keep this token private! It provides full access to this
|
|
// project, so a leaked token will be vulnerable to abuse. Read it from
|
|
// a local file or the mgmt deploy, or provide it directly as a string.
|
|
// NOTE: It must be generated manually via https://console.hetzner.cloud/.
|
|
// NOTE: This token is usually a 64 character alphanumeric string.
|
|
APIToken string `lang:"apitoken"`
|
|
|
|
// State specifies the desired state of the server instance. The supported
|
|
// options are "" (undefined), "absent", "exists", "off" and "running".
|
|
// HetznerStateUndefined ("") leaves the state undefined by default.
|
|
// HetznerStateExists ("exists") indicates that the server must exist.
|
|
// HetznerStateAbsent ("absent") indicates that the server must not exist.
|
|
// HetznerStateRunning ("running") tells the server it must be powered on.
|
|
// HetznerStateOff ("off") tells the server it must be powered off.
|
|
// NOTE: any other inputs will not pass Validate and result in an error.
|
|
// NOTE: setting the state of a live server to "absent" will delete all data
|
|
// and services that are located on that instance! Use with caution.
|
|
State string `lang:"state"`
|
|
|
|
// AllowRebuild provides flexible protection against unexpected server
|
|
// rebuilds. Any changes to the "servertype", "datacenter" or "image" params
|
|
// require a destructive rebuild, which deletes all data on that server.
|
|
// The user must explicitly allow these operations with AllowRebuild.
|
|
// Choose from three options: "ifneeded" allows all rebuilds that are needed
|
|
// by CheckApply to meet the specified params. "ignore" disables these
|
|
// rebuilds, but continues without error. The default option ("") disables
|
|
// always returns an error when CheckApply requests a rebuild.
|
|
// NOTE: Soft updates related to power and rescue mode are always allowed,
|
|
// because they are only required for explicit changes to resource fields.
|
|
// TODO: add AllowReboot if any indirect poweroffs are ever implemented.
|
|
AllowRebuild string `lang:"allowrebuild"`
|
|
|
|
// ServerType determines the machine type as defined by Hetzner. A complete
|
|
// and up-to-date list of options must be requested from the Hetzner API,
|
|
// but hcloud-go-getopts (url) provides a static reference. Basic servertype
|
|
// options include "cx11", "cx21", "cx31" etc.
|
|
// NOTE: make sure to check the price of the selected servertype! The listed
|
|
// examples are usually very cheap, but never free. Price and availability
|
|
// can also be dependent on the selected datacenter.
|
|
// https://github.com/JefMasereel/hcloud-go-getopts/
|
|
// TODO: set some kind of cost-based protection policy?
|
|
ServerType string `lang:"servertype"`
|
|
|
|
// Datacenter determines where the resource is hosted. A complete and
|
|
// up-to-date list of options must be requested from the Hetzner API, but
|
|
// hcloud-go-getopts (url) provides a static reference. The datacenter
|
|
// options include "nbg1-dc3", "fsn1-dc14", "hel1-dc2" etc.
|
|
// https://github.com/JefMasereel/hcloud-go-getopts/
|
|
Datacenter string `lang:"datacenter"`
|
|
|
|
// Image determines the operating system to be installed. A complete and
|
|
// up-to-date list of options must be requested from the Hetzner API, but
|
|
// hcloud-go-getopts (url) provides a static reference. The image type
|
|
// options include "centos-7", "ubuntu-18.04", "debian-10" etc.
|
|
// https://github.com/JefMasereel/hcloud-go-getopts/
|
|
Image string `lang:"image"`
|
|
|
|
// UserData can be used to run commands on the server instance at creation.
|
|
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html.
|
|
UserData string `lang:"userdata"`
|
|
|
|
// ServerRescueMode specifies the image type used when enabling rescue mode.
|
|
// The supported image types are "linux32", "linux64" and "freebsd64".
|
|
// Alternatively, leave this string empty to disable rescue mode (default).
|
|
// Other input values will not pass Validate and result in an error.
|
|
// NOTE: rescue mode can not be enabled if the server is absent.
|
|
// NOTE: Rescue mode can be used to log into the server over SSH and access
|
|
// the disks when the normal OS has trouble booting on its own.
|
|
ServerRescueMode string `lang:"serverrescuemode"`
|
|
|
|
// ServerRescueSSHKeys can be used to select a subset of keys that should be
|
|
// enabled for rescue mode operations over SSH. From all SSH keys known to
|
|
// the project client, choose a subset of keys by name, as an array of
|
|
// strings. New keys must first be added manually via the cloud console.
|
|
// An error is thrown if a given keyname is not recognized by the client.
|
|
// NOTE: live changes to this keylist while rescue mode is already enabled
|
|
// are not (yet) detected or applied by CheckApply.
|
|
// TODO: improve ssh key handling at checkApplyRescueMode and serverRebuild.
|
|
ServerRescueSSHKeys []string `lang:"serverrescuekeys"`
|
|
|
|
// WaitInterval is the interval in seconds that is used when waiting for
|
|
// transient states to converge between intermediate operations. A zero
|
|
// value causes the waiter to run without delays (burst requests). Although
|
|
// such burst requests are allowed, it is recommended to use a wait interval
|
|
// that keeps the total request rate under 3600 requests per hour. Take
|
|
// these factors into account: polling rate "Meta:poll", number of active
|
|
// resources under the same Hetzner project, and the expected rate of param
|
|
// updates. This will help to prevent rate limit errors.
|
|
WaitInterval uint32 `lang:"waitinterval"`
|
|
|
|
// WaitTimeout will cancel wait loops if they do not exit cleanly before
|
|
// the expected time in seconds, in order to detect defective loops and
|
|
// avoid unnecessary consumption of computational resources.
|
|
WaitTimeout uint32 `lang:"waittimeout"`
|
|
|
|
// client is required for hcloud-go to interact with the Hetzner API.
|
|
client *hcloud.Client
|
|
|
|
// server is a local copy of the server object returned by hcloud-go. If
|
|
// this is nil, the server is considered to be absent. Otherwise, this
|
|
// struct describes the properties of the server instance as registered with
|
|
// Hetzner at the time of the update request.
|
|
server *hcloud.Server
|
|
|
|
// serverconfig is a local copy of the serverCreateOpts struct generated
|
|
// with hcloud-go. This struct is dependent on the ServerType, Datacenter,
|
|
// Image and State params. These must be chosen from the valid options
|
|
// provided by Hetzner, see details on https://docs.hetzner.cloud/.
|
|
serverconfig hcloud.ServerCreateOpts
|
|
|
|
// lastObservedState is a local copy of the last observed state of the
|
|
// resource. This is used to determine the startAfterCreate option during
|
|
// server rebuilds when the state is "" (undefined).
|
|
lastObservedState hcloud.ServerStatus
|
|
|
|
// rescueKeys is a local copy of the array of SSH key values to be enabled
|
|
// in rescue mode, after formatting for direct use with hcloud-go.
|
|
rescueKeys []*hcloud.SSHKey
|
|
|
|
// rescueImage is a local copy of the image type used when rescue mode was
|
|
// enabled the last time, to give checkapplyrescuemode a static reference.
|
|
rescueImage hcloud.ServerRescueType
|
|
}
|
|
|
|
// Default returns some conservative defaults for this resource.
|
|
func (obj *HetznerVMRes) Default() engine.Res {
|
|
return &HetznerVMRes{
|
|
State: HetznerStateUndefined,
|
|
AllowRebuild: HetznerAllowRebuildError,
|
|
WaitInterval: HetznerWaitIntervalDefault,
|
|
WaitTimeout: HetznerWaitTimeoutDefault,
|
|
}
|
|
}
|
|
|
|
// Validate if the given param values are valid.
|
|
func (obj *HetznerVMRes) Validate() error {
|
|
|
|
// check for empty token
|
|
if obj.APIToken == "" {
|
|
return fmt.Errorf("empty token string")
|
|
}
|
|
|
|
// validate state param
|
|
switch obj.State {
|
|
case HetznerStateRunning, HetznerStateOff, HetznerStateAbsent:
|
|
// Valid: the server is in a well defined steady state.
|
|
case HetznerStateExists:
|
|
// Valid: the server exists (on, off or transient state).
|
|
case HetznerStateUndefined:
|
|
// Valid: the server state is left undefined (default).
|
|
default:
|
|
return fmt.Errorf("invalid state: %s", obj.State)
|
|
}
|
|
|
|
// validate allowrebuild
|
|
switch obj.AllowRebuild {
|
|
case HetznerAllowRebuildError, HetznerAllowRebuildIgnore, HetznerAllowRebuildIfNeeded:
|
|
// ok
|
|
default:
|
|
return fmt.Errorf("invalid allowrebuild: %s", obj.AllowRebuild)
|
|
}
|
|
|
|
// validate rescue mode parameters
|
|
switch obj.ServerRescueMode {
|
|
case HetznerServerRescueTypeLinux32, HetznerServerRescueTypeLinux64, HetznerServerRescueTypeFreeBSD64:
|
|
// valid options for rescue mode image
|
|
case HetznerServerRescueDisabled:
|
|
// valid option to disable rescue mode
|
|
default:
|
|
return fmt.Errorf("invalid serverrescuemode: %s", obj.ServerRescueMode)
|
|
}
|
|
|
|
// validate time params
|
|
if obj.MetaParams().Poll < HetznerPollLimit {
|
|
return fmt.Errorf("invalid polling interval (minimum %d s)", HetznerPollLimit)
|
|
}
|
|
if obj.WaitInterval < HetznerWaitIntervalLimit {
|
|
return fmt.Errorf("invalid wait interval (minimum %d)", HetznerWaitIntervalLimit)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Init runs some startup code for this resource: initialize hcloud-go client,
|
|
// and then build some internal flags from the given public fields.
|
|
func (obj *HetznerVMRes) Init(init *engine.Init) error {
|
|
|
|
// save init struct
|
|
obj.init = init
|
|
|
|
// initialize hcloud-go client
|
|
obj.client = hcloud.NewClient(
|
|
hcloud.WithToken(obj.APIToken),
|
|
hcloud.WithApplication(obj.init.Program, obj.init.Version),
|
|
// TODO: hcloud.WithEndpoint(),
|
|
// TODO: hcloud.WithDebugWriter(),
|
|
)
|
|
|
|
// warn user about AllowRebuild setting
|
|
switch obj.AllowRebuild {
|
|
case HetznerAllowRebuildError:
|
|
obj.init.Logf("warning: server rebuild requests will be blocked with error")
|
|
case HetznerAllowRebuildIgnore:
|
|
obj.init.Logf("warning: server rebuild requests will be skipped without error")
|
|
case HetznerAllowRebuildIfNeeded:
|
|
obj.init.Logf("warning: server rebuild requests will be applied without error")
|
|
}
|
|
|
|
// warn user about late serverconfig validation
|
|
obj.init.Logf("warning: serverconfig options will only be validated during checkapply")
|
|
|
|
// warn user about timing requirements
|
|
obj.init.Logf("warning: Meta:poll must always be greater or equal than %d seconds", HetznerPollLimit)
|
|
obj.init.Logf("warning: waitinterval must always be greater or equal than %d seconds", HetznerWaitIntervalLimit)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Cleanup is run by the engine to clean up after the resource is done. It
|
|
// deletes the authentication info before closing the resource.
|
|
func (obj *HetznerVMRes) Cleanup() error {
|
|
obj.APIToken = ""
|
|
obj.client = nil
|
|
return nil
|
|
}
|
|
|
|
// Watch is not implemented for this resource, since the Hetzner API does not
|
|
// provide any event streams. Instead, always use polling.
|
|
// NOTE: HetznerPollLimit sets an explicit minimum on the polling interval.
|
|
func (obj *HetznerVMRes) Watch(context.Context) error {
|
|
return fmt.Errorf("invalid Watch call: requires poll metaparam")
|
|
}
|
|
|
|
// CheckApply checks the resource state and determines what needs to happen for
|
|
// the HetznerVM resource to converge. It only applies the necessary changes if
|
|
// the bool apply is true. If the resource requires changes, CheckApply returns
|
|
// false regardless of the apply value, true otherwise. Any errors that might
|
|
// occur are wrapped and returned.
|
|
// NOTE: all functions that push changes to the Hetzner instance run a waitUntil
|
|
// call with the appropriate exit condition before returning, such that the
|
|
// requested operation is confirmed before continuing. This ensures that the
|
|
// "server" struct always contains up-to-date info of the live instance.
|
|
// NOTE: this last assumption might still fail in case the same resource
|
|
// instance is managed by multiple running mgmt instances!
|
|
// TODO: possible to ensure safe concurrency?
|
|
func (obj *HetznerVMRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
|
|
checkOK := true
|
|
// Request up-to-date server info from the API.
|
|
if err := obj.getServerUpdate(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "getServerUpdate failed")
|
|
}
|
|
// Try to get the server in the correct state (if it is not already there).
|
|
// NOTE: in case of undefined state, this always returns (true, nil).
|
|
if c, err := obj.checkApplyServerState(ctx, apply); err != nil {
|
|
return false, errwrap.Wrapf(err, "checkApplyServerState failed")
|
|
} else if !c {
|
|
checkOK = false
|
|
}
|
|
// If the intended state was not reached, exit here.
|
|
// NOTE: this prevents unnecessary checks and operations.
|
|
// NOTE: undefined state will pass! Further steps are applied if possible.
|
|
if stateOK, err := obj.serverStateConverged(); err != nil {
|
|
return false, errwrap.Wrapf(err, "serverStateConverged failed")
|
|
} else if !stateOK {
|
|
return false, nil
|
|
}
|
|
// Changes in cpu, location and/or image require a server rebuild.
|
|
// NOTE: these changes are only applied if the server exists.
|
|
if c, err := obj.checkApplyServerRebuild(ctx, apply); err != nil {
|
|
return false, errwrap.Wrapf(err, "checkApplyServerRebuild failed")
|
|
} else if !c {
|
|
checkOK = false
|
|
}
|
|
// Changes in rescue mode can be made without a destructive rebuild.
|
|
// NOTE: these changes are only applied if the server is running.
|
|
if c, err := obj.checkApplyRescueMode(ctx, apply); err != nil {
|
|
return false, errwrap.Wrapf(err, "checkApplyRescueMode failed")
|
|
} else if !c {
|
|
checkOK = false
|
|
}
|
|
return checkOK, nil
|
|
}
|
|
|
|
// checkApplyServerState tries to get the server in the correct state. If it is
|
|
// already there (converged), no changes are applied. In case of the undefined
|
|
// state, this function immediately returns (true, nil). Otherwise, it powers
|
|
// the server on/off, creates a new instance, or deletes the existing one as
|
|
// needed to reach the specified state.
|
|
// NOTE: the output arguments follow the rules of CheckApply: If the resource
|
|
// requires changes, CheckApply returns false regardless of the apply value,
|
|
// true otherwise. Any errors that might occur are wrapped and returned.
|
|
func (obj *HetznerVMRes) checkApplyServerState(ctx context.Context, apply bool) (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("checkApplyServerState(apply: %t)", apply)
|
|
}
|
|
// Exit immediately if the server state is undefined.
|
|
if obj.State == HetznerStateUndefined {
|
|
return true, nil
|
|
}
|
|
// Make sure the server exists as intended before further checks.
|
|
serverCreationRequired := false
|
|
if obj.server == nil {
|
|
// The server doesn't exist as intended (state = "absent").
|
|
if obj.State == HetznerStateAbsent {
|
|
return true, nil
|
|
}
|
|
// Otherwise, the server should exist, but doesn't (yet).
|
|
serverCreationRequired = true
|
|
if !apply {
|
|
return false, nil
|
|
}
|
|
// Request the creation of a new server.
|
|
if err := obj.createServer(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "createServer failed")
|
|
}
|
|
}
|
|
// If the resource only needs to exist, exit here.
|
|
if obj.State == HetznerStateExists {
|
|
return !serverCreationRequired, nil
|
|
}
|
|
// Otherwise, continue if/once the resource is in a steady state.
|
|
if err := obj.waitUntil(ctx, obj.serverInSteadyState); err != nil {
|
|
return false, errwrap.Wrapf(err, "waitUntil(serverInSteadyState) exited early")
|
|
}
|
|
// If the state has already converged, exit here.
|
|
stateConverged, err := obj.serverStateConverged()
|
|
if err != nil {
|
|
return false, errwrap.Wrapf(err, "serverStateConverged failed")
|
|
}
|
|
if checkOK := (stateConverged && !serverCreationRequired); stateConverged {
|
|
return checkOK, nil
|
|
}
|
|
// Otherwise, the server is in a steady state, but not the right one.
|
|
if !apply {
|
|
return false, nil
|
|
}
|
|
// Apply the necessary changes to get to the specified state.
|
|
switch obj.State {
|
|
case HetznerStateRunning:
|
|
if err := obj.powerServerOn(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "powerServerOn failed")
|
|
}
|
|
case HetznerStateOff:
|
|
if err := obj.powerServerOff(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "powerServerOff failed")
|
|
}
|
|
case HetznerStateAbsent:
|
|
if err := obj.deleteServer(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "deleteServer failed")
|
|
}
|
|
default:
|
|
return false, fmt.Errorf("invalid state: %s", obj.State)
|
|
}
|
|
// All required state changes were applied without error.
|
|
return false, nil
|
|
}
|
|
|
|
// checkApplyServerRebuild checks the servertype, datacenter and image values of
|
|
// the live instance, and tries to rebuild the server when that is required to
|
|
// match the specified params.
|
|
// NOTE: AllowRebuild protects the user against unexpected server deletions.
|
|
// NOTE: the output arguments follow the rules of CheckApply: If the resource
|
|
// requires changes, CheckApply returns false regardless of the apply value,
|
|
// true otherwise. Any errors that might occur are wrapped and returned.
|
|
func (obj *HetznerVMRes) checkApplyServerRebuild(ctx context.Context, apply bool) (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("checkApplyServerRebuild(apply: %t)", apply)
|
|
}
|
|
// Exit immediately if the server does not exist.
|
|
if obj.server == nil {
|
|
return false, nil
|
|
}
|
|
// Compare ServerType, Datacenter and Image params.
|
|
specsOK, err := obj.cmpServerSpecs()
|
|
if err != nil {
|
|
return false, errwrap.Wrapf(err, "cmpServerSpecs failed")
|
|
}
|
|
if specsOK {
|
|
return true, nil
|
|
}
|
|
if !apply {
|
|
return false, nil
|
|
}
|
|
// Rebuild the server to meet specs (if AllowRebuild passes).
|
|
// NOTE: if "undefined", this tries to match the last observed state.
|
|
if err := obj.rebuildServer(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "rebuildServer failed")
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// checkApplyRescueMode checks if the rescue mode is enabled (or disabled) as
|
|
// intended, and tries to disable (or enable) the rescue mode if needed to meet
|
|
// the specified parameters. When enabling rescue mode, the SSH keys specified
|
|
// by ServerRescueSSHKeys are validated and enabled for rescue login over SSH.
|
|
// NOTE: rescue mode changes require steady state ("off" or "running").
|
|
// NOTE: the output arguments follow the rules of CheckApply: If the resource
|
|
// requires changes, CheckApply returns false regardless of the apply value,
|
|
// true otherwise. Any errors that might occur are wrapped and returned.
|
|
// NOTE: switching image type in ServerRescueMode triggers this checkapply, but
|
|
// dynamic changes to the SSH keys are not yet supported.
|
|
// TODO: add "undefined" option for HetznerServerRescueMode? default?
|
|
// TODO: add support for rescue login via root password?
|
|
func (obj *HetznerVMRes) checkApplyRescueMode(ctx context.Context, apply bool) (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("checkApplyRescueMode(apply: %t)", apply)
|
|
}
|
|
// Exit immediately if the server is absent.
|
|
// NOTE: an absent server is treated as rescue mode "disabled".
|
|
if obj.server == nil {
|
|
if obj.ServerRescueMode == HetznerServerRescueDisabled {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
// Exit if the server is not in a steady state ("running" or "off").
|
|
// NOTE: otherwise the "server is locked" when trying to enable or disable.
|
|
stateOK, err := obj.serverInSteadyState()
|
|
if err != nil {
|
|
return false, errwrap.Wrapf(err, "serverInSteadyState failed")
|
|
}
|
|
if !stateOK {
|
|
return false, nil
|
|
}
|
|
// Exit if rescue mode is already in the intended configuration.
|
|
// TODO: add check for ssh keys? Only checking rescueImage.
|
|
rescueModeOK, err := obj.rescueModeConverged()
|
|
if err != nil {
|
|
return false, errwrap.Wrapf(err, "rescueModeConverged failed")
|
|
}
|
|
if rescueModeOK {
|
|
return true, nil
|
|
}
|
|
if !apply {
|
|
return false, nil
|
|
}
|
|
// Disable rescue mode to match specs, or to re-enable with new image type.
|
|
if err := obj.disableRescueMode(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "disableRescueMode failed")
|
|
}
|
|
// Enable rescue mode if specified.
|
|
if obj.ServerRescueMode != HetznerServerRescueDisabled {
|
|
if err := obj.enableRescueMode(ctx); err != nil {
|
|
return false, errwrap.Wrapf(err, "enableRescueMode failed")
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// getServerUpdate pings the Hetzner API for up-to-date server info.
|
|
// NOTE: if obj.server is nil, the server is considered to be in "absent" state.
|
|
func (obj *HetznerVMRes) getServerUpdate(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("getServerUpdate()")
|
|
}
|
|
server, _, err := obj.client.Server.GetByName(ctx, obj.Name())
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed serverupdate request")
|
|
}
|
|
obj.server = server
|
|
return nil
|
|
}
|
|
|
|
// Cmp compares two resource structs. Returns nil if the comparison holds true,
|
|
// otherwise an error is thrown to identify the difference.
|
|
func (obj *HetznerVMRes) Cmp(r engine.Res) error {
|
|
// check if empty
|
|
if obj == nil && r == nil {
|
|
return nil
|
|
}
|
|
if (obj == nil) != (r == nil) {
|
|
return fmt.Errorf("one resource is empty")
|
|
}
|
|
// compare types
|
|
res, ok := r.(*HetznerVMRes)
|
|
if !ok {
|
|
return fmt.Errorf("not a %s", obj.Kind())
|
|
}
|
|
// compare resource fields
|
|
if obj.APIToken != res.APIToken {
|
|
return fmt.Errorf("apitoken differs")
|
|
}
|
|
if obj.State != res.State {
|
|
return fmt.Errorf("state differs")
|
|
}
|
|
if obj.AllowRebuild != res.AllowRebuild {
|
|
return fmt.Errorf("allowrebuild differs")
|
|
}
|
|
if obj.ServerType != res.ServerType {
|
|
return fmt.Errorf("servertype differs")
|
|
}
|
|
if obj.Datacenter != res.Datacenter {
|
|
return fmt.Errorf("datacenter differs")
|
|
}
|
|
if obj.Image != res.Image {
|
|
return fmt.Errorf("image differs")
|
|
}
|
|
if obj.UserData != res.UserData {
|
|
return fmt.Errorf("userdata differs")
|
|
}
|
|
if obj.ServerRescueMode != res.ServerRescueMode {
|
|
return fmt.Errorf("serverrescuemode differs")
|
|
}
|
|
// TODO: more robust comparison of keylists
|
|
for i, key := range obj.ServerRescueSSHKeys {
|
|
if key != res.ServerRescueSSHKeys[i] {
|
|
return fmt.Errorf("serverrescuekeys differ")
|
|
}
|
|
}
|
|
if obj.WaitInterval != res.WaitInterval {
|
|
return fmt.Errorf("waitinterval differs")
|
|
}
|
|
if obj.WaitTimeout != res.WaitTimeout {
|
|
return fmt.Errorf("waittimeout differs")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cmpServerSpecs compares the server specifications between the local mcl
|
|
// struct HetznerVMRes and the corresponding server instance. Returns true if
|
|
// ServerType, Datacenter and Image match. Returns an error if the server is
|
|
// absent.
|
|
func (obj *HetznerVMRes) cmpServerSpecs() (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("cmpServerSpecs()")
|
|
}
|
|
if obj.server == nil {
|
|
return false, fmt.Errorf("server is unavailable")
|
|
}
|
|
if obj.ServerType != obj.server.ServerType.Name {
|
|
return false, nil
|
|
}
|
|
if obj.Datacenter != obj.server.Datacenter.Name {
|
|
return false, nil
|
|
}
|
|
if obj.Image != obj.server.Image.Name {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// powerServerOn requests a poweron for the specified server, then waits until
|
|
// the new "running" state is confirmed. Returns an error if the specified
|
|
// server is absent, or if waitUntil exits early due to timeout, context
|
|
// cancellation or another error.
|
|
func (obj *HetznerVMRes) powerServerOn(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("powerServerOn()")
|
|
}
|
|
if obj.server == nil {
|
|
return fmt.Errorf("server is unavailable")
|
|
}
|
|
if _, _, err := obj.client.Server.Poweron(ctx, obj.server); err != nil {
|
|
return errwrap.Wrapf(err, "client.Server.Poweron failed")
|
|
}
|
|
// Wait until the poweron is confirmed, error otherwise.
|
|
if err := obj.waitUntil(ctx, obj.serverStateIs(HetznerStateRunning)); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(serverStateIs(Running)) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// powerServerOff requests a poweroff for the specified server, then waits until
|
|
// the new "off" state is confirmed. Returns an error if the specified server is
|
|
// absent, or if waitUntil exits early due to timeout, context cancellation or
|
|
// another error.
|
|
func (obj *HetznerVMRes) powerServerOff(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("powerServerOff()")
|
|
}
|
|
if obj.server == nil {
|
|
return fmt.Errorf("server is unavailable")
|
|
}
|
|
if _, _, err := obj.client.Server.Poweroff(ctx, obj.server); err != nil {
|
|
return errwrap.Wrapf(err, "client.Server.Poweroff failed")
|
|
}
|
|
// Wait until the poweroff is confirmed, error otherwise.
|
|
if err := obj.waitUntil(ctx, obj.serverStateIs(HetznerStateOff)); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(serverStateIs(Off)) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createServer checks if the servername does not already exists, builds the
|
|
// serverconfig in hcloud-go format from resource params, requests a server
|
|
// creation with that configuration, and waits until the creation is confirmed.
|
|
// Errors occur when the server exists already, the client fails, or the wait
|
|
// step exits early due context cancellation, client failure or timeout.
|
|
// NOTE: the startAfterCreate option is used to reach "running" state faster for
|
|
// two cases. When the state is specified as "running", or when the state is ""
|
|
// (undefined) and the last observed serverstatus was "running" or "starting".
|
|
func (obj *HetznerVMRes) createServer(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("createServer()")
|
|
}
|
|
if obj.server != nil {
|
|
return fmt.Errorf("server already exists")
|
|
}
|
|
if err := obj.getServerConfig(ctx); err != nil {
|
|
return errwrap.Wrapf(err, "getServerConfig failed")
|
|
}
|
|
if obj.serverconfig.SSHKeys == nil {
|
|
obj.init.Logf("warning: no ssh keys registered for server creation")
|
|
}
|
|
if _, _, err := obj.client.Server.Create(ctx, obj.serverconfig); err != nil {
|
|
return errwrap.Wrapf(err, "client.server.create failed")
|
|
}
|
|
if err := obj.waitUntil(ctx, obj.serverStateIs(HetznerStateExists)); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(serverExists) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteServer checks if the server is available from the client, requests a
|
|
// server deletion from the API, waits for confirmation and then returns. It
|
|
// returns an error when the server is already absent or something fails.
|
|
// Context cancellation allows a clean exit when needed.
|
|
// NOTE: a direct deleteServer call is never blocked. Use with caution.
|
|
func (obj *HetznerVMRes) deleteServer(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("deleteServer()")
|
|
}
|
|
if obj.server == nil {
|
|
return fmt.Errorf("server is already unavailable")
|
|
}
|
|
if _, err := obj.client.Server.Delete(ctx, obj.server); err != nil {
|
|
return errwrap.Wrapf(err, "client.server.delete failed")
|
|
}
|
|
if err := obj.waitUntil(ctx, obj.serverStateIs(HetznerStateAbsent)); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(serverStateIs(Absent)) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rebuildServer deletes the current server instance and creates a new one, in
|
|
// accordance with the provided resource specifications. If the state is ""
|
|
// (undefined), this function tries to match the last observed state of the live
|
|
// instance. If that last observed state is "absent", rebuild returns nil
|
|
// without creating a new server. Otherwise, the server must exist, and absence
|
|
// will result in an error.
|
|
// NOTE: AllowRebuild protects the user against unexpected server deletions:
|
|
// AllowRebuildError blocks deletion with error, AllowRebuildIgnore blocks
|
|
// deletion without error, and HetznerAllowRebuildIfNeeded allows deletion.
|
|
func (obj *HetznerVMRes) rebuildServer(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("rebuildServer()")
|
|
}
|
|
// Exit immediately if the server is absent.
|
|
if obj.server == nil {
|
|
// Leave undefined server as is, rebuild if/when it becomes available.
|
|
if obj.State == HetznerStateUndefined {
|
|
return nil
|
|
}
|
|
// Otherwise there is no reason to allow absence.
|
|
return fmt.Errorf("server is unavailable")
|
|
}
|
|
// Exit if rebuild is not allowed.
|
|
if obj.AllowRebuild == HetznerAllowRebuildError {
|
|
// exit without applying changes, throw error
|
|
return fmt.Errorf("server rebuild blocked, requires deletion")
|
|
}
|
|
if obj.AllowRebuild == HetznerAllowRebuildIgnore {
|
|
// exit without applying changes, but no error
|
|
return nil
|
|
}
|
|
// If the server exists but is undefined, save a temporary copy of the last
|
|
// observed state. This will be used to create the appropriate serverconfig.
|
|
if obj.State == HetznerStateUndefined {
|
|
obj.lastObservedState = obj.server.Status
|
|
}
|
|
// Rebuild.
|
|
if err := obj.deleteServer(ctx); err != nil {
|
|
return errwrap.Wrapf(err, "deleteServer failed")
|
|
}
|
|
if err := obj.createServer(ctx); err != nil {
|
|
return errwrap.Wrapf(err, "createServer failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getServerConfig builds a serverconfig struct based on the given resource
|
|
// parameters, such that this serverconfig can be used to create a new server
|
|
// instance that matches the specified parameters. Errors can occur if the
|
|
// params used to construct serverconfig contain invalid arguments, or if the
|
|
// client fails.
|
|
// NOTE: the startAfterCreate option is used to reach "running" state faster for
|
|
// two cases. When the state is specified as "running", or when the state is ""
|
|
// (undefined) and the last observed serverstatus was "running" or "starting".
|
|
// TODO: add option to define Location xor Datacenter (never both!).
|
|
func (obj *HetznerVMRes) getServerConfig(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("getServerConfig()")
|
|
}
|
|
// default, volumes not supported (yet)
|
|
// TODO: add support for volume selection?
|
|
automount := false
|
|
// poweron at creation to reach "running" faster
|
|
startAfterCreate := false
|
|
if obj.State == HetznerStateRunning {
|
|
startAfterCreate = true
|
|
}
|
|
if obj.State == HetznerStateUndefined {
|
|
switch obj.lastObservedState {
|
|
case hcloud.ServerStatusRunning, hcloud.ServerStatusStarting:
|
|
startAfterCreate = true
|
|
default:
|
|
// leave powered off
|
|
}
|
|
}
|
|
// collect serverconfig elements
|
|
serverType, _, err := obj.client.ServerType.GetByName(ctx, obj.ServerType)
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed to collect ServerType struct")
|
|
}
|
|
image, _, err := obj.client.Image.GetByName(ctx, obj.Image)
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed to collect Image struct")
|
|
}
|
|
datacenter, _, err := obj.client.Datacenter.GetByName(ctx, obj.Datacenter)
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed to collect Datacenter struct")
|
|
}
|
|
// TODO: add more flexible key selection
|
|
keylist, err := obj.client.SSHKey.All(ctx)
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed to collect SSHKey array")
|
|
}
|
|
// NOTE: GetByName will return nil in case the given name is unknown.
|
|
if serverType == nil {
|
|
return fmt.Errorf("unknown servertype: %s", obj.ServerType)
|
|
}
|
|
if image == nil {
|
|
return fmt.Errorf("unknown image: %s", obj.Image)
|
|
}
|
|
if datacenter == nil {
|
|
return fmt.Errorf("unknown datacenter: %s", obj.Datacenter)
|
|
}
|
|
// build serverconfig from given specs & defaults
|
|
obj.serverconfig = hcloud.ServerCreateOpts{
|
|
Name: obj.Name(), // string
|
|
ServerType: serverType, // *ServerType
|
|
Image: image, // *Image
|
|
SSHKeys: keylist, // []*SSHKey
|
|
Location: nil, // *Location
|
|
Datacenter: datacenter, // *Datacenter
|
|
UserData: obj.UserData, // string
|
|
StartAfterCreate: &startAfterCreate, // *bool
|
|
Labels: nil, // map[string]string
|
|
Automount: &automount, // *bool
|
|
Volumes: nil, // []*Volume
|
|
Networks: nil, // []*Network
|
|
Firewalls: nil, // []*ServerCreateFirewall
|
|
PlacementGroup: nil, // *PlacementGroup
|
|
}
|
|
// hcloud-go provides basic validation, but this can still miss problems!
|
|
// TODO: add tests? If issues come up, add checks to Validate.
|
|
if err := hcloud.ServerCreateOpts.Validate(obj.serverconfig); err != nil {
|
|
return errwrap.Wrapf(err, "invalid serverconfig")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// enableRescueMode tries to enable rescue mode for the specified server, then
|
|
// waits until the operation is confirmed. Returns an error if the server is not
|
|
// in steady state, if an intermediate API request fails, if waitUntil exits
|
|
// early or in case of context cancellation.
|
|
// NOTE: the EnableRescue request requires steady state ("off" or "running").
|
|
func (obj *HetznerVMRes) enableRescueMode(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("enableRescueMode()")
|
|
}
|
|
// Exit immediately if the server is absent.
|
|
if obj.server == nil {
|
|
return fmt.Errorf("server is unavailable")
|
|
}
|
|
// Exit if rescue mode is already enabled.
|
|
if obj.server.RescueEnabled {
|
|
return nil
|
|
}
|
|
// Exit if the server is not in a steady state ("running" or "off").
|
|
// NOTE: otherwise the "server is locked" when trying to enable.
|
|
stateOK, err := obj.serverInSteadyState()
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "serverInSteadyState failed")
|
|
}
|
|
if !stateOK {
|
|
return fmt.Errorf("state must be 'running' or 'off' (now: %s)", obj.server.Status)
|
|
}
|
|
// Format rescueImage and rescueKeys, then enable rescue mode.
|
|
// NOTE: rescueImage and rescueKeys also provide a checkapply reference.
|
|
obj.rescueImage = hcloud.ServerRescueType(obj.ServerRescueMode)
|
|
if err := obj.getRescueKeys(ctx); err != nil {
|
|
return errwrap.Wrapf(err, "getRescueKeys failed")
|
|
}
|
|
opts := hcloud.ServerEnableRescueOpts{
|
|
Type: obj.rescueImage,
|
|
SSHKeys: obj.rescueKeys,
|
|
}
|
|
if _, _, err := obj.client.Server.EnableRescue(ctx, obj.server, opts); err != nil {
|
|
return errwrap.Wrapf(err, "client.Server.EnableRescue failed")
|
|
}
|
|
// NOTE: EnableRescue returns a root password, but this is ignored in favor
|
|
// of connecting to the server in rescue mode over SSH.
|
|
// TODO: add support for password login? SSH usually ok.
|
|
|
|
// Wait until the rescue enable is confirmed.
|
|
if err := obj.waitUntil(ctx, obj.rescueModeEnabled); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(rescueModeEnabled) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// disableRescueMode tries to disable rescue mode for the specified server, then
|
|
// waits until the operation is confirmed. It returns early if the rescue mode
|
|
// is already disabled. Returns an error if an intermediate API request fails,
|
|
// if waitUntil exits early, or in case of context cancellation.
|
|
// NOTE: an absent server is treated as a disabled serverrescuemode.
|
|
// NOTE: the DisableRescue request requires steady state ("off" or "running").
|
|
func (obj *HetznerVMRes) disableRescueMode(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("disableRescueMode()")
|
|
}
|
|
// Exit immediately if rescue mode is already disabled.
|
|
if obj.server == nil {
|
|
return nil
|
|
}
|
|
if !obj.server.RescueEnabled {
|
|
return nil
|
|
}
|
|
// Exit if the server is not in a steady state ("running" or "off").
|
|
// NOTE: otherwise the "server is locked" when trying to enable.
|
|
stateOK, err := obj.serverInSteadyState()
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "serverInSteadyState failed")
|
|
}
|
|
if !stateOK {
|
|
return fmt.Errorf("state must be 'running' or 'off' (now: %s)", obj.server.Status)
|
|
}
|
|
// Disable rescue mode.
|
|
if _, _, err := obj.client.Server.DisableRescue(ctx, obj.server); err != nil {
|
|
return errwrap.Wrapf(err, "client.Server.EnableRescue failed")
|
|
}
|
|
// Wait until the rescue disable is confirmed.
|
|
if err := obj.waitUntil(ctx, obj.rescueModeDisabled); err != nil {
|
|
return errwrap.Wrapf(err, "waitUntil(rescueModeDisabled) exited early")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getRescueKeys builds a list of keys to be enabled for rescue mode over SSH.
|
|
// ServerRescueSSHKeys provides the selected keys as []string by name. The
|
|
// corresponding data is collected with the Hetzner client (if valid). The
|
|
// resulting keylist is formatted as []*hcloud.SSHKey for use with hcloud, and
|
|
// saved for later use in private field rescueKeys.
|
|
// TODO: standardize this so that it can also be used for serverconfig keys.
|
|
func (obj *HetznerVMRes) getRescueKeys(ctx context.Context) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("getRescueKeys()")
|
|
}
|
|
var keylist []*hcloud.SSHKey
|
|
for _, keyname := range obj.ServerRescueSSHKeys {
|
|
key, _, err := obj.client.SSHKey.GetByName(ctx, keyname)
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "SSHKey GetByName(%s) failed", keyname)
|
|
}
|
|
if key == nil {
|
|
return fmt.Errorf("unknown keyname: %s", keyname)
|
|
}
|
|
if obj.init.Debug {
|
|
obj.init.Logf("appending known key: %s", keyname)
|
|
}
|
|
keylist = append(keylist, key)
|
|
}
|
|
obj.rescueKeys = keylist
|
|
return nil
|
|
}
|
|
|
|
// waitUntil provides a general function that waits until the provided exit
|
|
// condition is satisfied. It retries every WaitInterval until the condition is
|
|
// satisfied. It can exit early in case the WaitTimeout is reached, the context
|
|
// is cancelled or an error occurs. Otherwise it returns nil once the condition
|
|
// is satisfied. The exit condition must check a well-defined condition for the
|
|
// resource, and return true if satisfied, false otherwise. The condition must
|
|
// check its logic without API requests, so no context is needed.
|
|
func (obj *HetznerVMRes) waitUntil(ctx context.Context, condition func() (bool, error)) error {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("waitUntil()")
|
|
}
|
|
timeout := time.After(time.Duration(obj.WaitTimeout) * time.Second)
|
|
for {
|
|
// Get up-to-date serverinfo.
|
|
if err := obj.getServerUpdate(ctx); err != nil {
|
|
return errwrap.Wrapf(err, "failed serverupdate request")
|
|
}
|
|
// Check if the provided exit condition is satisfied.
|
|
conditionSatisfied, err := condition()
|
|
if err != nil {
|
|
return errwrap.Wrapf(err, "failed to confirm exit condition")
|
|
}
|
|
if conditionSatisfied {
|
|
return nil
|
|
}
|
|
// Retry every WaitInterval until the exit condition is satisfied.
|
|
// Can exit early by timeout, context cancellation or an error.
|
|
select {
|
|
case <-time.After(time.Duration(obj.WaitInterval) * time.Second):
|
|
// retry confirmation
|
|
case <-timeout:
|
|
return fmt.Errorf("timeout: exit condition not confirmed after %d seconds", obj.WaitTimeout)
|
|
case <-ctx.Done():
|
|
return errwrap.Wrapf(ctx.Err(), "wait interrupted by context")
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverStateConverged checks if the target server is in the desired state.
|
|
// Returns true if the client confirms that the state is "exists", "running",
|
|
// "off" or "absent" as intended. An undefined state "" always returns true.
|
|
// Otherwise, this function returns false. Invalid states result in an error.
|
|
func (obj *HetznerVMRes) serverStateConverged() (converged bool, err error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("serverStateConverged()")
|
|
}
|
|
// always return true if undefined
|
|
if obj.State == HetznerStateUndefined {
|
|
return true, nil
|
|
}
|
|
// return true if absent as intended
|
|
if obj.server == nil {
|
|
if obj.State == HetznerStateAbsent {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
// convergence cases if the server exists
|
|
switch obj.State {
|
|
case HetznerStateAbsent:
|
|
// false, nil
|
|
case HetznerStateExists:
|
|
converged = true
|
|
case HetznerStateRunning:
|
|
converged = (obj.server.Status == hcloud.ServerStatusRunning)
|
|
case HetznerStateOff:
|
|
converged = (obj.server.Status == hcloud.ServerStatusOff)
|
|
default:
|
|
err = fmt.Errorf("invalid state: %s", obj.State)
|
|
}
|
|
return converged, err
|
|
}
|
|
|
|
// serverInSteadyState returns true if the server is in one of the two known
|
|
// steady states, i.e. "running" or "off", and false otherwise. Any other states
|
|
// are either transients or "absent", so it is safe to return false without
|
|
// errors and try again later if needed.
|
|
func (obj *HetznerVMRes) serverInSteadyState() (steady bool, err error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("serverInSteadyState()")
|
|
}
|
|
if obj.server == nil {
|
|
return false, nil
|
|
}
|
|
switch obj.server.Status {
|
|
case hcloud.ServerStatusRunning, hcloud.ServerStatusOff:
|
|
return true, nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// rescueModeEnabled returns true if rescue mode is enabled, false otherwise.
|
|
func (obj *HetznerVMRes) rescueModeEnabled() (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("rescueModeEnabled()")
|
|
}
|
|
if obj.server == nil {
|
|
return false, nil
|
|
}
|
|
if obj.server.RescueEnabled {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// rescueModeDisabled returns true if rescue mode is disabled, false otherwise.
|
|
// Server absence is also considered to "disable" rescue mode, and returns true.
|
|
func (obj *HetznerVMRes) rescueModeDisabled() (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("rescueModeDisabled()")
|
|
}
|
|
if obj.server == nil {
|
|
return true, nil
|
|
}
|
|
if obj.server.RescueEnabled {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// rescueModeConverged returns true if the server's rescue mode is enabled or
|
|
// disabled as intended, false otherwise. Absence is treated as a valid case of
|
|
// disabled rescue mode. An error can only occur for invalid rescue images.
|
|
// TODO: review checks for image and ssh keys.
|
|
func (obj *HetznerVMRes) rescueModeConverged() (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("rescueModeConverged()")
|
|
}
|
|
// check server existence
|
|
if obj.server == nil {
|
|
if obj.ServerRescueMode == HetznerServerRescueDisabled {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
// check rescue mode
|
|
switch obj.ServerRescueMode {
|
|
case HetznerServerRescueDisabled:
|
|
// check if disabled as intended
|
|
if obj.server.RescueEnabled {
|
|
return false, nil
|
|
}
|
|
case HetznerServerRescueTypeLinux32, HetznerServerRescueTypeLinux64, HetznerServerRescueTypeFreeBSD64:
|
|
// check if enabled as intended
|
|
if !obj.server.RescueEnabled {
|
|
return false, nil
|
|
}
|
|
// check if the last used image type matches specs
|
|
// TODO: reference logic needs review
|
|
if obj.rescueImage != hcloud.ServerRescueType(obj.ServerRescueMode) {
|
|
return false, nil
|
|
}
|
|
// check if the last used keyset matches specs
|
|
// TODO: compare rescueKeys with ServerRescueSSHKeys?
|
|
default:
|
|
return false, fmt.Errorf("invalid ServerRescueMode: %s", obj.ServerRescueMode)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// serverStateIs returns a function that can be used with waitUntil. When this
|
|
// function is called, it returns true if the server status matches the state
|
|
// specified as input argument, false otherwise. It also returns false if the
|
|
// state argument is not supported. The supported states are "absent", "exists",
|
|
// "running", "off" and "" (undefined). Other inputs will result in an error.
|
|
// NOTE: hcloud states like ServerStatusUnknown and ServerStatusDeleting are
|
|
// also considered to be valid for state "exists". This is important to take
|
|
// into account when rewriting or adjusting any logic using this function.
|
|
func (obj *HetznerVMRes) serverStateIs(state string) func() (bool, error) {
|
|
if obj.init.Debug {
|
|
obj.init.Logf("serverStateIs(%s)", state)
|
|
}
|
|
return func() (bool, error) {
|
|
// Undefined state is always true.
|
|
if state == HetznerStateUndefined {
|
|
return true, nil
|
|
}
|
|
// Exit if the server is absent.
|
|
if obj.server == nil {
|
|
if state == HetznerStateAbsent {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
// The server exists, but in the right state?
|
|
switch state {
|
|
case HetznerStateAbsent:
|
|
return false, nil
|
|
case HetznerStateExists:
|
|
return true, nil
|
|
case HetznerStateRunning:
|
|
if obj.server.Status == hcloud.ServerStatusRunning {
|
|
return true, nil
|
|
}
|
|
case HetznerStateOff:
|
|
if obj.server.Status == hcloud.ServerStatusOff {
|
|
return true, nil
|
|
}
|
|
default:
|
|
return false, fmt.Errorf("unsupported state: %s", state)
|
|
}
|
|
return false, nil
|
|
}
|
|
}
|