diff --git a/engine/resources/hetzner_vm.go b/engine/resources/hetzner_vm.go new file mode 100644 index 00000000..fbece336 --- /dev/null +++ b/engine/resources/hetzner_vm.go @@ -0,0 +1,1227 @@ +// Mgmt +// Copyright (C) 2013-2021+ James Shubin and the project contributors +// Written by James Shubin 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 . + +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 +} + +// Close deletes the authentication info before closing the resource. +func (obj *HetznerVMRes) Close() 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() 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(apply bool) (bool, error) { + checkOK := true + ctx := context.TODO() + // 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 + } +} diff --git a/examples/lang/hetzner_vm0.mcl b/examples/lang/hetzner_vm0.mcl new file mode 100644 index 00000000..b00ebfe8 --- /dev/null +++ b/examples/lang/hetzner_vm0.mcl @@ -0,0 +1,51 @@ +import "os" +#import "deploy" + +# read token from a local path outside repo: +$f = "../localsecrets/apitoken.txt" +$token = os.readfile($f) + +# alternatively, read from the deploy: +# $token = deploy.readfile(...) + +# or give the string directly: +# $token = "..." + +hetzner:vm "resourcedemo" { + # generate a token on https://console.hetzner.cloud + # NOTE: SSH keys must also be registered via console + apitoken => $token, + + # set server state (``, `absent`,`exists`, `running`, `off`) + # default: undefined state. Don't specify this param, or set "" + state => "absent", + + # choose server protection policy (``, `ignore`, `ifneeded`) + # default: `` throws error to prevent destructive rebuilds + allowrebuild => "", + + # serverconfig params: full options must be requested from Hetzner API + # static reference on https://github.com/JefMasereel/hcloud-go-getopts + # for example: + servertype => "cx11", # `cx11`, `cx21`, `cx31` + datacenter => "hel1-dc2", # `nbg1-dc3`, `fsn1-dc14`, `hel1-dc2` + image => "debian-10", # `centos-7`, `ubuntu-18.04`, `debian-10` + + # provide a cloud-init script that will be run at server creation (optional) + userdata => "", + + # set rescue mode (`linux32`/`linux64`/`freebsd64`/``) + # default: `` disables rescue mode + serverrescuemode => "linux64", + + # list the SSH keys to be enabled when using rescue mode + # note: must be a subset of the keys registered with the project! + serverrescuekeys => ["bootstrap-test-key",], + + # set wait interval and timeout in seconds + waitinterval => 1, + waittimeout => 5*60, + + # set polling interval in seconds + Meta:poll => 10, +} diff --git a/go.mod b/go.mod index 4386886b..1570ffd3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go v0.54.0 // indirect github.com/Microsoft/go-winio v0.4.17 // indirect github.com/aws/aws-sdk-go v1.40.49 + github.com/blynn/nex v0.0.0-20210330102341-1a3320dab988 // indirect github.com/containerd/containerd v1.4.9 // indirect github.com/coredhcp/coredhcp v0.0.0-20210830115404-2176f33418f4 github.com/coreos/go-systemd/v22 v22.3.2 @@ -16,6 +17,8 @@ require ( github.com/docker/docker v20.10.8+incompatible github.com/docker/go-connections v0.4.0 github.com/docker/go-units v0.4.0 // indirect + github.com/dvyukov/go-fuzz v0.0.0-20220220162807-a217d9bdbece // indirect + github.com/elazarl/go-bindata-assetfs v1.0.1 // indirect github.com/fsnotify/fsnotify v1.5.1 github.com/godbus/dbus/v5 v5.0.4 github.com/google/uuid v1.2.0 // indirect @@ -23,8 +26,10 @@ require ( github.com/hashicorp/consul/api v1.1.0 github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/hil v0.0.0-20210521165536-27a72121fd40 + github.com/hetznercloud/hcloud-go v1.33.1 github.com/iancoleman/strcase v0.2.0 github.com/insomniacslk/dhcp v0.0.0-20210827173440-b95caade3eac + github.com/kevinburke/go-bindata v3.23.0+incompatible // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kylelemons/godebug v1.1.0 github.com/libvirt/libvirt-go v7.4.0+incompatible @@ -41,6 +46,7 @@ require ( github.com/sanity-io/litter v1.5.1 github.com/sirupsen/logrus v1.8.1 // indirect github.com/spf13/afero v1.5.1 + github.com/stephens2424/writerset v1.0.2 // indirect github.com/urfave/cli/v2 v2.3.0 github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852 go.etcd.io/etcd/api/v3 v3.5.0 @@ -48,8 +54,10 @@ require ( go.etcd.io/etcd/client/v3 v3.5.0 go.etcd.io/etcd/server/v3 v3.5.0 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c + golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba + golang.org/x/tools v0.1.9 // indirect gopkg.in/src-d/go-git.v4 v4.13.1 gopkg.in/yaml.v2 v2.4.0 gotest.tools/v3 v3.0.3 // indirect diff --git a/go.sum b/go.sum index 2a810933..bb2e1ab3 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,7 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74= github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w= github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -57,6 +58,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/blynn/nex v0.0.0-20210330102341-1a3320dab988 h1:n52kgfiPyHIfpQxmzlN7twzaKtQy1sgW7RrYhIPxdIk= +github.com/blynn/nex v0.0.0-20210330102341-1a3320dab988/go.mod h1:FYQGyGHZWt/nYpr26buSZp4+WR8dN3LB800EGQ1VAz4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054 h1:uH66TXeswKn5PW5zdZ39xEwfS9an067BirqA+P4QaLI= @@ -117,6 +120,12 @@ github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dvyukov/go-fuzz v0.0.0-20210914135545-4980593459a1 h1:YQOLTC8zvFaNSEuMexG0i7pY26bOksnQFsSJfGclo54= +github.com/dvyukov/go-fuzz v0.0.0-20210914135545-4980593459a1/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/dvyukov/go-fuzz v0.0.0-20220220162807-a217d9bdbece h1:6BGhEtGBmkgr8TKLjMBBMpvM/fK0GHj4prrkK1tYPcA= +github.com/dvyukov/go-fuzz v0.0.0-20220220162807-a217d9bdbece/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw= +github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -260,6 +269,8 @@ github.com/hashicorp/memberlist v0.1.3 h1:EmmoJme1matNzb+hMpDuR/0sbJSUisxyqBGG67 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hetznercloud/hcloud-go v1.33.1 h1:W1HdO2bRLTKU4WsyqAasDSpt54fYO4WNckWYfH5AuCQ= +github.com/hetznercloud/hcloud-go v1.33.1/go.mod h1:XX/TQub3ge0yWR2yHWmnDVIrB+MQbda1pHxkUmDlUME= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= @@ -294,6 +305,10 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kevinburke/go-bindata v3.22.0+incompatible h1:/JmqEhIWQ7GRScV0WjX/0tqBrC5D21ALg0H0U/KZ/ts= +github.com/kevinburke/go-bindata v3.22.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= +github.com/kevinburke/go-bindata v3.23.0+incompatible h1:rqNOXZlqrYhMVVAsQx8wuc+LaA73YcfbQ407wAykyS8= +github.com/kevinburke/go-bindata v3.23.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -420,6 +435,7 @@ github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3x github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM= +github.com/robertkrimen/godocdown v0.0.0-20130622164427-0bfa04905481/go.mod h1:C9WhFzY47SzYBIvzFqSvHIR6ROgDo4TtdTuRaOMjF/s= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -462,6 +478,8 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stephens2424/writerset v1.0.2 h1:znRLgU6g8RS5euYRcy004XeE4W+Tu44kALzy7ghPif8= +github.com/stephens2424/writerset v1.0.2/go.mod h1:aS2JhsMn6eA7e82oNmW4rfsgAOp9COBTTl8mzkwADnc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= @@ -497,6 +515,7 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= @@ -598,6 +617,8 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -636,6 +657,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -709,6 +732,8 @@ golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= @@ -721,6 +746,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -767,6 +794,10 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.2 h1:kRBLX7v7Af8W7Gdbbc908OJcdgtK8bOz9Uaj8/F1ACA= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=