From c1ef86b1b4c15ec7e3ea91076acb0e0a53fadc0e Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 12:07:11 +0200 Subject: [PATCH 01/12] Add `machine` cmd. --- cmd/admin/v2/commands.go | 1 + cmd/admin/v2/machine.go | 291 +++++++++++ cmd/api/v2/commands.go | 1 + cmd/api/v2/machine.go | 474 ++++++++++++++++++ cmd/completion/image.go | 15 + cmd/completion/ip.go | 2 + cmd/completion/machine.go | 30 +- cmd/sorters/machine.go | 33 ++ cmd/tableprinters/common.go | 9 - cmd/tableprinters/machine.go | 161 ++++++ cmd/tableprinters/switch.go | 54 -- docs/admin/metalctlv2_admin.md | 1 + docs/admin/metalctlv2_admin_machine.md | 42 ++ docs/admin/metalctlv2_admin_machine_apply.md | 46 ++ .../metalctlv2_admin_machine_bmc-command.md | 32 ++ .../admin/metalctlv2_admin_machine_console.md | 32 ++ docs/admin/metalctlv2_admin_machine_create.md | 86 ++++ docs/admin/metalctlv2_admin_machine_delete.md | 46 ++ .../metalctlv2_admin_machine_describe.md | 32 ++ docs/admin/metalctlv2_admin_machine_edit.md | 31 ++ docs/admin/metalctlv2_admin_machine_list.md | 39 ++ docs/admin/metalctlv2_admin_machine_lock.md | 33 ++ docs/admin/metalctlv2_admin_machine_taint.md | 33 ++ docs/admin/metalctlv2_admin_machine_update.md | 46 ++ docs/metalctlv2.md | 1 + docs/metalctlv2_machine.md | 38 ++ docs/metalctlv2_machine_apply.md | 46 ++ docs/metalctlv2_machine_create.md | 68 +++ docs/metalctlv2_machine_delete.md | 47 ++ docs/metalctlv2_machine_describe.md | 32 ++ docs/metalctlv2_machine_edit.md | 31 ++ docs/metalctlv2_machine_list.md | 39 ++ docs/metalctlv2_machine_update.md | 46 ++ pkg/helpers/emoji.go | 27 + 34 files changed, 1871 insertions(+), 74 deletions(-) create mode 100644 cmd/admin/v2/machine.go create mode 100644 cmd/api/v2/machine.go create mode 100644 cmd/sorters/machine.go create mode 100644 cmd/tableprinters/machine.go create mode 100644 docs/admin/metalctlv2_admin_machine.md create mode 100644 docs/admin/metalctlv2_admin_machine_apply.md create mode 100644 docs/admin/metalctlv2_admin_machine_bmc-command.md create mode 100644 docs/admin/metalctlv2_admin_machine_console.md create mode 100644 docs/admin/metalctlv2_admin_machine_create.md create mode 100644 docs/admin/metalctlv2_admin_machine_delete.md create mode 100644 docs/admin/metalctlv2_admin_machine_describe.md create mode 100644 docs/admin/metalctlv2_admin_machine_edit.md create mode 100644 docs/admin/metalctlv2_admin_machine_list.md create mode 100644 docs/admin/metalctlv2_admin_machine_lock.md create mode 100644 docs/admin/metalctlv2_admin_machine_taint.md create mode 100644 docs/admin/metalctlv2_admin_machine_update.md create mode 100644 docs/metalctlv2_machine.md create mode 100644 docs/metalctlv2_machine_apply.md create mode 100644 docs/metalctlv2_machine_create.md create mode 100644 docs/metalctlv2_machine_delete.md create mode 100644 docs/metalctlv2_machine_describe.md create mode 100644 docs/metalctlv2_machine_edit.md create mode 100644 docs/metalctlv2_machine_list.md create mode 100644 docs/metalctlv2_machine_update.md create mode 100644 pkg/helpers/emoji.go diff --git a/cmd/admin/v2/commands.go b/cmd/admin/v2/commands.go index 3aad5c0..6bd3dec 100644 --- a/cmd/admin/v2/commands.go +++ b/cmd/admin/v2/commands.go @@ -18,6 +18,7 @@ func AddCmds(cmd *cobra.Command, c *config.Config) { adminCmd.AddCommand(newComponentCmd(c)) adminCmd.AddCommand(newImageCmd(c)) adminCmd.AddCommand(newIPCmd(c)) + adminCmd.AddCommand(newMachineCmd(c)) adminCmd.AddCommand(newNetworkCmd(c)) adminCmd.AddCommand(newPartitionCmd(c)) adminCmd.AddCommand(newProjectCmd(c)) diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go new file mode 100644 index 0000000..a45ba32 --- /dev/null +++ b/cmd/admin/v2/machine.go @@ -0,0 +1,291 @@ +package v2 + +import ( + "fmt" + + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/cmd/config" + "github.com/metal-stack/cli/cmd/sorters" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type machine struct { + c *config.Config +} + +func newMachineCmd(c *config.Config) *cobra.Command { + w := &machine{ + c: c, + } + + cmdsConfig := &genericcli.CmdsConfig[any, any, *apiv2.Machine]{ + BinaryName: config.BinaryName, + GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), + Singular: "machine", + Plural: "machines", + Description: "manage machines", + Sorter: sorters.MachineSorter(), + DescribePrinter: func() printers.Printer { return c.DescribePrinter }, + ListPrinter: func() printers.Printer { return c.ListPrinter }, + ListCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().String("uuid", "", "allocation uuid of machine which should be listed") + cmd.Flags().String("name", "", "name from machines which should be listed") + cmd.Flags().String("hostname", "", "hostname from machines which should be listed") + cmd.Flags().String("size", "", "size from machines which should be listed") + cmd.Flags().String("image", "", "image") + cmd.Flags().StringP("project", "p", "", "project from where machines should be listed") + cmd.Flags().StringP("partition", "", "", "partition from where machines should be listed") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.Completion.Size)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) + }, + DescribeCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("project", "p", "", "project of the machine") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + }, + CreateCmdMutateFn: func(cmd *cobra.Command) { + // w.addMachineCreateFlags(cmd, "machine") + cmd.Aliases = []string{"allocate"} + cmd.Example = `machine create can be done in two different ways: + +- default with automatic allocation: + + metalctl machine create \ + --hostname worker01 \ + --name worker \ + --image ubuntu-18.04 \ # query available with: metalctl image list + --size t1-small-x86 \ # query available with: metalctl size list + --partition test \ # query available with: metalctl partition list + --project cluster01 \ + --sshpublickey "@~/.ssh/id_rsa.pub" + +- for metal administration with reserved machines: + + reserve a machine you want to allocate: + + metalctl machine reserve 00000000-0000-0000-0000-0cc47ae54694 --description "blocked for maintenance" + + allocate this machine: + + metalctl machine create \ + --hostname worker01 \ + --name worker \ + --image ubuntu-18.04 \ # query available with: metalctl image list + --project cluster01 \ + --sshpublickey "@~/.ssh/id_rsa.pub" \ + --id 00000000-0000-0000-0000-0cc47ae54694 + +after you do not want to use this machine exclusive, remove the reservation: + +metalctl machine reserve 00000000-0000-0000-0000-0cc47ae54694 --remove + +Once created the machine installation can not be modified anymore. +` + }, + ValidArgsFn: c.Completion.Machine, + } + + bmcCommandCmd := &cobra.Command{ + Use: "bmc-command", + Short: "send a command to the bmc of a machine", + RunE: func(cmd *cobra.Command, args []string) error { + return w.bmcCommand(args) + }, + ValidArgsFunction: c.Completion.Machine, + } + bmcCommandCmd.Flags().String("command", "", "the actual command to send to the machine") + genericcli.Must(bmcCommandCmd.RegisterFlagCompletionFunc("command", c.Completion.BMCCommands)) + genericcli.Must(bmcCommandCmd.MarkFlagRequired("command")) + + lockCmd := &cobra.Command{ + Use: "lock", + Short: "lock or unlock a machine, e.g. machine cannot be used", + RunE: func(cmd *cobra.Command, args []string) error { + return w.lockOrTaint(args, apiv2.MachineState_MACHINE_STATE_LOCKED) + }, + ValidArgsFunction: c.Completion.Machine, + } + lockCmd.Flags().String("description", "", "description of why the machine was locked") + lockCmd.Flags().Bool("remove", false, "if set to true, machine will be unlocked") + + taintCmd := &cobra.Command{ + Use: "taint", + Short: "taint or untaint a machine, e.g. machine will not be automatically selected on machine create, only admins can create them", + RunE: func(cmd *cobra.Command, args []string) error { + return w.lockOrTaint(args, apiv2.MachineState_MACHINE_STATE_TAINTED) + }, + ValidArgsFunction: c.Completion.Machine, + } + taintCmd.Flags().String("description", "", "description of why the machine was tainted") + taintCmd.Flags().Bool("remove", false, "if set to true, machine will be untainted") + + consoleCmd := &cobra.Command{ + Use: "console", + Short: "establishes a connection to the serial console of a machine. for authentication at the metal-console it uses the token such that no machine ssh key is required for access (unlike the corresponding user API command).", + RunE: func(cmd *cobra.Command, args []string) error { + return w.console(args) + }, + ValidArgsFunction: c.Completion.Machine, + } + consoleCmd.Flags().Bool("ipmi", false, "if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present)") + + return genericcli.NewCmds(cmdsConfig, bmcCommandCmd, lockCmd, taintCmd, consoleCmd) +} + +func (c *machine) Create(rq any) (*apiv2.Machine, error) { + panic("unimplemented") +} + +func (c *machine) Delete(id string) (*apiv2.Machine, error) { + panic("unimplemented") +} + +func (c *machine) Get(id string) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Adminv2().Machine().Get(ctx, &adminv2.MachineServiceGetRequest{ + Uuid: id, + }) + if err != nil { + return nil, err + } + + return resp.Machine, nil +} + +func (c *machine) List() ([]*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + var allocation *apiv2.MachineAllocationQuery + + if viper.IsSet("hostname") || viper.IsSet("project") || viper.IsSet("image") { + allocation = &apiv2.MachineAllocationQuery{ + Hostname: pointer.PointerOrNil(viper.GetString("hostname")), + Project: pointer.PointerOrNil(viper.GetString("project")), + Image: pointer.PointerOrNil(viper.GetString("image")), + } + } + + resp, err := c.c.Client.Adminv2().Machine().List(ctx, &adminv2.MachineServiceListRequest{ + Query: &apiv2.MachineQuery{ + Uuid: pointer.PointerOrNil(viper.GetString("id")), + Name: pointer.PointerOrNil(viper.GetString("name")), + Partition: pointer.PointerOrNil(viper.GetString("partition")), + Size: pointer.PointerOrNil(viper.GetString("size")), + Allocation: allocation, + // Rack: pointer.PointerOrNil(viper.GetString("rack")), + // Labels: &apiv2.Labels{ + // Labels: tag.NewTagMap(viper.GetStringSlice("labels")), + // }, + // Bmc: &apiv2.MachineBMCQuery{ + // Address: pointer.PointerOrNil(viper.GetString("bmc-address")), + // Mac: pointer.PointerOrNil(viper.GetString("bmc-mac")), + // User: pointer.PointerOrNil(viper.GetString("bmc-user")), + // Interface: pointer.PointerOrNil(viper.GetString("bmc-interface")), + // }, + // Fru: &apiv2.MachineFRUQuery{ + // ChassisPartNumber: pointer.PointerOrNil(viper.GetString("chassis-part-number")), + // ChassisPartSerial: pointer.PointerOrNil(viper.GetString("chassis-part-serial")), + // BoardMfg: pointer.PointerOrNil(viper.GetString("board-mfg")), + // BoardSerial: pointer.PointerOrNil(viper.GetString("board-serial")), + // BoardPartNumber: pointer.PointerOrNil(viper.GetString("board-part-number")), + // ProductManufacturer: pointer.PointerOrNil(viper.GetString("product-manufacturer")), + // ProductPartNumber: pointer.PointerOrNil(viper.GetString("product-part-number")), + // ProductSerial: pointer.PointerOrNil(viper.GetString("product-serial")), + // }, + // Hardware: &apiv2.MachineHardwareQuery{ + // Memory: pointer.PointerOrNil(viper.GetUint64("memory")), + // CpuCores: pointer.PointerOrNil(viper.GetUint32("cpu-cores")), + // }, + // State: &0, + }, + }) + if err != nil { + return nil, err + } + + return resp.Machines, nil +} + +func (c *machine) Update(rq any) (*apiv2.Machine, error) { + panic("unimplemented") +} + +func (c *machine) Convert(r *apiv2.Machine) (string, any, any, error) { + panic("unimplemented") + +} + +func (c *machine) MachineResponseToCreate(r *apiv2.Machine) any { + panic("unimplemented") +} + +func (c *machine) MachineResponseToUpdate(desired *apiv2.Machine) (any, error) { + panic("unimplemented") +} + +func (c *machine) lockOrTaint(args []string, state apiv2.MachineState) error { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + if viper.GetBool("remove") { + state = apiv2.MachineState_MACHINE_STATE_AVAILABLE + } + + resp, err := c.c.Client.Adminv2().Machine().SetState(ctx, &adminv2.MachineServiceSetStateRequest{ + Uuid: id, + Description: viper.GetString("description"), + State: state, + }) + if err != nil { + return err + } + + return c.c.ListPrinter.Print(resp.Machine) +} + +func (c *machine) bmcCommand(args []string) error { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + commandString := viper.GetString("command") + + cmd, ok := apiv2.MachineBMCCommand_value[commandString] + if !ok { + return fmt.Errorf("unknown bmc command: %s", commandString) + } + + _, err = c.c.Client.Adminv2().Machine().BMCCommand(ctx, &adminv2.MachineServiceBMCCommandRequest{ + Uuid: id, + Command: apiv2.MachineBMCCommand(cmd), + }) + if err != nil { + return err + } + + return err +} + +func (c *machine) console(args []string) error { + panic("unimplemented") +} diff --git a/cmd/api/v2/commands.go b/cmd/api/v2/commands.go index 32d8182..31dcfd0 100644 --- a/cmd/api/v2/commands.go +++ b/cmd/api/v2/commands.go @@ -10,6 +10,7 @@ func AddCmds(cmd *cobra.Command, c *config.Config) { cmd.AddCommand(newHealthCmd(c)) cmd.AddCommand(newImageCmd(c)) cmd.AddCommand(newIPCmd(c)) + cmd.AddCommand(newMachineCmd(c)) cmd.AddCommand(newMethodsCmd(c)) cmd.AddCommand(newNetworkCmd(c)) cmd.AddCommand(newPartitionCmd(c)) diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go new file mode 100644 index 0000000..d804941 --- /dev/null +++ b/cmd/api/v2/machine.go @@ -0,0 +1,474 @@ +package v2 + +import ( + "encoding/base64" + "fmt" + "net/netip" + "os" + osuser "os/user" + "path/filepath" + "strings" + + "github.com/metal-stack/api/go/errorutil" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/cmd/config" + "github.com/metal-stack/cli/cmd/sorters" + "github.com/metal-stack/cli/pkg/helpers" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type machine struct { + c *config.Config +} + +func newMachineCmd(c *config.Config) *cobra.Command { + w := &machine{ + c: c, + } + + cmdsConfig := &genericcli.CmdsConfig[*apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, *apiv2.Machine]{ + BinaryName: config.BinaryName, + GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), + Singular: "machine", + Plural: "machines", + Description: "allocate a machine", + Sorter: sorters.MachineSorter(), + DescribePrinter: func() printers.Printer { return c.DescribePrinter }, + ListPrinter: func() printers.Printer { return c.ListPrinter }, + CreateRequestFromCLI: w.createRequestFromCLI, + CreateCmdMutateFn: func(cmd *cobra.Command) { + w.addMachineCreateFlags(cmd, "machine") + cmd.Aliases = []string{"allocate"} + }, + ListCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().String("uuid", "", "allocation uuid of machine which should be listed") + cmd.Flags().String("name", "", "name from machines which should be listed") + cmd.Flags().String("hostname", "", "hostname from machines which should be listed") + cmd.Flags().String("size", "", "size from machines which should be listed") + cmd.Flags().String("image", "", "image") + cmd.Flags().StringP("project", "p", "", "project from where machines should be listed") + cmd.Flags().StringP("partition", "", "", "partition from where machines should be listed") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.Completion.Size)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) + }, + DescribeCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("project", "p", "", "project of the machine") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + }, + DeleteCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("project", "p", "", "project of the machine") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + }, + ValidArgsFn: c.Completion.Machine, + } + + return genericcli.NewCmds(cmdsConfig) +} + +func (c *machine) Create(rq *apiv2.MachineServiceCreateRequest) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Apiv2().Machine().Create(ctx, rq) + if err != nil { + if errorutil.IsConflict(err) { + return nil, genericcli.AlreadyExistsError() + } + + return nil, err + } + + return resp.Machine, nil +} + +func (c *machine) Delete(id string) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Apiv2().Machine().Delete(ctx, &apiv2.MachineServiceDeleteRequest{ + Uuid: id, + Project: c.c.GetProject(), + }) + if err != nil { + return nil, err + } + + return resp.Machine, nil +} + +func (c *machine) Get(id string) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Apiv2().Machine().Get(ctx, &apiv2.MachineServiceGetRequest{ + Project: c.c.GetProject(), + Uuid: id, + }) + if err != nil { + return nil, err + } + + return resp.Machine, nil +} + +func (c *machine) List() ([]*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + var allocation *apiv2.MachineAllocationQuery + + if viper.IsSet("hostname") || viper.IsSet("project") || viper.IsSet("image") { + allocation = &apiv2.MachineAllocationQuery{ + Hostname: pointer.PointerOrNil(viper.GetString("hostname")), + Project: pointer.PointerOrNil(viper.GetString("project")), + Image: pointer.PointerOrNil(viper.GetString("image")), + } + } + + resp, err := c.c.Client.Apiv2().Machine().List(ctx, &apiv2.MachineServiceListRequest{ + Project: c.c.GetProject(), + Query: &apiv2.MachineQuery{ + Uuid: pointer.PointerOrNil(viper.GetString("id")), + Name: pointer.PointerOrNil(viper.GetString("name")), + Partition: pointer.PointerOrNil(viper.GetString("partition")), + Size: pointer.PointerOrNil(viper.GetString("size")), + Allocation: allocation, + }, + }) + if err != nil { + return nil, err + } + + return resp.Machines, nil +} + +func (c *machine) Update(rq *apiv2.MachineServiceUpdateRequest) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Apiv2().Machine().Update(ctx, rq) + if err != nil { + return nil, err + } + + return resp.Machine, nil +} + +func (c *machine) Convert(r *apiv2.Machine) (string, *apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, error) { + update, err := c.MachineResponseToUpdate(r) + if err != nil { + return "", nil, nil, err + } + + create, err := c.MachineResponseToCreate(r) + if err != nil { + return "", nil, nil, err + } + + return helpers.EncodeProject(r.Uuid, r.Allocation.Project), create, update, err +} + +func (c *machine) MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServiceCreateRequest, error) { + if r.Allocation == nil { + return nil, fmt.Errorf("allocation is nil") + } + + return &apiv2.MachineServiceCreateRequest{ + Project: r.Allocation.Project, + Name: r.Allocation.Name, + Description: &r.Allocation.Description, + Hostname: &r.Allocation.Hostname, + Partition: &r.Partition.Id, + Size: &r.Size.Id, + Image: r.Allocation.Image.Id, + FilesystemLayout: &r.Allocation.FilesystemLayout.Id, + SshPublicKeys: r.Allocation.SshPublicKeys, + Userdata: &r.Allocation.Userdata, + Labels: pointer.SafeDeref(r.Meta).Labels, + // Networks: r.Allocation.Networks, TODO + // PlacementTags: r.Allocation.Plac, // TODO: should be stored in the allocation to see what was provided + DnsServers: r.Allocation.DnsServers, + NtpServers: r.Allocation.NtpServers, + AllocationType: r.Allocation.AllocationType, + // FirewallSpec: r.Allocation.Fir, TODO + }, nil +} + +func (c *machine) MachineResponseToUpdate(r *apiv2.Machine) (*apiv2.MachineServiceUpdateRequest, error) { + if r.Allocation == nil { + return nil, fmt.Errorf("allocation is nil") + } + + return &apiv2.MachineServiceUpdateRequest{ + Uuid: r.Uuid, + UpdateMeta: helpers.UpdateMetaFromMeta(r.Meta), + Labels: helpers.UpdateLabelsFromMeta(r.Meta), + Project: r.Allocation.Project, + Description: &r.Allocation.Description, + SshPublicKeys: r.Allocation.SshPublicKeys, + }, nil +} + +func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, error) { + var ( + keys []string + dnsServers []*apiv2.DNSServer + ntpServers []*apiv2.NTPServer + allocationType apiv2.MachineAllocationType + firewallSpec *apiv2.FirewallSpec + labels *apiv2.Labels + + sshPublicKeyArgument = viper.GetString("sshpublickey") + dnsServersArgument = viper.GetStringSlice("dnsservers") + ntpServersArgument = viper.GetStringSlice("ntpservers") + ) + + if strings.HasPrefix(sshPublicKeyArgument, "@") { + var err error + sshPublicKeyArgument, err = readFromFile(sshPublicKeyArgument[1:]) + if err != nil { + return nil, err + } + } + + if len(sshPublicKeyArgument) == 0 { + sshKey, err := searchSSHKey() + if err != nil { + return nil, err + } + sshPublicKey := sshKey + ".pub" + sshPublicKeyArgument, err = readFromFile(sshPublicKey) + if err != nil { + return nil, err + } + } + + if sshPublicKeyArgument != "" { + keys = append(keys, sshPublicKeyArgument) + } + + userDataArgument := viper.GetString("userdata") + if strings.HasPrefix(userDataArgument, "@") { + var err error + userDataArgument, err = readFromFile(userDataArgument[1:]) + if err != nil { + return nil, err + } + } + if userDataArgument != "" { + userDataArgument = base64.StdEncoding.EncodeToString([]byte(userDataArgument)) + } + + possibleNetworks := viper.GetStringSlice("networks") + networks, err := parseNetworks(possibleNetworks) + if err != nil { + return nil, err + } + + for _, s := range dnsServersArgument { + dnsServers = append(dnsServers, &apiv2.DNSServer{Ip: s}) + } + + for _, s := range ntpServersArgument { + ntpServers = append(ntpServers, &apiv2.NTPServer{Address: s}) + } + + allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE + if viper.GetString("allocation-type") == "firewall" { + allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL + } + + for k, v := range viper.GetStringMap("labels") { + if labels == nil { + labels = &apiv2.Labels{} + } else { + value, ok := v.(string) + if ok { + labels.Labels[k] = value + } else { + labels.Labels[k] = "" + } + } + } + + var filesystemlayout *string + if viper.IsSet("filesystemlayout") { + filesystemlayout = new(viper.GetString("filesystemlayout")) + } + var size *string + if viper.IsSet("size") { + size = new(viper.GetString("size")) + } + var uuid *string + if viper.IsSet("id") { + uuid = new(viper.GetString("id")) + } + var partition *string + if viper.IsSet("partition") { + partition = new(viper.GetString("partition")) + } + var hostname *string + if viper.IsSet("hostname") { + hostname = new(viper.GetString("hostname")) + } + var description *string + if viper.IsSet("description") { + description = new(viper.GetString("description")) + } + + mcr := &apiv2.MachineServiceCreateRequest{ + Description: description, + Partition: partition, + Hostname: hostname, + Image: viper.GetString("image"), + Name: viper.GetString("name"), + Uuid: uuid, + Project: viper.GetString("project"), + Size: size, + SshPublicKeys: keys, + Labels: labels, + Userdata: new(userDataArgument), + Networks: networks, + DnsServers: dnsServers, + NtpServers: ntpServers, + FilesystemLayout: filesystemlayout, + PlacementTags: viper.GetStringSlice("placement-tags"), + AllocationType: allocationType, + FirewallSpec: firewallSpec, + } + return mcr, nil +} + +var defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} + +func searchSSHKey() (string, error) { + currentUser, err := osuser.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) + } + homeDir := currentUser.HomeDir + defaultDir := filepath.Join(homeDir, "/.ssh/") + var key string + for _, k := range defaultSSHKeys { + possibleKey := filepath.Join(defaultDir, k) + _, err := os.ReadFile(possibleKey) + if err == nil { + fmt.Printf("using SSH identity: %s. Another identity can be specified with --sshidentity/-p\n", + possibleKey) + key = possibleKey + break + } + } + + if key == "" { + return "", fmt.Errorf("failure to locate a SSH identity in default location (%s), "+ + "another identity can be specified with --sshidentity/-p", defaultDir) + } + return key, nil +} +func readFromFile(filePath string) (string, error) { + filePath, err := expandFilepath(filePath) + if err != nil { + return "", err + } + + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("unable to read from given file %s error:%w", filePath, err) + } + return strings.TrimSpace(string(content)), nil +} + +func expandFilepath(filePath string) (string, error) { + currentUser, err := osuser.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) + } + homeDir := currentUser.HomeDir + + if filePath == "~" { + filePath = homeDir + } else if strings.HasPrefix(filePath, "~/") { + filePath = filepath.Join(homeDir, filePath[2:]) + } + + return filePath, nil +} +func parseNetworks(possibleNetworks []string) ([]*apiv2.MachineAllocationNetwork, error) { + var result []*apiv2.MachineAllocationNetwork + for _, n := range possibleNetworks { + if n == "" { + continue + } + man := &apiv2.MachineAllocationNetwork{ + Network: n, + } + nw, ipsString, found := strings.Cut(n, ":") + if found { + man.Network = nw + for ip := range strings.SplitSeq(ipsString, ",") { + if ip == "" { + continue + } + _, err := netip.ParseAddr(ip) + if err != nil { + return nil, fmt.Errorf("malformed ip:%s %w", ip, err) + } + man.Ips = append(man.Ips, ip) + } + } + result = append(result, man) + } + return result, nil +} + +func (c *machine) addMachineCreateFlags(cmd *cobra.Command, name string) { + cmd.Flags().StringP("description", "d", "", "Description of the "+name+" to create. [optional]") + cmd.Flags().StringP("partition", "S", "", "partition/datacenter where the "+name+" is created. [required, except for reserved machines]") + cmd.Flags().StringP("hostname", "H", "", "Hostname of the "+name+". [required]") + cmd.Flags().StringP("image", "i", "", "OS Image to install. [required]") + cmd.Flags().StringP("filesystemlayout", "", "", "Filesystemlayout to use during machine installation. [optional]") + cmd.Flags().StringP("name", "n", "", "Name of the "+name+". [optional]") + cmd.Flags().StringP("id", "I", "", "ID of a specific "+name+" to allocate, if given, size and partition are ignored. Need to be set to reserved (--reserve) state before.") + cmd.Flags().StringP("project", "P", "", "Project where the "+name+" should belong to. [required]") + cmd.Flags().StringP("size", "s", "", "Size of the "+name+". [required, except for reserved machines]") + cmd.Flags().StringP("allocation-type", "t", "machine", "allocation type, can be either machine|firewall") + cmd.Flags().StringP("sshpublickey", "p", "", + `SSH public key for access via ssh and console. [optional] +Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". +If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) + cmd.Flags().StringSlice("tags", []string{}, "tags to add to the "+name+", use it like: --tags \"tag1,tag2\" or --tags \"tag3\".") + cmd.Flags().StringP("userdata", "", "", `cloud-init.io compatible userdata. [optional] +Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg".`) + cmd.Flags().StringSlice("dnsservers", []string{}, "dns servers to add to the machine or firewall. [optional]") + cmd.Flags().StringSlice("ntpservers", []string{}, "ntp servers to add to the machine or firewall. [optional]") + + cmd.Flags().StringSlice("networks", []string{}, + `Adds a network. Usage: [--networks NETWORK,[ip:ip:ip][,NETWORK...]... +NETWORK specifies the name or id of an existing network. +IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. +`) + + cmd.MarkFlagsMutuallyExclusive("file", "project") + cmd.MarkFlagsRequiredTogether("project", "networks", "hostname", "image") + cmd.MarkFlagsRequiredTogether("size", "partition") + + // Completion for arguments + genericcli.Must(cmd.RegisterFlagCompletionFunc("networks", c.c.Completion.Network)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.c.Completion.Partition)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.c.Completion.Size)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.c.Completion.Project)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.c.Completion.Machine)) + // FIXME implement + // genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.c.Completion.ImageListCompletion)) + // genericcli.Must(cmd.RegisterFlagCompletionFunc("filesystemlayout", c.c.Completion.FilesystemLayoutListCompletion)) +} diff --git a/cmd/completion/image.go b/cmd/completion/image.go index 3c2021b..08d818f 100644 --- a/cmd/completion/image.go +++ b/cmd/completion/image.go @@ -6,6 +6,21 @@ import ( "github.com/spf13/cobra" ) +func (c *Completion) Image(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Apiv2().Image().List(cmd.Context(), &apiv2.ImageServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var names []string + + for _, img := range resp.Images { + names = append(names, img.Id+"\t"+*img.Name) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + func (c *Completion) ImageFeatures(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var names []string diff --git a/cmd/completion/ip.go b/cmd/completion/ip.go index 8071fc6..0ae829d 100644 --- a/cmd/completion/ip.go +++ b/cmd/completion/ip.go @@ -20,6 +20,7 @@ func (c *Completion) Ip(cmd *cobra.Command, args []string, toComplete string) ([ } return names, cobra.ShellCompDirectiveNoFileComp } + func (c *Completion) AddressFamily(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var afs []string for _, af := range []apiv2.IPAddressFamily{ @@ -34,6 +35,7 @@ func (c *Completion) AddressFamily(cmd *cobra.Command, args []string, toComplete return afs, cobra.ShellCompDirectiveNoFileComp } + func (c *Completion) IPType(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var afs []string for _, af := range []apiv2.IPType{ diff --git a/cmd/completion/machine.go b/cmd/completion/machine.go index 36f1b06..a108cc6 100644 --- a/cmd/completion/machine.go +++ b/cmd/completion/machine.go @@ -1,25 +1,33 @@ package completion import ( - adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/spf13/cobra" ) func (c *Completion) Machine(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - req := &adminv2.MachineServiceListRequest{} - resp, err := c.Client.Adminv2().Machine().List(cmd.Context(), req) + resp, err := c.Client.Apiv2().Machine().List(cmd.Context(), &apiv2.MachineServiceListRequest{ + Project: c.Proj, + }) if err != nil { return nil, cobra.ShellCompDirectiveError } + + var names []string + + for _, s := range resp.Machines { + names = append(names, s.Uuid) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) BMCCommands(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var names []string - for _, m := range resp.Machines { - var hostname string - if m.Allocation != nil { - hostname = m.Allocation.Hostname - names = append(names, m.Uuid+"\t"+hostname) - } else { - names = append(names, m.Uuid) - } + + for _, name := range apiv2.MachineBMCCommand_name { + names = append(names, name) } + return names, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/sorters/machine.go b/cmd/sorters/machine.go new file mode 100644 index 0000000..0683409 --- /dev/null +++ b/cmd/sorters/machine.go @@ -0,0 +1,33 @@ +package sorters + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/multisort" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func MachineSorter() *multisort.Sorter[*apiv2.Machine] { + return multisort.New(multisort.FieldMap[*apiv2.Machine]{ + "partition": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(a.Partition.Id, b.Partition.Id, descending) + }, + "size": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(a.Size.Id, b.Size.Id, descending) + }, + "uuid": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(a.Uuid, b.Uuid, descending) + }, + "image": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Allocation).Image.Id, pointer.SafeDeref(b.Allocation).Image.Id, descending) + }, + "rack": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(a.Rack, b.Rack, descending) + }, + "project": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Allocation).Project, pointer.SafeDeref(b.Allocation).Project, descending) + }, + "age": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Allocation).Meta.CreatedAt.AsTime().Unix(), pointer.SafeDeref(b.Allocation).Meta.CreatedAt.AsTime().Unix(), descending) + }, + }, multisort.Keys{{ID: "partition"}, {ID: "size"}, {ID: "project"}, {ID: "uuid"}}) +} diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 8b31e78..36c2215 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -18,15 +18,6 @@ const ( nbr = " " halfpie = "◒" threequarterpie = "◕" - - ambulance = "🚑" - exclamation = "❗" - bark = "🚧" - loop = "⭕" - lock = "🔒" - question = "❓" - skull = "💀" - vpn = "🛡" ) type TablePrinter struct { diff --git a/cmd/tableprinters/machine.go b/cmd/tableprinters/machine.go new file mode 100644 index 0000000..d59076d --- /dev/null +++ b/cmd/tableprinters/machine.go @@ -0,0 +1,161 @@ +package tableprinters + +import ( + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/metal-stack/api/go/enum" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/pkg/helpers" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func (t *TablePrinter) MachineTable(data []*apiv2.Machine, wide bool) ([]string, [][]string, error) { + + var ( + rows [][]string + header = []string{"ID", "", "Last Event", "When", "Age", "Hostname", "Project", "Size", "Image", "Partition", "Rack"} + ) + + if wide { + header = []string{"ID", "Last Event", "When", "Age", "Description", "Name", "Hostname", "Project", "IPs", "Size", "Image", "Partition", "Rack", "Started", "Tags", "Lock/Reserve"} + } + + for _, machine := range data { + machineID := machine.Uuid + + if machine.Status != nil && machine.Status.LedState != nil && machine.Status.LedState.Value == "LED-ON" { + blue := color.New(color.FgBlue).SprintFunc() + machineID = blue(machineID) + } + + var ( + alloc = pointer.SafeDeref(machine.Allocation) + sizeID = pointer.SafeDeref(machine.Size).Id + partitionID = pointer.SafeDeref(machine.Partition).Id + project = alloc.Project + name = alloc.Name + desc = alloc.Description + hostname = alloc.Hostname + image = pointer.SafeDeref(pointer.SafeDeref(alloc.Image).Name) + rack = machine.Rack + truncatedHostname = genericcli.TruncateEnd(hostname, 30) + + nwIPs []string + ) + + for _, nw := range alloc.Networks { + nwIPs = append(nwIPs, nw.Ips...) + } + + var ( + ips = strings.Join(nwIPs, "\n") + started = "" + age = "" + tags = "" + reserved = "" + lastEvent = "" + when = "" + ) + + if alloc.Meta != nil && alloc.Meta.CreatedAt != nil && !alloc.Meta.CreatedAt.AsTime().IsZero() { + started = alloc.Meta.CreatedAt.AsTime().Format(time.RFC3339) + age = humanizeDuration(time.Since(alloc.Meta.CreatedAt.AsTime())) + } + + if machine.Meta.Labels != nil && len(machine.Meta.Labels.Labels) > 0 { + var labels []string + for k, v := range machine.Meta.Labels.Labels { + labels = append(labels, k+"="+v) + } + tags = strings.Join(labels, ",") + } + + if machine.Status.Condition != nil { + stateString, err := enum.GetStringValue(machine.Status.Condition.State) + if err != nil { + return nil, nil, err + } + reserved = fmt.Sprintf("%s:%s", *stateString, machine.Status.Condition.Description) + } + + if len(machine.RecentProvisioningEvents.Events) > 0 { + since := time.Since(machine.RecentProvisioningEvents.LastEventTime.AsTime()) + when = humanizeDuration(since) + lastEventString, err := enum.GetStringValue(machine.RecentProvisioningEvents.Events[0].Event) + if err != nil { + return nil, nil, err + } + lastEvent = *lastEventString + } + + emojis := t.getMachineStatusEmojis(machine) + + if wide { + rows = append(rows, []string{machineID, lastEvent, when, age, desc, name, hostname, project, ips, sizeID, image, partitionID, rack, started, tags, reserved}) + } else { + rows = append(rows, []string{machineID, emojis, lastEvent, when, age, truncatedHostname, project, sizeID, image, partitionID, rack}) + } + } + + t.t.DisableAutoWrap(false) + + return header, rows, nil +} + +func (t *TablePrinter) getMachineStatusEmojis(m *apiv2.Machine) string { + if m == nil { + return "" + } + + var ( + emojis []string + ) + + if status := m.Status; status != nil { + switch status.Liveliness { + case apiv2.MachineLiveliness_MACHINE_LIVELINESS_ALIVE: + // noop + case apiv2.MachineLiveliness_MACHINE_LIVELINESS_DEAD: + emojis = append(emojis, helpers.Skull) + default: + emojis = append(emojis, helpers.Question) + } + + if status.Condition != nil { + switch status.Condition.State { + case apiv2.MachineState_MACHINE_STATE_LOCKED: + emojis = append(emojis, helpers.Lock) + case apiv2.MachineState_MACHINE_STATE_TAINTED: + emojis = append(emojis, helpers.Bark) + default: + // noop + } + } + } + + if events := m.RecentProvisioningEvents; events != nil { + switch events.State { + case apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_FAILED_RECLAIM: + emojis = append(emojis, helpers.Ambulance) + case apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_CRASHLOOP: + emojis = append(emojis, helpers.Loop) + default: + // noop + + } + + if time.Since(events.LastErrorEvent.Time.AsTime()) < t.lastEventErrorThreshold { + emojis = append(emojis, helpers.Exclamation) + } + } + + if m.Allocation != nil && m.Allocation.Vpn != nil && m.Allocation.Vpn.Connected { + emojis = append(emojis, helpers.VPN) + } + + return strings.Join(emojis, nbr) +} diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go index f898caa..2a0cc47 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -313,60 +313,6 @@ func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail) ([]string, [][ return header, rows, nil } -func (t *TablePrinter) getMachineStatusEmojis(m *apiv2.Machine) string { - if m == nil { - return "" - } - - var ( - emojis []string - ) - - if status := m.Status; status != nil { - switch status.Liveliness { - case apiv2.MachineLiveliness_MACHINE_LIVELINESS_ALIVE: - // noop - case apiv2.MachineLiveliness_MACHINE_LIVELINESS_DEAD: - emojis = append(emojis, skull) - default: - emojis = append(emojis, question) - } - - if status.Condition != nil { - switch status.Condition.State { - case apiv2.MachineState_MACHINE_STATE_LOCKED: - emojis = append(emojis, lock) - case apiv2.MachineState_MACHINE_STATE_TAINTED: - emojis = append(emojis, bark) - default: - // noop - } - } - } - - if events := m.RecentProvisioningEvents; events != nil { - switch events.State { - case apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_FAILED_RECLAIM: - emojis = append(emojis, ambulance) - case apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_CRASHLOOP: - emojis = append(emojis, loop) - default: - // noop - - } - - if time.Since(events.LastErrorEvent.Time.AsTime()) < t.lastEventErrorThreshold { - emojis = append(emojis, exclamation) - } - } - - if m.Allocation != nil && m.Allocation.Vpn != nil && m.Allocation.Vpn.Connected { - emojis = append(emojis, vpn) - } - - return strings.Join(emojis, nbr) -} - func filterColumns(filter *apiv2.BGPFilter, i int) []string { var ( vni string diff --git a/docs/admin/metalctlv2_admin.md b/docs/admin/metalctlv2_admin.md index 9ed2106..d32ad0b 100644 --- a/docs/admin/metalctlv2_admin.md +++ b/docs/admin/metalctlv2_admin.md @@ -32,6 +32,7 @@ these commands utilize the admin api, which can only be accessed by metal-stack * [metalctlv2 admin component](metalctlv2_admin_component.md) - manage component entities * [metalctlv2 admin image](metalctlv2_admin_image.md) - manage image entities * [metalctlv2 admin ip](metalctlv2_admin_ip.md) - manage ip entities +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities * [metalctlv2 admin network](metalctlv2_admin_network.md) - manage network entities * [metalctlv2 admin partition](metalctlv2_admin_partition.md) - manage partition entities * [metalctlv2 admin project](metalctlv2_admin_project.md) - manage project entities diff --git a/docs/admin/metalctlv2_admin_machine.md b/docs/admin/metalctlv2_admin_machine.md new file mode 100644 index 0000000..2312c7d --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine.md @@ -0,0 +1,42 @@ +## metalctlv2 admin machine + +manage machine entities + +### Synopsis + +manage machines + +### Options + +``` + -h, --help help for machine +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin](metalctlv2_admin.md) - admin commands +* [metalctlv2 admin machine apply](metalctlv2_admin_machine_apply.md) - applies one or more machines from a given file +* [metalctlv2 admin machine bmc-command](metalctlv2_admin_machine_bmc-command.md) - send a command to the bmc of a machine +* [metalctlv2 admin machine console](metalctlv2_admin_machine_console.md) - establishes a connection to the serial console of a machine. for authentication at the metal-console it uses the token such that no machine ssh key is required for access (unlike the corresponding user API command). +* [metalctlv2 admin machine create](metalctlv2_admin_machine_create.md) - creates the machine +* [metalctlv2 admin machine delete](metalctlv2_admin_machine_delete.md) - deletes the machine +* [metalctlv2 admin machine describe](metalctlv2_admin_machine_describe.md) - describes the machine +* [metalctlv2 admin machine edit](metalctlv2_admin_machine_edit.md) - edit the machine through an editor and update +* [metalctlv2 admin machine list](metalctlv2_admin_machine_list.md) - list all machines +* [metalctlv2 admin machine lock](metalctlv2_admin_machine_lock.md) - lock or unlock a machine, e.g. machine cannot be used +* [metalctlv2 admin machine taint](metalctlv2_admin_machine_taint.md) - taint or untaint a machine, e.g. machine will not be automatically selected on machine create, only admins can create them +* [metalctlv2 admin machine update](metalctlv2_admin_machine_update.md) - updates the machine + diff --git a/docs/admin/metalctlv2_admin_machine_apply.md b/docs/admin/metalctlv2_admin_machine_apply.md new file mode 100644 index 0000000..a60524b --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_apply.md @@ -0,0 +1,46 @@ +## metalctlv2 admin machine apply + +applies one or more machines from a given file + +``` +metalctlv2 admin machine apply [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine apply -f - + $ # or via file + $ metalctlv2 machine apply -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for apply + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_bmc-command.md b/docs/admin/metalctlv2_admin_machine_bmc-command.md new file mode 100644 index 0000000..66a1d2d --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_bmc-command.md @@ -0,0 +1,32 @@ +## metalctlv2 admin machine bmc-command + +send a command to the bmc of a machine + +``` +metalctlv2 admin machine bmc-command [flags] +``` + +### Options + +``` + --command string the actual command to send to the machine + -h, --help help for bmc-command +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_console.md b/docs/admin/metalctlv2_admin_machine_console.md new file mode 100644 index 0000000..3c54808 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_console.md @@ -0,0 +1,32 @@ +## metalctlv2 admin machine console + +establishes a connection to the serial console of a machine. for authentication at the metal-console it uses the token such that no machine ssh key is required for access (unlike the corresponding user API command). + +``` +metalctlv2 admin machine console [flags] +``` + +### Options + +``` + -h, --help help for console + --ipmi if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present) +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_create.md b/docs/admin/metalctlv2_admin_machine_create.md new file mode 100644 index 0000000..5c31835 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_create.md @@ -0,0 +1,86 @@ +## metalctlv2 admin machine create + +creates the machine + +``` +metalctlv2 admin machine create [flags] +``` + +### Examples + +``` +machine create can be done in two different ways: + +- default with automatic allocation: + + metalctl machine create \ + --hostname worker01 \ + --name worker \ + --image ubuntu-18.04 \ # query available with: metalctl image list + --size t1-small-x86 \ # query available with: metalctl size list + --partition test \ # query available with: metalctl partition list + --project cluster01 \ + --sshpublickey "@~/.ssh/id_rsa.pub" + +- for metal administration with reserved machines: + + reserve a machine you want to allocate: + + metalctl machine reserve 00000000-0000-0000-0000-0cc47ae54694 --description "blocked for maintenance" + + allocate this machine: + + metalctl machine create \ + --hostname worker01 \ + --name worker \ + --image ubuntu-18.04 \ # query available with: metalctl image list + --project cluster01 \ + --sshpublickey "@~/.ssh/id_rsa.pub" \ + --id 00000000-0000-0000-0000-0cc47ae54694 + +after you do not want to use this machine exclusive, remove the reservation: + +metalctl machine reserve 00000000-0000-0000-0000-0cc47ae54694 --remove + +Once created the machine installation can not be modified anymore. + +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine create -f - + $ # or via file + $ metalctlv2 machine create -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for create + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_delete.md b/docs/admin/metalctlv2_admin_machine_delete.md new file mode 100644 index 0000000..d1befd2 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_delete.md @@ -0,0 +1,46 @@ +## metalctlv2 admin machine delete + +deletes the machine + +``` +metalctlv2 admin machine delete [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine delete -f - + $ # or via file + $ metalctlv2 machine delete -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for delete + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_describe.md b/docs/admin/metalctlv2_admin_machine_describe.md new file mode 100644 index 0000000..2f5894b --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_describe.md @@ -0,0 +1,32 @@ +## metalctlv2 admin machine describe + +describes the machine + +``` +metalctlv2 admin machine describe [flags] +``` + +### Options + +``` + -h, --help help for describe + -p, --project string project of the machine +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_edit.md b/docs/admin/metalctlv2_admin_machine_edit.md new file mode 100644 index 0000000..a3ae867 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_edit.md @@ -0,0 +1,31 @@ +## metalctlv2 admin machine edit + +edit the machine through an editor and update + +``` +metalctlv2 admin machine edit [flags] +``` + +### Options + +``` + -h, --help help for edit +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_list.md b/docs/admin/metalctlv2_admin_machine_list.md new file mode 100644 index 0000000..8b144f7 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_list.md @@ -0,0 +1,39 @@ +## metalctlv2 admin machine list + +list all machines + +``` +metalctlv2 admin machine list [flags] +``` + +### Options + +``` + -h, --help help for list + --hostname string hostname from machines which should be listed + --image string image + --name string name from machines which should be listed + --partition string partition from where machines should be listed + -p, --project string project from where machines should be listed + --size string size from machines which should be listed + --sort-by strings sort by (comma separated) column(s), sort direction can be changed by appending :asc or :desc behind the column identifier. possible values: age|image|partition|project|rack|size|uuid + --uuid string allocation uuid of machine which should be listed +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_lock.md b/docs/admin/metalctlv2_admin_machine_lock.md new file mode 100644 index 0000000..3163051 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_lock.md @@ -0,0 +1,33 @@ +## metalctlv2 admin machine lock + +lock or unlock a machine, e.g. machine cannot be used + +``` +metalctlv2 admin machine lock [flags] +``` + +### Options + +``` + --description string description of why the machine was locked + -h, --help help for lock + --remove if set to true, machine will be unlocked +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_taint.md b/docs/admin/metalctlv2_admin_machine_taint.md new file mode 100644 index 0000000..e0290f4 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_taint.md @@ -0,0 +1,33 @@ +## metalctlv2 admin machine taint + +taint or untaint a machine, e.g. machine will not be automatically selected on machine create, only admins can create them + +``` +metalctlv2 admin machine taint [flags] +``` + +### Options + +``` + --description string description of why the machine was tainted + -h, --help help for taint + --remove if set to true, machine will be untainted +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/admin/metalctlv2_admin_machine_update.md b/docs/admin/metalctlv2_admin_machine_update.md new file mode 100644 index 0000000..2c70e24 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_update.md @@ -0,0 +1,46 @@ +## metalctlv2 admin machine update + +updates the machine + +``` +metalctlv2 admin machine update [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine update -f - + $ # or via file + $ metalctlv2 machine update -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for update + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/metalctlv2.md b/docs/metalctlv2.md index b1579d5..a4524aa 100644 --- a/docs/metalctlv2.md +++ b/docs/metalctlv2.md @@ -27,6 +27,7 @@ cli for managing entities in metal-stack * [metalctlv2 ip](metalctlv2_ip.md) - manage ip entities * [metalctlv2 login](metalctlv2_login.md) - login * [metalctlv2 logout](metalctlv2_logout.md) - logout +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities * [metalctlv2 markdown](metalctlv2_markdown.md) - create markdown documentation * [metalctlv2 network](metalctlv2_network.md) - manage network entities * [metalctlv2 partition](metalctlv2_partition.md) - manage partition entities diff --git a/docs/metalctlv2_machine.md b/docs/metalctlv2_machine.md new file mode 100644 index 0000000..8df768c --- /dev/null +++ b/docs/metalctlv2_machine.md @@ -0,0 +1,38 @@ +## metalctlv2 machine + +manage machine entities + +### Synopsis + +allocate a machine + +### Options + +``` + -h, --help help for machine +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2](metalctlv2.md) - cli for managing entities in metal-stack +* [metalctlv2 machine apply](metalctlv2_machine_apply.md) - applies one or more machines from a given file +* [metalctlv2 machine create](metalctlv2_machine_create.md) - creates the machine +* [metalctlv2 machine delete](metalctlv2_machine_delete.md) - deletes the machine +* [metalctlv2 machine describe](metalctlv2_machine_describe.md) - describes the machine +* [metalctlv2 machine edit](metalctlv2_machine_edit.md) - edit the machine through an editor and update +* [metalctlv2 machine list](metalctlv2_machine_list.md) - list all machines +* [metalctlv2 machine update](metalctlv2_machine_update.md) - updates the machine + diff --git a/docs/metalctlv2_machine_apply.md b/docs/metalctlv2_machine_apply.md new file mode 100644 index 0000000..da19bbe --- /dev/null +++ b/docs/metalctlv2_machine_apply.md @@ -0,0 +1,46 @@ +## metalctlv2 machine apply + +applies one or more machines from a given file + +``` +metalctlv2 machine apply [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine apply -f - + $ # or via file + $ metalctlv2 machine apply -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for apply + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_create.md b/docs/metalctlv2_machine_create.md new file mode 100644 index 0000000..1938955 --- /dev/null +++ b/docs/metalctlv2_machine_create.md @@ -0,0 +1,68 @@ +## metalctlv2 machine create + +creates the machine + +``` +metalctlv2 machine create [flags] +``` + +### Options + +``` + -t, --allocation-type string allocation type, can be either machine|firewall (default "machine") + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -d, --description string Description of the machine to create. [optional] + --dnsservers strings dns servers to add to the machine or firewall. [optional] + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine create -f - + $ # or via file + $ metalctlv2 machine create -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + --filesystemlayout string Filesystemlayout to use during machine installation. [optional] + -h, --help help for create + -H, --hostname string Hostname of the machine. [required] + -I, --id string ID of a specific machine to allocate, if given, size and partition are ignored. Need to be set to reserved (--reserve) state before. + -i, --image string OS Image to install. [required] + -n, --name string Name of the machine. [optional] + --networks strings Adds a network. Usage: [--networks NETWORK,[ip:ip:ip][,NETWORK...]... + NETWORK specifies the name or id of an existing network. + IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. + + --ntpservers strings ntp servers to add to the machine or firewall. [optional] + -S, --partition string partition/datacenter where the machine is created. [required, except for reserved machines] + -P, --project string Project where the machine should belong to. [required] + -s, --size string Size of the machine. [required, except for reserved machines] + --skip-security-prompts skips security prompt for bulk operations + -p, --sshpublickey string SSH public key for access via ssh and console. [optional] + Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". + If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. + --tags strings tags to add to the machine, use it like: --tags "tag1,tag2" or --tags "tag3". + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations + --userdata string cloud-init.io compatible userdata. [optional] + Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg". +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_delete.md b/docs/metalctlv2_machine_delete.md new file mode 100644 index 0000000..dcab7bd --- /dev/null +++ b/docs/metalctlv2_machine_delete.md @@ -0,0 +1,47 @@ +## metalctlv2 machine delete + +deletes the machine + +``` +metalctlv2 machine delete [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine delete -f - + $ # or via file + $ metalctlv2 machine delete -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for delete + -p, --project string project of the machine + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_describe.md b/docs/metalctlv2_machine_describe.md new file mode 100644 index 0000000..3d9b27c --- /dev/null +++ b/docs/metalctlv2_machine_describe.md @@ -0,0 +1,32 @@ +## metalctlv2 machine describe + +describes the machine + +``` +metalctlv2 machine describe [flags] +``` + +### Options + +``` + -h, --help help for describe + -p, --project string project of the machine +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_edit.md b/docs/metalctlv2_machine_edit.md new file mode 100644 index 0000000..10279d8 --- /dev/null +++ b/docs/metalctlv2_machine_edit.md @@ -0,0 +1,31 @@ +## metalctlv2 machine edit + +edit the machine through an editor and update + +``` +metalctlv2 machine edit [flags] +``` + +### Options + +``` + -h, --help help for edit +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_list.md b/docs/metalctlv2_machine_list.md new file mode 100644 index 0000000..8717404 --- /dev/null +++ b/docs/metalctlv2_machine_list.md @@ -0,0 +1,39 @@ +## metalctlv2 machine list + +list all machines + +``` +metalctlv2 machine list [flags] +``` + +### Options + +``` + -h, --help help for list + --hostname string hostname from machines which should be listed + --image string image + --name string name from machines which should be listed + --partition string partition from where machines should be listed + -p, --project string project from where machines should be listed + --size string size from machines which should be listed + --sort-by strings sort by (comma separated) column(s), sort direction can be changed by appending :asc or :desc behind the column identifier. possible values: age|image|partition|project|rack|size|uuid + --uuid string allocation uuid of machine which should be listed +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_machine_update.md b/docs/metalctlv2_machine_update.md new file mode 100644 index 0000000..aa574cc --- /dev/null +++ b/docs/metalctlv2_machine_update.md @@ -0,0 +1,46 @@ +## metalctlv2 machine update + +updates the machine + +``` +metalctlv2 machine update [flags] +``` + +### Options + +``` + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine update -f - + $ # or via file + $ metalctlv2 machine update -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for update + --skip-security-prompts skips security prompt for bulk operations + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 machine](metalctlv2_machine.md) - manage machine entities + diff --git a/pkg/helpers/emoji.go b/pkg/helpers/emoji.go new file mode 100644 index 0000000..63e9c56 --- /dev/null +++ b/pkg/helpers/emoji.go @@ -0,0 +1,27 @@ +package helpers + +const ( + Ambulance = "🚑" + Exclamation = "❗" + Bark = "🚧" + Loop = "⭕" + Lock = "🔒" + Question = "❓" + Skull = "💀" + VPN = "🛡" +) + +func EmojiHelpText() string { + return ` +Meaning of the emojis: + +🚧 Machine is reserved. Reserved machines are not considered for random allocation until the reservation flag is removed. +🔒 Machine is locked. Locked machines can not be deleted until the lock is removed. +💀 Machine is dead. The metal-api does not receive any events from this machine. +❗ Machine has a last event error. The machine has recently encountered an error during the provisioning lifecycle. +❓ Machine is in unknown condition. The metal-api does not receive phoned home events anymore or has never booted successfully. +⭕ Machine is in a provisioning crash loop. Flag can be reset through an API-triggered reboot or when the machine reaches the phoned home state. +🚑 Machine reclaim has failed. The machine was deleted but it is not going back into the available machine pool. +🛡 Machine is connected to our VPN, ssh access only possible via this VPN. +` +} From 3ba1a073233e7463e1745ce76cf850b3d896a106 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 13:24:39 +0200 Subject: [PATCH 02/12] First tests. --- cmd/api/v2/machine.go | 43 +++- cmd/tableprinters/common.go | 5 + cmd/tableprinters/machine.go | 9 +- tests/e2e/api/machine_test.go | 401 +++++++++++++++++++++++++++++ tests/e2e/testresources/machine.go | 182 +++++++++++++ tests/e2e/testresources/switch.go | 4 +- 6 files changed, 628 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/api/machine_test.go create mode 100644 tests/e2e/testresources/machine.go diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index d804941..903138d 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -126,10 +126,9 @@ func (c *machine) List() ([]*apiv2.Machine, error) { var allocation *apiv2.MachineAllocationQuery - if viper.IsSet("hostname") || viper.IsSet("project") || viper.IsSet("image") { + if viper.IsSet("hostname") || viper.IsSet("image") { allocation = &apiv2.MachineAllocationQuery{ Hostname: pointer.PointerOrNil(viper.GetString("hostname")), - Project: pointer.PointerOrNil(viper.GetString("project")), Image: pointer.PointerOrNil(viper.GetString("image")), } } @@ -182,24 +181,45 @@ func (c *machine) MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServi return nil, fmt.Errorf("allocation is nil") } + var ( + networks []*apiv2.MachineAllocationNetwork + firewallSpec *apiv2.FirewallSpec + ) + + for _, nw := range r.Allocation.Networks { + networks = append(networks, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + if r.Allocation.AllocationType.Enum() == apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL.Enum() { + firewallSpec = &apiv2.FirewallSpec{ + FirewallRules: &apiv2.FirewallRules{ + Egress: r.Allocation.FirewallRules.Egress, + Ingress: r.Allocation.FirewallRules.Ingress, + }, + } + } + return &apiv2.MachineServiceCreateRequest{ Project: r.Allocation.Project, Name: r.Allocation.Name, Description: &r.Allocation.Description, Hostname: &r.Allocation.Hostname, - Partition: &r.Partition.Id, - Size: &r.Size.Id, + Partition: new(pointer.SafeDeref(r.Partition).Id), + Size: new(pointer.SafeDeref(r.Size).Id), Image: r.Allocation.Image.Id, FilesystemLayout: &r.Allocation.FilesystemLayout.Id, SshPublicKeys: r.Allocation.SshPublicKeys, Userdata: &r.Allocation.Userdata, Labels: pointer.SafeDeref(r.Meta).Labels, - // Networks: r.Allocation.Networks, TODO + Networks: networks, + DnsServers: r.Allocation.DnsServers, + NtpServers: r.Allocation.NtpServers, + AllocationType: r.Allocation.AllocationType, + FirewallSpec: firewallSpec, // PlacementTags: r.Allocation.Plac, // TODO: should be stored in the allocation to see what was provided - DnsServers: r.Allocation.DnsServers, - NtpServers: r.Allocation.NtpServers, - AllocationType: r.Allocation.AllocationType, - // FirewallSpec: r.Allocation.Fir, TODO }, nil } @@ -325,7 +345,7 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er description = new(viper.GetString("description")) } - mcr := &apiv2.MachineServiceCreateRequest{ + return &apiv2.MachineServiceCreateRequest{ Description: description, Partition: partition, Hostname: hostname, @@ -344,8 +364,7 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er PlacementTags: viper.GetStringSlice("placement-tags"), AllocationType: allocationType, FirewallSpec: firewallSpec, - } - return mcr, nil + }, nil } var defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 36c2215..eb7f89e 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -58,6 +58,11 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin case []*apiv2.Network: return t.NetworkTable(d, wide) + case *apiv2.Machine: + return t.MachineTable(pointer.WrapInSlice(d), wide) + case []*apiv2.Machine: + return t.MachineTable(d, wide) + case *apiv2.IP: return t.IPTable(pointer.WrapInSlice(d), wide) case []*apiv2.IP: diff --git a/cmd/tableprinters/machine.go b/cmd/tableprinters/machine.go index d59076d..e68262d 100644 --- a/cmd/tableprinters/machine.go +++ b/cmd/tableprinters/machine.go @@ -1,7 +1,6 @@ package tableprinters import ( - "fmt" "strings" "time" @@ -21,7 +20,7 @@ func (t *TablePrinter) MachineTable(data []*apiv2.Machine, wide bool) ([]string, ) if wide { - header = []string{"ID", "Last Event", "When", "Age", "Description", "Name", "Hostname", "Project", "IPs", "Size", "Image", "Partition", "Rack", "Started", "Tags", "Lock/Reserve"} + header = []string{"ID", "Last Event", "When", "Age", "Description", "Name", "Hostname", "Project", "Ips", "Size", "Image", "Partition", "Rack", "Started", "Tags", "State"} } for _, machine := range data { @@ -79,7 +78,11 @@ func (t *TablePrinter) MachineTable(data []*apiv2.Machine, wide bool) ([]string, if err != nil { return nil, nil, err } - reserved = fmt.Sprintf("%s:%s", *stateString, machine.Status.Condition.Description) + + reserved = *stateString + if machine.Status.Condition.Description != "" { + reserved += ":" + machine.Status.Condition.Description + } } if len(machine.RecentProvisioningEvents.Events) > 0 { diff --git a/tests/e2e/api/machine_test.go b/tests/e2e/api/machine_test.go new file mode 100644 index 0000000..25b0773 --- /dev/null +++ b/tests/e2e/api/machine_test.go @@ -0,0 +1,401 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + "github.com/metal-stack/api/go/client" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + e2erootcmd "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" +) + +func Test_MachineCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceListResponse, apiv2.Machine]{ + { + Name: "list", + CmdArgs: []string{"machine", "list", "--project", testresources.Project2().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceListRequest{ + Project: testresources.Project2().Uuid, + Query: &apiv2.MachineQuery{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceListResponse{ + Machines: []*apiv2.Machine{ + testresources.Machine2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + WantWideTable: new(` + ID LAST EVENT WHEN AGE DESCRIPTION NAME HOSTNAME PROJECT IPS SIZE IMAGE PARTITION RACK STARTED TAGS STATE + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd Phoned Home 1m 1m machine 2 machine-2 machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c 4.5.6.7 v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 1999-12-31T23:59:00Z c=d available + 192.1.1.1 + `), + Template: new("{{ .allocation.uuid }} {{ .allocation.project }}"), + WantTemplate: new(` + 4f94e87b-b08f-4f82-b053-9b8305de60ad f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c + `), + WantMarkdown: new(` + | ID | | LAST EVENT | WHEN | AGE | HOSTNAME | PROJECT | SIZE | IMAGE | PARTITION | RACK | + |---------------------------------------|---|-------------|------|-----|-----------|--------------------------------------|---------------|--------------|-------------|--------| + | 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd | 🛡 | Phoned Home | 1m | 1m | machine-2 | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | v1-medium-x86 | Ubuntu 24.04 | partition-2 | rack-1 | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ + { + Name: "describe", + CmdArgs: []string{"machine", "describe", "--project", testresources.Project2().Uuid, testresources.Machine2().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceGetRequest{ + Uuid: testresources.Machine2().Uuid, + Project: testresources.Project2().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceGetResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + WantWideTable: new(` + ID LAST EVENT WHEN AGE DESCRIPTION NAME HOSTNAME PROJECT IPS SIZE IMAGE PARTITION RACK STARTED TAGS STATE + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd Phoned Home 1m 1m machine 2 machine-2 machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c 4.5.6.7 v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 1999-12-31T23:59:00Z c=d available + 192.1.1.1 + `), + Template: new("{{ .allocation.uuid }} {{ .allocation.project }}"), + WantTemplate: new(` + 4f94e87b-b08f-4f82-b053-9b8305de60ad f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c + `), + WantMarkdown: new(` + | ID | | LAST EVENT | WHEN | AGE | HOSTNAME | PROJECT | SIZE | IMAGE | PARTITION | RACK | + |---------------------------------------|---|-------------|------|-----|-----------|--------------------------------------|---------------|--------------|-------------|--------| + | 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd | 🛡 | Phoned Home | 1m | 1m | machine-2 | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | v1-medium-x86 | Ubuntu 24.04 | partition-2 | rack-1 | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +// func Test_MachineCmd_Create(t *testing.T) { +// tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ +// { +// Name: "create", +// CmdArgs: []string{"machine", "create", "--project", testresources.Machine1().Project, "--network", testresources.Machine1().Network, "--static=true"}, +// NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceCreateRequest{ +// Project: testresources.Machine1().Project, +// Network: testresources.Machine1().Network, +// Type: &testresources.Machine1().Type, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }), +// WantProtoObject: testresources.Machine1(), +// }, +// { +// Name: "create from file", +// CmdArgs: append([]string{"machine", "create"}, e2e.AppendFromFileCommonArgs()...), +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// FsMocks: func(fs *afero.Afero) { +// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) +// }, +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceCreateRequest{ +// Ip: &testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Network: testresources.Machine1().Network, +// Name: &testresources.Machine1().Name, +// Descrmachinetion: &testresources.Machine1().Descrmachinetion, +// Labels: testresources.Machine1().Meta.Labels, +// Type: &testresources.Machine1().Type, +// AddressFamily: nil, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// }, +// } +// for _, tt := range tests { +// tt.TestCmd(t) +// } +// } + +// func Test_MachineCmd_Delete(t *testing.T) { +// tests := []*e2e.Test[apiv2.MachineServiceDeleteResponse, *apiv2.Machine]{ +// { +// Name: "delete", +// CmdArgs: []string{"machine", "delete", "--project", testresources.Machine1().Project, testresources.Machine1().Ip}, +// NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceDeleteRequest{ +// Ip: testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }), +// WantProtoObject: testresources.Machine1(), +// }, +// { +// Name: "delete from file", +// CmdArgs: append([]string{"machine", "delete"}, e2e.AppendFromFileCommonArgs()...), +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// FsMocks: func(fs *afero.Afero) { +// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) +// }, +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceDeleteRequest{ +// Ip: testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }, +// ), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// }, +// } +// for _, tt := range tests { +// tt.TestCmd(t) +// } +// } + +// func Test_MachineCmd_Update(t *testing.T) { +// tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ +// { +// Name: "update", +// CmdArgs: []string{"machine", "update", "--project", testresources.Machine1().Project, testresources.Machine1().Ip, "--name", "foo"}, +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceUpdateRequest{ +// Ip: testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Name: new("foo"), +// UpdateMeta: &apiv2.UpdateMeta{ +// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, +// }, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }, +// ), +// WantProtoObject: testresources.Machine1(), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// WantWideTable: new(` +// Machine PROJECT ID TYPE NAME DESCRMachineTION LABELS +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a a descrmachinetion cluster.metal-stack.io/id/namespace/service=/default/ingress-nginx +// `), +// }, +// { +// Name: "update from file", +// CmdArgs: append([]string{"machine", "update"}, e2e.AppendFromFileCommonArgs()...), +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// FsMocks: func(fs *afero.Afero) { +// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) +// }, +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceUpdateRequest{ +// Ip: testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Descrmachinetion: &testresources.Machine1().Descrmachinetion, +// Labels: &apiv2.UpdateLabels{ +// Strategy: &apiv2.UpdateLabels_Replace{ +// Replace: &apiv2.Labels{ +// Labels: testresources.Machine1().Meta.Labels.Labels, +// }, +// }, +// }, +// Name: &testresources.Machine1().Name, +// Type: &testresources.Machine1().Type, +// UpdateMeta: &apiv2.UpdateMeta{ +// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, +// }, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }, +// ), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// }, +// } +// for _, tt := range tests { +// tt.TestCmd(t) +// } +// } + +// func Test_MachineCmd_Apply(t *testing.T) { +// tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ +// { +// Name: "apply", +// CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// FsMocks: func(fs *afero.Afero) { +// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) +// }, +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceCreateRequest{ +// Ip: &testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Network: testresources.Machine1().Network, +// Name: &testresources.Machine1().Name, +// Descrmachinetion: &testresources.Machine1().Descrmachinetion, +// Labels: testresources.Machine1().Meta.Labels, +// Type: &testresources.Machine1().Type, +// AddressFamily: nil, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }, +// ), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// }, +// { +// Name: "apply already exists", +// CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), +// NewRootCmd: e2erootcmd.NewRootCmd(t, +// &e2erootcmd.TestConfig{ +// FsMocks: func(fs *afero.Afero) { +// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) +// }, +// ClientCalls: []client.ClientCall{ +// { +// WantRequest: &apiv2.MachineServiceCreateRequest{ +// Ip: &testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Network: testresources.Machine1().Network, +// Name: &testresources.Machine1().Name, +// Descrmachinetion: &testresources.Machine1().Descrmachinetion, +// Labels: testresources.Machine1().Meta.Labels, +// Type: &testresources.Machine1().Type, +// AddressFamily: nil, +// }, +// WantError: connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("already exists")), +// }, +// { +// WantRequest: &apiv2.MachineServiceUpdateRequest{ +// Ip: testresources.Machine1().Ip, +// Project: testresources.Machine1().Project, +// Descrmachinetion: &testresources.Machine1().Descrmachinetion, +// Labels: &apiv2.UpdateLabels{ +// Strategy: &apiv2.UpdateLabels_Replace{ +// Replace: &apiv2.Labels{ +// Labels: testresources.Machine1().Meta.Labels.Labels, +// }, +// }}, +// Name: &testresources.Machine1().Name, +// Type: &testresources.Machine1().Type, +// UpdateMeta: &apiv2.UpdateMeta{ +// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, +// }, +// }, +// WantResponse: func() connect.AnyResponse { +// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ +// Ip: testresources.Machine1(), +// }) +// }, +// }, +// }, +// }, +// ), +// WantTable: new(` +// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE +// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a +// `), +// }, +// } +// for _, tt := range tests { +// tt.TestCmd(t) +// } +// } diff --git a/tests/e2e/testresources/machine.go b/tests/e2e/testresources/machine.go new file mode 100644 index 0000000..68089c9 --- /dev/null +++ b/tests/e2e/testresources/machine.go @@ -0,0 +1,182 @@ +package testresources + +import ( + "time" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + Machine1 = func() *apiv2.Machine { + return &apiv2.Machine{ + Uuid: "5fa2bbe1-407c-4142-92d5-e4419daf9646", + Meta: &apiv2.Meta{ + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "a": "b", + }, + }, + }, + Partition: Partition1(), + Rack: *Switch1().Rack, + Room: *Switch1().Room, + Size: Size1(), + Hardware: &apiv2.MachineHardware{ + Memory: 1024, + Disks: []*apiv2.MachineBlockDevice{ + { + Name: "/dev/sda", + Size: 1, + }, + }, + Cpus: []*apiv2.MetalCPU{ + { + Vendor: "Intel", + Model: "Xeon", + Cores: 4, + Threads: 4, + }, + }, + Gpus: []*apiv2.MetalGPU{}, + Nics: []*apiv2.MachineNic{}, + }, + Allocation: nil, + Status: &apiv2.MachineStatus{ + Condition: &apiv2.MachineCondition{ + State: apiv2.MachineState_MACHINE_STATE_AVAILABLE, + }, + LedState: &apiv2.MachineChassisIdentifyLEDState{ + Value: "", + Description: "", + }, + Liveliness: apiv2.MachineLiveliness_MACHINE_LIVELINESS_ALIVE, + MetalHammerVersion: "1", + }, + RecentProvisioningEvents: &apiv2.MachineRecentProvisioningEvents{ + Events: []*apiv2.MachineProvisioningEvent{ + { + Time: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Minute)), + Event: apiv2.MachineProvisioningEventType_MACHINE_PROVISIONING_EVENT_TYPE_ALIVE, + Message: "alive", + }, + }, + LastEventTime: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Minute)), + LastErrorEvent: &apiv2.MachineProvisioningEvent{ + Time: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Hour)), + Event: apiv2.MachineProvisioningEventType_MACHINE_PROVISIONING_EVENT_TYPE_WAITING, + Message: "waiting", + }, + State: apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_UNSPECIFIED, + }, + } + } + Machine2 = func() *apiv2.Machine { + return &apiv2.Machine{ + Uuid: "673fc473-63ca-4ea4-b9dd-b45cb2127a6fd", + Meta: &apiv2.Meta{ + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "c": "d", + }, + }, + }, + Partition: Partition2(), + Rack: *Switch2().Rack, + Room: *Switch2().Room, + Size: Size1(), + Hardware: &apiv2.MachineHardware{ + Memory: 1024, + Disks: []*apiv2.MachineBlockDevice{ + { + Name: "/dev/sda", + Size: 1, + }, + }, + Cpus: []*apiv2.MetalCPU{ + { + Vendor: "Intel", + Model: "Xeon", + Cores: 4, + Threads: 4, + }, + }, + Gpus: []*apiv2.MetalGPU{}, + Nics: []*apiv2.MachineNic{}, + }, + Allocation: &apiv2.MachineAllocation{ + Uuid: "4f94e87b-b08f-4f82-b053-9b8305de60ad", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Minute)), + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "e": "f", + }, + }, + }, + Name: "machine-2", + Description: "machine 2", + CreatedBy: "foo", + Project: Project2().Uuid, + Image: Image1(), + FilesystemLayout: &apiv2.FilesystemLayout{}, + Networks: []*apiv2.MachineNetwork{ + { + Network: Network1().Id, + Ips: []string{"4.5.6.7"}, + }, + { + Network: Network2().Id, + Ips: []string{"192.1.1.1"}, + }, + }, + Hostname: "machine-2", + SshPublicKeys: []string{}, + Userdata: "", + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallRules: nil, + DnsServers: []*apiv2.DNSServer{}, + NtpServers: []*apiv2.NTPServer{}, + Vpn: &apiv2.MachineVPN{ + ControlPlaneAddress: "1.2.3.4", + AuthKey: "abc", + Connected: true, + Ips: []string{"3.4.5.6"}, + }, + }, + Status: &apiv2.MachineStatus{ + Condition: &apiv2.MachineCondition{ + State: apiv2.MachineState_MACHINE_STATE_AVAILABLE, + }, + LedState: &apiv2.MachineChassisIdentifyLEDState{ + Value: "", + Description: "", + }, + Liveliness: apiv2.MachineLiveliness_MACHINE_LIVELINESS_ALIVE, + MetalHammerVersion: "1", + }, + RecentProvisioningEvents: &apiv2.MachineRecentProvisioningEvents{ + Events: []*apiv2.MachineProvisioningEvent{ + { + Time: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Minute)), + Event: apiv2.MachineProvisioningEventType_MACHINE_PROVISIONING_EVENT_TYPE_PHONED_HOME, + Message: "phoned home", + }, + { + Time: timestamppb.New(e2e.TimeBubbleStartTime().Add(-2 * time.Minute)), + Event: apiv2.MachineProvisioningEventType_MACHINE_PROVISIONING_EVENT_TYPE_ALIVE, + Message: "alive", + }, + }, + LastEventTime: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Minute)), + LastErrorEvent: &apiv2.MachineProvisioningEvent{ + Time: timestamppb.New(e2e.TimeBubbleStartTime().Add(-1 * time.Hour)), + Event: apiv2.MachineProvisioningEventType_MACHINE_PROVISIONING_EVENT_TYPE_WAITING, + Message: "waiting", + }, + State: apiv2.MachineProvisioningEventState_MACHINE_PROVISIONING_EVENT_STATE_UNSPECIFIED, + }, + } + } +) diff --git a/tests/e2e/testresources/switch.go b/tests/e2e/testresources/switch.go index 7564b02..09ad012 100644 --- a/tests/e2e/testresources/switch.go +++ b/tests/e2e/testresources/switch.go @@ -15,11 +15,12 @@ var ( Id: "leaf01", Partition: "fra-equ01", Rack: new("rack-1"), + Room: new("room-1"), Description: "leaf switch 1", ManagementIp: "10.0.0.1", ManagementUser: new("admin"), MachineConnections: []*apiv2.MachineConnection{ - &apiv2.MachineConnection{ + { MachineId: "id1", Nic: Nic1(), }, @@ -44,6 +45,7 @@ var ( Id: "leaf02", Partition: "fra-equ01", Rack: new("rack-1"), + Room: new("room-2"), Description: "leaf switch 2", ManagementIp: "10.0.0.2", ManagementUser: new("admin"), From 12dbc68d1c6da87bb63c32a977ff3856e1ef9cbc Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 14:22:05 +0200 Subject: [PATCH 03/12] More tests. --- cmd/api/v2/machine.go | 89 +++++++--------- cmd/config/config.go | 2 +- cmd/root.go | 4 +- testing/e2e/test_cmd.go | 4 +- tests/e2e/api/machine_test.go | 191 +++++++++++++++++++++++----------- 5 files changed, 177 insertions(+), 113 deletions(-) diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index 903138d..184fe4d 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -17,6 +17,7 @@ import ( "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -245,16 +246,15 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er ntpServers []*apiv2.NTPServer allocationType apiv2.MachineAllocationType firewallSpec *apiv2.FirewallSpec - labels *apiv2.Labels - sshPublicKeyArgument = viper.GetString("sshpublickey") - dnsServersArgument = viper.GetStringSlice("dnsservers") - ntpServersArgument = viper.GetStringSlice("ntpservers") + sshPublicKeyArgument = viper.GetString("ssh-public-key") + dnsServersArgument = viper.GetStringSlice("dns-servers") + ntpServersArgument = viper.GetStringSlice("ntp-servers") ) if strings.HasPrefix(sshPublicKeyArgument, "@") { var err error - sshPublicKeyArgument, err = readFromFile(sshPublicKeyArgument[1:]) + sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKeyArgument[1:]) if err != nil { return nil, err } @@ -266,7 +266,7 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er return nil, err } sshPublicKey := sshKey + ".pub" - sshPublicKeyArgument, err = readFromFile(sshPublicKey) + sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKey) if err != nil { return nil, err } @@ -279,7 +279,7 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er userDataArgument := viper.GetString("userdata") if strings.HasPrefix(userDataArgument, "@") { var err error - userDataArgument, err = readFromFile(userDataArgument[1:]) + userDataArgument, err = readFromFile(c.c.Fs, userDataArgument[1:]) if err != nil { return nil, err } @@ -307,31 +307,19 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL } - for k, v := range viper.GetStringMap("labels") { - if labels == nil { - labels = &apiv2.Labels{} - } else { - value, ok := v.(string) - if ok { - labels.Labels[k] = value - } else { - labels.Labels[k] = "" - } - } + labels, err := helpers.LabelsFromSlice(viper.GetStringSlice("labels")) + if err != nil { + return nil, err } var filesystemlayout *string - if viper.IsSet("filesystemlayout") { - filesystemlayout = new(viper.GetString("filesystemlayout")) + if viper.IsSet("filesystem-layout") { + filesystemlayout = new(viper.GetString("filesystem-layout")) } var size *string if viper.IsSet("size") { size = new(viper.GetString("size")) } - var uuid *string - if viper.IsSet("id") { - uuid = new(viper.GetString("id")) - } var partition *string if viper.IsSet("partition") { partition = new(viper.GetString("partition")) @@ -351,7 +339,6 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er Hostname: hostname, Image: viper.GetString("image"), Name: viper.GetString("name"), - Uuid: uuid, Project: viper.GetString("project"), Size: size, SshPublicKeys: keys, @@ -394,16 +381,18 @@ func searchSSHKey() (string, error) { } return key, nil } -func readFromFile(filePath string) (string, error) { + +func readFromFile(fs *afero.Afero, filePath string) (string, error) { filePath, err := expandFilepath(filePath) if err != nil { return "", err } - content, err := os.ReadFile(filePath) + content, err := fs.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("unable to read from given file %s error:%w", filePath, err) + return "", fmt.Errorf("unable to read from given file %q: %w", filePath, err) } + return strings.TrimSpace(string(content)), nil } @@ -422,8 +411,10 @@ func expandFilepath(filePath string) (string, error) { return filePath, nil } + func parseNetworks(possibleNetworks []string) ([]*apiv2.MachineAllocationNetwork, error) { var result []*apiv2.MachineAllocationNetwork + for _, n := range possibleNetworks { if n == "" { continue @@ -434,48 +425,49 @@ func parseNetworks(possibleNetworks []string) ([]*apiv2.MachineAllocationNetwork nw, ipsString, found := strings.Cut(n, ":") if found { man.Network = nw - for ip := range strings.SplitSeq(ipsString, ",") { + for ip := range strings.SplitSeq(ipsString, ";") { if ip == "" { continue } _, err := netip.ParseAddr(ip) if err != nil { - return nil, fmt.Errorf("malformed ip:%s %w", ip, err) + return nil, fmt.Errorf("malformed ip %q: %w", ip, err) } man.Ips = append(man.Ips, ip) } } result = append(result, man) } + return result, nil } func (c *machine) addMachineCreateFlags(cmd *cobra.Command, name string) { - cmd.Flags().StringP("description", "d", "", "Description of the "+name+" to create. [optional]") - cmd.Flags().StringP("partition", "S", "", "partition/datacenter where the "+name+" is created. [required, except for reserved machines]") - cmd.Flags().StringP("hostname", "H", "", "Hostname of the "+name+". [required]") - cmd.Flags().StringP("image", "i", "", "OS Image to install. [required]") - cmd.Flags().StringP("filesystemlayout", "", "", "Filesystemlayout to use during machine installation. [optional]") - cmd.Flags().StringP("name", "n", "", "Name of the "+name+". [optional]") - cmd.Flags().StringP("id", "I", "", "ID of a specific "+name+" to allocate, if given, size and partition are ignored. Need to be set to reserved (--reserve) state before.") - cmd.Flags().StringP("project", "P", "", "Project where the "+name+" should belong to. [required]") - cmd.Flags().StringP("size", "s", "", "Size of the "+name+". [required, except for reserved machines]") - cmd.Flags().StringP("allocation-type", "t", "machine", "allocation type, can be either machine|firewall") - cmd.Flags().StringP("sshpublickey", "p", "", + cmd.Flags().String("description", "", "Description of the "+name+" to create. [optional]") + cmd.Flags().String("partition", "", "partition/datacenter where the "+name+" is created. [required, except for reserved machines]") + cmd.Flags().String("hostname", "", "Hostname of the "+name+". [required]") + cmd.Flags().String("image", "", "OS Image to install. [required]") + cmd.Flags().String("filesystem-layout", "", "Filesystemlayout to use during machine installation. [optional]") + cmd.Flags().String("name", "", "Name of the "+name+". [optional]") + cmd.Flags().StringP("project", "p", "", "Project where the "+name+" should belong to. [required]") + cmd.Flags().String("size", "", "Size of the "+name+". [required, except for reserved machines]") + cmd.Flags().String("allocation-type", "machine", "allocation type, can be either machine|firewall") + cmd.Flags().StringP("ssh-public-key", "i", "", `SSH public key for access via ssh and console. [optional] Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) - cmd.Flags().StringSlice("tags", []string{}, "tags to add to the "+name+", use it like: --tags \"tag1,tag2\" or --tags \"tag3\".") - cmd.Flags().StringP("userdata", "", "", `cloud-init.io compatible userdata. [optional] + cmd.Flags().StringSlice("labels", []string{}, "labels to add to the "+name+", use it like: --labels \"a=b\" or --labels \"a=\".") + cmd.Flags().String("userdata", "", `cloud-init.io compatible userdata. [optional] Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg".`) - cmd.Flags().StringSlice("dnsservers", []string{}, "dns servers to add to the machine or firewall. [optional]") - cmd.Flags().StringSlice("ntpservers", []string{}, "ntp servers to add to the machine or firewall. [optional]") + cmd.Flags().StringSlice("dns-servers", []string{}, "dns servers to add to the machine or firewall. [optional]") + cmd.Flags().StringSlice("ntp-servers", []string{}, "ntp servers to add to the machine or firewall. [optional]") cmd.Flags().StringSlice("networks", []string{}, - `Adds a network. Usage: [--networks NETWORK,[ip:ip:ip][,NETWORK...]... + `Adds a network. Usage: [--networks NETWORK[:ip[;ip]][,NETWORK[:ip[;ip]]... NETWORK specifies the name or id of an existing network. IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. `) + cmd.Flags().StringSlice("placement-tags", []string{}, "placement tags used for rack spreading") cmd.MarkFlagsMutuallyExclusive("file", "project") cmd.MarkFlagsRequiredTogether("project", "networks", "hostname", "image") @@ -486,8 +478,7 @@ IPs can be added per network colon separated, these ips must be already allocate genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.c.Completion.Partition)) genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.c.Completion.Size)) genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.c.Completion.Project)) - genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.c.Completion.Machine)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.c.Completion.Image)) // FIXME implement - // genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.c.Completion.ImageListCompletion)) - // genericcli.Must(cmd.RegisterFlagCompletionFunc("filesystemlayout", c.c.Completion.FilesystemLayoutListCompletion)) + // genericcli.Must(cmd.RegisterFlagCompletionFunc("filesystem-layout", c.c.Completion.FilesystemLayoutListCompletion)) } diff --git a/cmd/config/config.go b/cmd/config/config.go index a5b9017..fdc692c 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -26,7 +26,7 @@ const ( ) type Config struct { - Fs afero.Fs + Fs *afero.Afero In io.Reader Out io.Writer PromptOut io.Writer diff --git a/cmd/root.go b/cmd/root.go index ae6ea2d..821a391 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,7 +20,9 @@ import ( func Execute() { cfg := &config.Config{ - Fs: afero.NewOsFs(), + Fs: &afero.Afero{ + Fs: afero.NewOsFs(), + }, Out: os.Stdout, PromptOut: os.Stdout, In: os.Stdin, diff --git a/testing/e2e/test_cmd.go b/testing/e2e/test_cmd.go index ba36594..9843115 100644 --- a/testing/e2e/test_cmd.go +++ b/testing/e2e/test_cmd.go @@ -57,7 +57,9 @@ func NewRootCmd(t *testing.T, c *TestConfig) e2e_test.NewRootCmdFunc { viper.Reset() return cmd.NewRootCmd(&config.Config{ - Fs: fs, + Fs: &afero.Afero{ + Fs: fs, + }, Out: &out, In: in, PromptOut: io.Discard, diff --git a/tests/e2e/api/machine_test.go b/tests/e2e/api/machine_test.go index 25b0773..246e438 100644 --- a/tests/e2e/api/machine_test.go +++ b/tests/e2e/api/machine_test.go @@ -1,6 +1,9 @@ package api_e2e import ( + "encoding/base64" + "os" + "strings" "testing" "connectrpc.com/connect" @@ -8,7 +11,9 @@ import ( apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" e2erootcmd "github.com/metal-stack/cli/testing/e2e" "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" + "github.com/spf13/afero" ) func Test_MachineCmd_List(t *testing.T) { @@ -104,67 +109,131 @@ func Test_MachineCmd_Describe(t *testing.T) { } } -// func Test_MachineCmd_Create(t *testing.T) { -// tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ -// { -// Name: "create", -// CmdArgs: []string{"machine", "create", "--project", testresources.Machine1().Project, "--network", testresources.Machine1().Network, "--static=true"}, -// NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceCreateRequest{ -// Project: testresources.Machine1().Project, -// Network: testresources.Machine1().Network, -// Type: &testresources.Machine1().Type, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }), -// WantProtoObject: testresources.Machine1(), -// }, -// { -// Name: "create from file", -// CmdArgs: append([]string{"machine", "create"}, e2e.AppendFromFileCommonArgs()...), -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// FsMocks: func(fs *afero.Afero) { -// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) -// }, -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceCreateRequest{ -// Ip: &testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Network: testresources.Machine1().Network, -// Name: &testresources.Machine1().Name, -// Descrmachinetion: &testresources.Machine1().Descrmachinetion, -// Labels: testresources.Machine1().Meta.Labels, -// Type: &testresources.Machine1().Type, -// AddressFamily: nil, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// }, -// } -// for _, tt := range tests { -// tt.TestCmd(t) -// } -// } +func Test_MachineCmd_Create(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ + { + Name: "create", + CmdArgs: []string{"machine", "create", + "--project", testresources.Machine2().Allocation.Project, + "--networks", func() string { + var names []string + + for _, nw := range testresources.Machine2().Allocation.Networks { + nwWithIps := nw.Network + if len(nw.Ips) > 0 { + nwWithIps += ":" + strings.Join(nw.Ips, ";") + } + names = append(names, nwWithIps) + } + + return strings.Join(names, ",") + }(), + "--allocation-type", testresources.Machine2().Allocation.AllocationType.String(), + "--description", testresources.Machine2().Allocation.Description, + "--dns-servers", "1.1.1.1", + "--ntp-servers", "2.2.2.2,3.3.3.3", + "--filesystem-layout", "fsl1", + "--hostname", testresources.Machine2().Allocation.Hostname, + "--image", testresources.Image1().Id, + "--name", testresources.Machine2().Allocation.Name, + "--partition", testresources.Machine2().Partition.Id, + "--size", testresources.Machine2().Size.Id, + "--ssh-public-key", "@.ssh/id_rsa.pub", + "--labels", "a=b", + "--userdata", "@ignition.json", + "--placement-tags", "cluster-id=cluster-uuid", + }, + AssertExhaustiveArgs: true, + AssertExhaustiveExcludes: e2e.CommonExcludedFileArgs(), + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + genericcli.Must(fs.WriteFile(".ssh/id_rsa.pub", []byte("12345"), os.ModeAppend)) + genericcli.Must(fs.WriteFile("ignition.json", []byte("{}"), os.ModeAppend)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Partition: &testresources.Machine2().Partition.Id, + Size: &testresources.Machine2().Size.Id, + Image: testresources.Machine2().Allocation.Image.Id, + FilesystemLayout: new("fsl1"), + SshPublicKeys: []string{"12345"}, + Userdata: new(base64.StdEncoding.EncodeToString([]byte("{}"))), + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "a": "b", + }, + }, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + PlacementTags: []string{"cluster-id=cluster-uuid"}, + DnsServers: []*apiv2.DNSServer{{Ip: "1.1.1.1"}}, + NtpServers: []*apiv2.NTPServer{{Address: "2.2.2.2"}, {Address: "3.3.3.3"}}, + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + }, + // { + // Name: "create from file", + // CmdArgs: append([]string{"machine", "create"}, e2e.AppendFromFileCommonArgs()...), + // NewRootCmd: e2erootcmd.NewRootCmd(t, + // &e2erootcmd.TestConfig{ + // FsMocks: func(fs *afero.Afero) { + // require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) + // }, + // ClientCalls: []client.ClientCall{ + // { + // WantRequest: &apiv2.MachineServiceCreateRequest{ + // Ip: &testresources.Machine1().Ip, + // Project: testresources.Machine1().Project, + // Network: testresources.Machine1().Network, + // Name: &testresources.Machine1().Name, + // Descrmachinetion: &testresources.Machine1().Descrmachinetion, + // Labels: testresources.Machine1().Meta.Labels, + // Type: &testresources.Machine1().Type, + // AddressFamily: nil, + // }, + // WantResponse: func() connect.AnyResponse { + // return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + // Ip: testresources.Machine1(), + // }) + // }, + // }, + // }, + // }), + // WantTable: new(` + // Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE + // 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a + // `), + // }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} // func Test_MachineCmd_Delete(t *testing.T) { // tests := []*e2e.Test[apiv2.MachineServiceDeleteResponse, *apiv2.Machine]{ From a99884ab72b917e157da89824e66439c4a099d59 Mon Sep 17 00:00:00 2001 From: Stefan Majer Date: Fri, 14 Aug 2026 14:51:25 +0200 Subject: [PATCH 04/12] console and ssh command --- cmd/admin/v2/machine.go | 227 +++++++++++++++++++++++++++++++++++++- cmd/completion/machine.go | 32 +++++- go.mod | 43 +++++++- go.sum | 221 ++++++++++++++++++++++++++++++++++++- 4 files changed, 513 insertions(+), 10 deletions(-) diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index a45ba32..a8a8f79 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -1,7 +1,14 @@ package v2 import ( + "context" "fmt" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" @@ -10,6 +17,9 @@ import ( "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" "github.com/metal-stack/metal-lib/pkg/pointer" + metalssh "github.com/metal-stack/metal-lib/pkg/ssh" + metalvpn "github.com/metal-stack/metal-lib/pkg/vpn" + "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -137,7 +147,19 @@ Once created the machine installation can not be modified anymore. } consoleCmd.Flags().Bool("ipmi", false, "if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present)") - return genericcli.NewCmds(cmdsConfig, bmcCommandCmd, lockCmd, taintCmd, consoleCmd) + firewallSSHCmd := &cobra.Command{ + Use: "ssh ", + Short: "SSH to a firewall", + Long: `SSH to a firewall via VPN.`, + RunE: func(cmd *cobra.Command, args []string) error { + return w.firewallSSH(args) + }, + ValidArgsFunction: c.Completion.Firewall, + } + firewallSSHCmd.Flags().StringP("identity", "i", "~/.ssh/id_rsa", "specify identity file to SSH to the firewall like: -i path/to/id_rsa") + firewallSSHCmd.Flags().String("reason", "", "the reason why to connect to the firewall through SSH") + + return genericcli.NewCmds(cmdsConfig, bmcCommandCmd, lockCmd, taintCmd, consoleCmd, firewallSSHCmd) } func (c *machine) Create(rq any) (*apiv2.Machine, error) { @@ -286,6 +308,207 @@ func (c *machine) bmcCommand(args []string) error { return err } +// Port open on our control-plane to connect via ssh to get machine console access. +const bmcConsolePort = 5222 + func (c *machine) console(args []string) error { - panic("unimplemented") + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + useIpmi := viper.GetBool("ipmi") + if useIpmi { + return c.impitool(id) + } + + parsedurl, err := url.Parse(pointer.SafeDeref(c.c.Context.ApiURL)) + if err != nil { + return err + } + + err = sshClient(id, viper.GetString("sshidentity"), parsedurl.Host, bmcConsolePort, &c.c.Context.Token, true) + if err != nil { + return fmt.Errorf("machine console error:%w", err) + } + + return nil +} + +func (c *machine) impitool(id string) error { + path, err := exec.LookPath("ipmitool") + if err != nil { + return fmt.Errorf("unable to locate ipmitool in path") + } + + resp, err := c.c.Client.Adminv2().Machine().GetBMC(context.Background(), &adminv2.MachineServiceGetBMCRequest{ + Uuid: id, + }) + if err != nil { + return err + } + + bmc := resp.Bmc.Bmc + intf := "lanplus" + + // -I lanplus -H 192.168.2.19 -U ADMIN -P ADMIN sol activate + hostAndPort := strings.Split(bmc.Address, ":") + if len(hostAndPort) < 2 { + hostAndPort = append(hostAndPort, "623") + } + usr := bmc.User + if bmc.User == "" { + _, _ = fmt.Fprintf(c.c.Out, "no ipmi user stored, please specify with --ipmiuser\n") + } + ipmiuser := viper.GetString("ipmiuser") + if ipmiuser != "" { + usr = ipmiuser + } + password := bmc.Password + if bmc.Password == "" { + _, _ = fmt.Fprintf(c.c.Out, "no ipmi password stored, please specify with --ipmipassword\n") + } + + bmcpassword := viper.GetString("ipmipassword") + if bmcpassword != "" { + password = bmcpassword + } + + err = os.Setenv("IPMITOOL_PASSWORD", password) + if err != nil { + return err + } + defer func() { + _ = os.Unsetenv("IPMITOOL_PASSWORD") + }() + + args := []string{"-I", intf, "-H", hostAndPort[0], "-p", hostAndPort[1], "-U", usr, "-E", "sol", "activate"} + _, _ = fmt.Fprintf(c.c.Out, "connecting to console with:\n%s %s\nExit with ~.\n\n", path, strings.Join(args, " ")) + cmd := exec.Command(path, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + return cmd.Run() +} + +func (c *machine) firewallSSH(args []string) (err error) { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + machine, err := c.Get(id) + if err != nil { + return fmt.Errorf("failed to find firewall: %w", err) + } + + if machine.Allocation == nil { + return fmt.Errorf("firewall allocation is nil") + } + + if machine.Allocation.AllocationType != apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL { + return fmt.Errorf("ssh can only be made to firewalls.") + } + + projectID := machine.Allocation.Project + _, _ = fmt.Fprintf(c.c.Out, "accessing firewall through vpn ") + authKeyResp, err := c.c.Client.Adminv2().VPN().AuthKey(context.Background(), &adminv2.VPNServiceAuthKeyRequest{ + Project: projectID, + Ephemeral: true, + Reason: viper.GetString("reason"), + }) + if err != nil { + return fmt.Errorf("failed to get VPN auth key: %w", err) + } + ctx := context.Background() + v, err := metalvpn.Connect(ctx, machine.Uuid, authKeyResp.Address, authKeyResp.AuthKey) + if err != nil { + return err + } + defer func() { + _ = v.Close() + }() + + privateKeyFile := viper.GetString("identity") + if strings.HasPrefix(privateKeyFile, "~/") { + home, _ := os.UserHomeDir() + privateKeyFile = filepath.Join(home, privateKeyFile[2:]) + } + + privateKey, err := os.ReadFile(privateKeyFile) + if err != nil { + return err + } + + opts := []metalssh.ConnectOpt{metalssh.ConnectOptOutputPrivateKey(privateKey)} + + s, err := metalssh.NewClientWithConnection("metal", v.TargetIP, v.Conn, opts...) + if err != nil { + return err + } + return s.Connect(nil) +} + +// sshClient opens an interactive ssh session to the host on port with user, authenticated by the key. +func sshClient(user, keyfile, host string, port int, idToken *string, passwordAuth bool) error { + + var opts []metalssh.ConnectOpt + if passwordAuth { + opts = append(opts, metalssh.ConnectOptOutputPassword(*idToken)) + } else { + if keyfile == "" { + var err error + keyfile, err = searchSSHKey() + if err != nil { + return err + } + } + + privateKey, err := os.ReadFile(keyfile) + if err != nil { + return err + } + + opts = append(opts, metalssh.ConnectOptOutputPrivateKey(privateKey)) + } + + s, err := metalssh.NewClient(user, host, port, opts...) + if err != nil { + return err + } + var env *metalssh.Env + if idToken != nil { + env = &metalssh.Env{"LC_METAL_STACK_OIDC_TOKEN": *idToken} + } + return s.Connect(env) +} + +var ( + defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} +) + +func searchSSHKey() (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) + } + homeDir := currentUser.HomeDir + defaultDir := filepath.Join(homeDir, "/.ssh/") + var key string + for _, k := range defaultSSHKeys { + possibleKey := filepath.Join(defaultDir, k) + _, err := os.ReadFile(possibleKey) + if err == nil { + fmt.Printf("using SSH identity: %s. Another identity can be specified with --sshidentity/-p\n", + possibleKey) + key = possibleKey + break + } + } + + if key == "" { + return "", fmt.Errorf("failure to locate a SSH identity in default location (%s), "+ + "another identity can be specified with --sshidentity/-p", defaultDir) + } + return key, nil } diff --git a/cmd/completion/machine.go b/cmd/completion/machine.go index a108cc6..6110485 100644 --- a/cmd/completion/machine.go +++ b/cmd/completion/machine.go @@ -1,12 +1,14 @@ package completion import ( + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/spf13/cobra" ) func (c *Completion) Machine(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { resp, err := c.Client.Apiv2().Machine().List(cmd.Context(), &apiv2.MachineServiceListRequest{ + // FIXME this only works for end users, not admin because they have no project set Project: c.Proj, }) if err != nil { @@ -15,8 +17,34 @@ func (c *Completion) Machine(cmd *cobra.Command, args []string, toComplete strin var names []string - for _, s := range resp.Machines { - names = append(names, s.Uuid) + for _, m := range resp.Machines { + name := m.Uuid + if m.Allocation != nil { + name = m.Uuid + "\t" + m.Allocation.Hostname + } + names = append(names, name) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) Firewall(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Machine().List(cmd.Context(), &adminv2.MachineServiceListRequest{ + Query: &apiv2.MachineQuery{ + Allocation: &apiv2.MachineAllocationQuery{ + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL.Enum(), + }, + }, + }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var names []string + + for _, m := range resp.Machines { + name := m.Uuid + "\t" + m.Allocation.Hostname + names = append(names, name) } return names, cobra.ShellCompDirectiveNoFileComp diff --git a/go.mod b/go.mod index ee0d712..600a825 100644 --- a/go.mod +++ b/go.mod @@ -25,52 +25,91 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 // indirect buf.build/go/protovalidate v1.3.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect - cel.dev/expr v0.25.2 // indirect + cel.dev/expr v0.25.3 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/creachadair/msync v0.10.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gaissmai/bart v0.29.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/strfmt v0.27.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.31.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.2 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/connect-compress/v2 v2.1.1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.6.1 // indirect github.com/minio/minlz v1.2.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/safchain/ethtool v0.7.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 // indirect + github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260812173653-3d80eb74bc5b // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v1.0.1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect k8s.io/apimachinery v0.36.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + tailscale.com v1.102.2 // indirect ) diff --git a/go.sum b/go.sum index 816f4a3..aed2987 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,100 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 h1:6nlcxMOui23ZRVAfJM451duu79P1npA5JRdZqMilrrQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= buf.build/go/protovalidate v1.3.0 h1:8ITcnZGkAHx6TyhZvro+iET/AyqU8gEWQJK2WsT62ms= buf.build/go/protovalidate v1.3.0/go.mod h1:82s5g+rFRj1CZPiLv6OTA31jBu2fpq7mLXHwa9mZfEs= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= +github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creachadair/mds v0.30.2 h1:cOP0xkfYkk4mxqPl4SmjnrY+agrpgiEqsVygLnPvJto= +github.com/creachadair/mds v0.30.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI= +github.com/creachadair/msync v0.10.0 h1:2RlGs187RQN5tzyluKEkbkXq+LRK2KIR+An6FoB9x+M= +github.com/creachadair/msync v0.10.0/go.mod h1:J+4p7as+O7NWydXYGJNrigY67qj1F1GB0CcTWyV/5AE= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ= +github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -33,6 +103,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI= +github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= @@ -43,26 +123,58 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/insomniacslk/dhcp v0.0.0-20240204152450-ca2dc33955c1 h1:L3pm9Kf2G6gJVYawz2SrI5QnV1wzHYbqmKnSHHXJAb8= +github.com/insomniacslk/dhcp v0.0.0-20240204152450-ca2dc33955c1/go.mod h1:izxuNQZeFrbx2nK2fAyN5iNUB34Fe9j0nK4PwLzAkKw= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= +github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/connect-compress/v2 v2.1.1 h1:ycZNp4rWOZBodVE2Ls5AzK4aHkyK+GteEfzRZgKNs+c= github.com/klauspost/connect-compress/v2 v2.1.1/go.mod h1:9oilsPHJMzGKkjafSBk9J7iVo4mO+dw0G0KSdVpnlVE= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -73,14 +185,30 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A= +github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg= github.com/metal-stack/api v0.4.4 h1:NwJKCFHnbmsU7DjHLtBmpdPuPniXc2wDabHjdpXk3qA= github.com/metal-stack/api v0.4.4/go.mod h1:E7f2GkKNSr4vBxhQwWrr/Mjnf4ctPqCX2dpDtXBqkBk= github.com/metal-stack/metal-lib v0.26.2 h1:ajPbC9wGe8LySw9J3JT03IGLZghvxsXSb7iceFp/FuQ= github.com/metal-stack/metal-lib v0.26.2/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A= github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -94,14 +222,26 @@ github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+m github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= +github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -119,21 +259,84 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k= +github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/exp v0.0.0-20260812173653-3d80eb74bc5b h1:3XR0v9KL97wjTmRb+6RqMylL4d7ZWM1sQYIITGDxtmg= -golang.org/x/exp v0.0.0-20260812173653-3d80eb74bc5b/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:dWeMvEJ3JhYgqSCAHUZZJgMUyfniiiCvDc72x5EqJP0= google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g= @@ -149,9 +352,19 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.102.2 h1:K0TJMOFv0F9aJDSjM/C2uVtrwnLn+ek22c42x61FXeA= +tailscale.com v1.102.2/go.mod h1:ynxKzc9hDxwGLHORQE0qrTH1b8NBRrIsXcnfnug/3dg= From 04da8d742bbb914a0369816419d1d25879d15213 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 14:53:26 +0200 Subject: [PATCH 05/12] Finish tests for API pkg. --- cmd/api/v2/ip.go | 32 +- cmd/api/v2/machine.go | 65 +++- tests/e2e/admin/switch_test.go | 2 + tests/e2e/api/machine_test.go | 583 ++++++++++++++++++--------------- 4 files changed, 391 insertions(+), 291 deletions(-) diff --git a/cmd/api/v2/ip.go b/cmd/api/v2/ip.go index afe2341..9f46a0f 100644 --- a/cmd/api/v2/ip.go +++ b/cmd/api/v2/ip.go @@ -53,8 +53,9 @@ func newIPCmd(c *config.Config) *cobra.Command { cmd.Flags().StringP("project", "p", "", "project of the ip") cmd.Flags().String("name", "", "name of the ip") cmd.Flags().String("description", "", "description of the ip") - cmd.Flags().StringArray("labels", nil, "adds (or edits) the volume labels in the form of =") - cmd.Flags().StringArray("remove-labels", nil, "removes the volume labels with the given key") + cmd.Flags().StringSlice("labels", nil, "labels to replace for the ip") + cmd.Flags().StringSlice("add-labels", nil, "labels to add to the ip") + cmd.Flags().StringSlice("remove-labels", nil, "labels to remove to the ip") cmd.Flags().Bool("static", false, "make this ip static") genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) @@ -100,12 +101,18 @@ func (c *ip) updateFromCLI(args []string) (*apiv2.IPServiceUpdateRequest, error) return nil, err } + updateLabels, err := helpers.UpdateLabelsFromCLI() + if err != nil { + return nil, err + } + req := &apiv2.IPServiceUpdateRequest{ Ip: uuid, Project: c.c.GetProject(), UpdateMeta: &apiv2.UpdateMeta{ LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, }, + Labels: updateLabels, } if viper.IsSet("name") { @@ -117,27 +124,6 @@ func (c *ip) updateFromCLI(args []string) (*apiv2.IPServiceUpdateRequest, error) if viper.IsSet("static") { req.Type = pointer.PointerOrNil(ipStaticToType(viper.GetBool("static"))) } - if viper.IsSet("remove-labels") || viper.IsSet("labels") { - updates := &apiv2.LabelsPatch{} - - if viper.IsSet("remove-labels") { - updates.Remove = viper.GetStringSlice("remove-labels") - } - - if viper.IsSet("labels") { - labels, err := genericcli.LabelsToMap(viper.GetStringSlice("labels")) - if err != nil { - return nil, err - } - updates.Update = &apiv2.Labels{Labels: labels} - } - - req.Labels = &apiv2.UpdateLabels{ - Strategy: &apiv2.UpdateLabels_Patch{ - Patch: updates, - }, - } - } return req, nil } diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index 184fe4d..e336fb2 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -59,6 +59,20 @@ func newMachineCmd(c *config.Config) *cobra.Command { genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) }, + UpdateCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("project", "p", "", "project from where machines should be listed") + cmd.Flags().String("description", "", "description of the machine") + cmd.Flags().StringSlice("labels", nil, "labels to replace for the machine") + cmd.Flags().StringSlice("add-labels", nil, "labels to add to the machine") + cmd.Flags().StringSlice("remove-labels", nil, "labels to remove to the machine") + cmd.Flags().StringP("ssh-public-key", "i", "", + `SSH public key for access via ssh and console. [optional] +Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". +If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + }, + UpdateRequestFromCLI: w.updateRequestFromCLI, DescribeCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().StringP("project", "p", "", "project of the machine") @@ -95,10 +109,20 @@ func (c *machine) Delete(id string) (*apiv2.Machine, error) { ctx, cancel := c.c.NewRequestContext() defer cancel() - resp, err := c.c.Client.Apiv2().Machine().Delete(ctx, &apiv2.MachineServiceDeleteRequest{ + req := &apiv2.MachineServiceDeleteRequest{ Uuid: id, Project: c.c.GetProject(), - }) + } + + if viper.IsSet("file") { + var err error + req.Uuid, req.Project, err = helpers.DecodeProject(id) + if err != nil { + return nil, err + } + } + + resp, err := c.c.Client.Apiv2().Machine().Delete(ctx, req) if err != nil { return nil, err } @@ -211,9 +235,9 @@ func (c *machine) MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServi Partition: new(pointer.SafeDeref(r.Partition).Id), Size: new(pointer.SafeDeref(r.Size).Id), Image: r.Allocation.Image.Id, - FilesystemLayout: &r.Allocation.FilesystemLayout.Id, + FilesystemLayout: pointer.PointerOrNil(r.Allocation.FilesystemLayout.Id), SshPublicKeys: r.Allocation.SshPublicKeys, - Userdata: &r.Allocation.Userdata, + Userdata: pointer.PointerOrNil(r.Allocation.Userdata), Labels: pointer.SafeDeref(r.Meta).Labels, Networks: networks, DnsServers: r.Allocation.DnsServers, @@ -354,6 +378,39 @@ func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, er }, nil } +func (c *machine) updateRequestFromCLI(args []string) (*apiv2.MachineServiceUpdateRequest, error) { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return nil, err + } + + updateLabels, err := helpers.UpdateLabelsFromCLI() + if err != nil { + return nil, err + } + + sshPublicKeyArgument := viper.GetString("ssh-public-key") + + if strings.HasPrefix(sshPublicKeyArgument, "@") { + var err error + sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKeyArgument[1:]) + if err != nil { + return nil, err + } + } + + return &apiv2.MachineServiceUpdateRequest{ + Uuid: id, + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Project: c.c.GetProject(), + Description: pointer.PointerOrNil(viper.GetString("description")), + Labels: updateLabels, + SshPublicKeys: []string{sshPublicKeyArgument}, + }, nil +} + var defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} func searchSSHKey() (string, error) { diff --git a/tests/e2e/admin/switch_test.go b/tests/e2e/admin/switch_test.go index b91de20..300b8d3 100644 --- a/tests/e2e/admin/switch_test.go +++ b/tests/e2e/admin/switch_test.go @@ -40,6 +40,7 @@ meta: createdAt: "2000-01-01T00:00:00Z" description: leaf switch 2 rack: rack-1 +room: room-2 partition: fra-equ01 managementIp: 10.0.0.2 managementUser: admin @@ -289,6 +290,7 @@ switch: createdAt: "2000-01-01T00:00:00Z" description: leaf switch 2 rack: rack-1 + room: room-2 partition: fra-equ01 managementIp: 10.0.0.2 managementUser: admin diff --git a/tests/e2e/api/machine_test.go b/tests/e2e/api/machine_test.go index 246e438..079a713 100644 --- a/tests/e2e/api/machine_test.go +++ b/tests/e2e/api/machine_test.go @@ -2,6 +2,7 @@ package api_e2e import ( "encoding/base64" + "fmt" "os" "strings" "testing" @@ -14,6 +15,7 @@ import ( "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" "github.com/spf13/afero" + "github.com/stretchr/testify/require" ) func Test_MachineCmd_List(t *testing.T) { @@ -196,275 +198,328 @@ func Test_MachineCmd_Create(t *testing.T) { }), WantProtoObject: testresources.Machine2(), }, - // { - // Name: "create from file", - // CmdArgs: append([]string{"machine", "create"}, e2e.AppendFromFileCommonArgs()...), - // NewRootCmd: e2erootcmd.NewRootCmd(t, - // &e2erootcmd.TestConfig{ - // FsMocks: func(fs *afero.Afero) { - // require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) - // }, - // ClientCalls: []client.ClientCall{ - // { - // WantRequest: &apiv2.MachineServiceCreateRequest{ - // Ip: &testresources.Machine1().Ip, - // Project: testresources.Machine1().Project, - // Network: testresources.Machine1().Network, - // Name: &testresources.Machine1().Name, - // Descrmachinetion: &testresources.Machine1().Descrmachinetion, - // Labels: testresources.Machine1().Meta.Labels, - // Type: &testresources.Machine1().Type, - // AddressFamily: nil, - // }, - // WantResponse: func() connect.AnyResponse { - // return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ - // Ip: testresources.Machine1(), - // }) - // }, - // }, - // }, - // }), - // WantTable: new(` - // Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE - // 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a - // `), - // }, + { + Name: "create from file", + CmdArgs: append([]string{"machine", "create"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Partition: &testresources.Machine2().Partition.Id, + Size: &testresources.Machine2().Size.Id, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, } for _, tt := range tests { tt.TestCmd(t) } } -// func Test_MachineCmd_Delete(t *testing.T) { -// tests := []*e2e.Test[apiv2.MachineServiceDeleteResponse, *apiv2.Machine]{ -// { -// Name: "delete", -// CmdArgs: []string{"machine", "delete", "--project", testresources.Machine1().Project, testresources.Machine1().Ip}, -// NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceDeleteRequest{ -// Ip: testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }), -// WantProtoObject: testresources.Machine1(), -// }, -// { -// Name: "delete from file", -// CmdArgs: append([]string{"machine", "delete"}, e2e.AppendFromFileCommonArgs()...), -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// FsMocks: func(fs *afero.Afero) { -// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) -// }, -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceDeleteRequest{ -// Ip: testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }, -// ), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// }, -// } -// for _, tt := range tests { -// tt.TestCmd(t) -// } -// } +func Test_MachineCmd_Delete(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceDeleteResponse, *apiv2.Machine]{ + { + Name: "delete", + CmdArgs: []string{"machine", "delete", "--project", testresources.Project2().Uuid, testresources.Machine2().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceDeleteRequest{ + Project: testresources.Project2().Uuid, + Uuid: testresources.Machine2().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + }, + { + Name: "delete from file", + CmdArgs: append([]string{"machine", "delete"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceDeleteRequest{ + Uuid: testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceDeleteResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} -// func Test_MachineCmd_Update(t *testing.T) { -// tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ -// { -// Name: "update", -// CmdArgs: []string{"machine", "update", "--project", testresources.Machine1().Project, testresources.Machine1().Ip, "--name", "foo"}, -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceUpdateRequest{ -// Ip: testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Name: new("foo"), -// UpdateMeta: &apiv2.UpdateMeta{ -// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, -// }, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }, -// ), -// WantProtoObject: testresources.Machine1(), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// WantWideTable: new(` -// Machine PROJECT ID TYPE NAME DESCRMachineTION LABELS -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a a descrmachinetion cluster.metal-stack.io/id/namespace/service=/default/ingress-nginx -// `), -// }, -// { -// Name: "update from file", -// CmdArgs: append([]string{"machine", "update"}, e2e.AppendFromFileCommonArgs()...), -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// FsMocks: func(fs *afero.Afero) { -// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) -// }, -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceUpdateRequest{ -// Ip: testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Descrmachinetion: &testresources.Machine1().Descrmachinetion, -// Labels: &apiv2.UpdateLabels{ -// Strategy: &apiv2.UpdateLabels_Replace{ -// Replace: &apiv2.Labels{ -// Labels: testresources.Machine1().Meta.Labels.Labels, -// }, -// }, -// }, -// Name: &testresources.Machine1().Name, -// Type: &testresources.Machine1().Type, -// UpdateMeta: &apiv2.UpdateMeta{ -// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, -// }, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }, -// ), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// }, -// } -// for _, tt := range tests { -// tt.TestCmd(t) -// } -// } +func Test_MachineCmd_Update(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ + { + Name: "update", + CmdArgs: []string{"machine", "update", + "--project", testresources.Project2().Uuid, + testresources.Machine2().Uuid, + "--description", "42", + "--labels", "1=2", + "--ssh-public-key", "@.ssh/id_rsa.pub", + }, + AssertExhaustiveArgs: true, + AssertExhaustiveExcludes: append(e2e.CommonExcludedFileArgs(), "add-labels", "remove-labels"), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + genericcli.Must(fs.WriteFile(".ssh/id_rsa.pub", []byte("12345"), os.ModeAppend)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Project2().Uuid, + Description: new("42"), + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: &apiv2.Labels{ + Labels: map[string]string{ + "1": "2", + }, + }, + }, + }, + SshPublicKeys: []string{"12345"}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantProtoObject: testresources.Machine2(), + }, + { + Name: "update from file", + CmdArgs: append([]string{"machine", "update"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Description: &testresources.Machine2().Allocation.Description, + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: testresources.Machine2().Meta.Labels, + }, + }, + SshPublicKeys: []string{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Apply(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ + { + Name: "apply", + CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Partition: &testresources.Machine2().Partition.Id, + Size: &testresources.Machine2().Size.Id, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork -// func Test_MachineCmd_Apply(t *testing.T) { -// tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ -// { -// Name: "apply", -// CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// FsMocks: func(fs *afero.Afero) { -// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) -// }, -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceCreateRequest{ -// Ip: &testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Network: testresources.Machine1().Network, -// Name: &testresources.Machine1().Name, -// Descrmachinetion: &testresources.Machine1().Descrmachinetion, -// Labels: testresources.Machine1().Meta.Labels, -// Type: &testresources.Machine1().Type, -// AddressFamily: nil, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }, -// ), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// }, -// { -// Name: "apply already exists", -// CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), -// NewRootCmd: e2erootcmd.NewRootCmd(t, -// &e2erootcmd.TestConfig{ -// FsMocks: func(fs *afero.Afero) { -// require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine1()), 0755)) -// }, -// ClientCalls: []client.ClientCall{ -// { -// WantRequest: &apiv2.MachineServiceCreateRequest{ -// Ip: &testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Network: testresources.Machine1().Network, -// Name: &testresources.Machine1().Name, -// Descrmachinetion: &testresources.Machine1().Descrmachinetion, -// Labels: testresources.Machine1().Meta.Labels, -// Type: &testresources.Machine1().Type, -// AddressFamily: nil, -// }, -// WantError: connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("already exists")), -// }, -// { -// WantRequest: &apiv2.MachineServiceUpdateRequest{ -// Ip: testresources.Machine1().Ip, -// Project: testresources.Machine1().Project, -// Descrmachinetion: &testresources.Machine1().Descrmachinetion, -// Labels: &apiv2.UpdateLabels{ -// Strategy: &apiv2.UpdateLabels_Replace{ -// Replace: &apiv2.Labels{ -// Labels: testresources.Machine1().Meta.Labels.Labels, -// }, -// }}, -// Name: &testresources.Machine1().Name, -// Type: &testresources.Machine1().Type, -// UpdateMeta: &apiv2.UpdateMeta{ -// LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, -// }, -// }, -// WantResponse: func() connect.AnyResponse { -// return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ -// Ip: testresources.Machine1(), -// }) -// }, -// }, -// }, -// }, -// ), -// WantTable: new(` -// Machine PROJECT ID NETWORK TYPE NAME ATTACHED SERVICE -// 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 internet static a -// `), -// }, -// } -// for _, tt := range tests { -// tt.TestCmd(t) -// } -// } + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + { + Name: "apply already exists", + CmdArgs: append([]string{"machine", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Partition: &testresources.Machine2().Partition.Id, + Size: &testresources.Machine2().Size.Id, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantError: connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("already exists")), + }, + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Description: &testresources.Machine2().Allocation.Description, + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: testresources.Machine2().Meta.Labels, + }, + }, + SshPublicKeys: []string{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} From 4a338a3c221a25cd197eeee27df33d29360e5d53 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 14:57:55 +0200 Subject: [PATCH 06/12] Show emoji help. --- cmd/admin/v2/machine.go | 3 + cmd/api/v2/machine.go | 2 + docs/admin/metalctlv2_admin_machine.md | 1 + docs/admin/metalctlv2_admin_machine_list.md | 16 +++++ docs/admin/metalctlv2_admin_machine_ssh.md | 37 ++++++++++ docs/metalctlv2_ip_update.md | 45 ++++++------ docs/metalctlv2_machine_create.md | 76 ++++++++++----------- docs/metalctlv2_machine_list.md | 16 +++++ docs/metalctlv2_machine_update.md | 14 +++- 9 files changed, 147 insertions(+), 63 deletions(-) create mode 100644 docs/admin/metalctlv2_admin_machine_ssh.md diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index a8a8f79..6d8b13e 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -14,6 +14,7 @@ import ( apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/metal-stack/cli/cmd/config" "github.com/metal-stack/cli/cmd/sorters" + "github.com/metal-stack/cli/pkg/helpers" "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" "github.com/metal-stack/metal-lib/pkg/pointer" @@ -55,6 +56,8 @@ func newMachineCmd(c *config.Config) *cobra.Command { genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.Completion.Size)) genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) + + cmd.Long = cmd.Short + "\n" + helpers.EmojiHelpText() }, DescribeCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().StringP("project", "p", "", "project of the machine") diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index e336fb2..1a40720 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -58,6 +58,8 @@ func newMachineCmd(c *config.Config) *cobra.Command { genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.Completion.Size)) genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) + + cmd.Long = cmd.Short + "\n" + helpers.EmojiHelpText() }, UpdateCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().StringP("project", "p", "", "project from where machines should be listed") diff --git a/docs/admin/metalctlv2_admin_machine.md b/docs/admin/metalctlv2_admin_machine.md index 2312c7d..e465d3c 100644 --- a/docs/admin/metalctlv2_admin_machine.md +++ b/docs/admin/metalctlv2_admin_machine.md @@ -37,6 +37,7 @@ manage machines * [metalctlv2 admin machine edit](metalctlv2_admin_machine_edit.md) - edit the machine through an editor and update * [metalctlv2 admin machine list](metalctlv2_admin_machine_list.md) - list all machines * [metalctlv2 admin machine lock](metalctlv2_admin_machine_lock.md) - lock or unlock a machine, e.g. machine cannot be used +* [metalctlv2 admin machine ssh](metalctlv2_admin_machine_ssh.md) - SSH to a firewall * [metalctlv2 admin machine taint](metalctlv2_admin_machine_taint.md) - taint or untaint a machine, e.g. machine will not be automatically selected on machine create, only admins can create them * [metalctlv2 admin machine update](metalctlv2_admin_machine_update.md) - updates the machine diff --git a/docs/admin/metalctlv2_admin_machine_list.md b/docs/admin/metalctlv2_admin_machine_list.md index 8b144f7..fdf9e28 100644 --- a/docs/admin/metalctlv2_admin_machine_list.md +++ b/docs/admin/metalctlv2_admin_machine_list.md @@ -2,6 +2,22 @@ list all machines +### Synopsis + +list all machines + +Meaning of the emojis: + +🚧 Machine is reserved. Reserved machines are not considered for random allocation until the reservation flag is removed. +🔒 Machine is locked. Locked machines can not be deleted until the lock is removed. +💀 Machine is dead. The metal-api does not receive any events from this machine. +❗ Machine has a last event error. The machine has recently encountered an error during the provisioning lifecycle. +❓ Machine is in unknown condition. The metal-api does not receive phoned home events anymore or has never booted successfully. +⭕ Machine is in a provisioning crash loop. Flag can be reset through an API-triggered reboot or when the machine reaches the phoned home state. +🚑 Machine reclaim has failed. The machine was deleted but it is not going back into the available machine pool. +🛡 Machine is connected to our VPN, ssh access only possible via this VPN. + + ``` metalctlv2 admin machine list [flags] ``` diff --git a/docs/admin/metalctlv2_admin_machine_ssh.md b/docs/admin/metalctlv2_admin_machine_ssh.md new file mode 100644 index 0000000..621ab65 --- /dev/null +++ b/docs/admin/metalctlv2_admin_machine_ssh.md @@ -0,0 +1,37 @@ +## metalctlv2 admin machine ssh + +SSH to a firewall + +### Synopsis + +SSH to a firewall via VPN. + +``` +metalctlv2 admin machine ssh [flags] +``` + +### Options + +``` + -h, --help help for ssh + -i, --identity string specify identity file to SSH to the firewall like: -i path/to/id_rsa (default "~/.ssh/id_rsa") + --reason string the reason why to connect to the firewall through SSH +``` + +### Options inherited from parent commands + +``` + --api-token string the token used for api requests + --api-url string the url to the metal-stack.io api + -c, --config string alternative config file path, (default is ~/.metal-stack/config.yaml) + --debug debug output + --force-color force colored output even without tty + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. For property names inspect the output of -o json or -o yaml for reference. + --timeout duration request timeout used for api requests +``` + +### SEE ALSO + +* [metalctlv2 admin machine](metalctlv2_admin_machine.md) - manage machine entities + diff --git a/docs/metalctlv2_ip_update.md b/docs/metalctlv2_ip_update.md index 2c402d7..6b437e5 100644 --- a/docs/metalctlv2_ip_update.md +++ b/docs/metalctlv2_ip_update.md @@ -9,28 +9,29 @@ metalctlv2 ip update [flags] ### Options ``` - --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. - --description string description of the ip - -f, --file string filename of the create or update request in yaml format, or - for stdin. - - Example: - $ metalctlv2 ip describe ip-1 -o yaml > ip.yaml - $ vi ip.yaml - $ # either via stdin - $ cat ip.yaml | metalctlv2 ip update -f - - $ # or via file - $ metalctlv2 ip update -f ip.yaml - - the file can also contain multiple documents and perform a bulk operation. - - -h, --help help for update - --labels stringArray adds (or edits) the volume labels in the form of = - --name string name of the ip - -p, --project string project of the ip - --remove-labels stringArray removes the volume labels with the given key - --skip-security-prompts skips security prompt for bulk operations - --static make this ip static - --timestamps when used with --file (bulk operation): prints timestamps in-between the operations + --add-labels strings labels to add to the ip + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + --description string description of the ip + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 ip describe ip-1 -o yaml > ip.yaml + $ vi ip.yaml + $ # either via stdin + $ cat ip.yaml | metalctlv2 ip update -f - + $ # or via file + $ metalctlv2 ip update -f ip.yaml + + the file can also contain multiple documents and perform a bulk operation. + + -h, --help help for update + --labels strings labels to replace for the ip + --name string name of the ip + -p, --project string project of the ip + --remove-labels strings labels to remove to the ip + --skip-security-prompts skips security prompt for bulk operations + --static make this ip static + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations ``` ### Options inherited from parent commands diff --git a/docs/metalctlv2_machine_create.md b/docs/metalctlv2_machine_create.md index 1938955..e789919 100644 --- a/docs/metalctlv2_machine_create.md +++ b/docs/metalctlv2_machine_create.md @@ -9,44 +9,44 @@ metalctlv2 machine create [flags] ### Options ``` - -t, --allocation-type string allocation type, can be either machine|firewall (default "machine") - --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. - -d, --description string Description of the machine to create. [optional] - --dnsservers strings dns servers to add to the machine or firewall. [optional] - -f, --file string filename of the create or update request in yaml format, or - for stdin. - - Example: - $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml - $ vi machine.yaml - $ # either via stdin - $ cat machine.yaml | metalctlv2 machine create -f - - $ # or via file - $ metalctlv2 machine create -f machine.yaml - - the file can also contain multiple documents and perform a bulk operation. - - --filesystemlayout string Filesystemlayout to use during machine installation. [optional] - -h, --help help for create - -H, --hostname string Hostname of the machine. [required] - -I, --id string ID of a specific machine to allocate, if given, size and partition are ignored. Need to be set to reserved (--reserve) state before. - -i, --image string OS Image to install. [required] - -n, --name string Name of the machine. [optional] - --networks strings Adds a network. Usage: [--networks NETWORK,[ip:ip:ip][,NETWORK...]... - NETWORK specifies the name or id of an existing network. - IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. - - --ntpservers strings ntp servers to add to the machine or firewall. [optional] - -S, --partition string partition/datacenter where the machine is created. [required, except for reserved machines] - -P, --project string Project where the machine should belong to. [required] - -s, --size string Size of the machine. [required, except for reserved machines] - --skip-security-prompts skips security prompt for bulk operations - -p, --sshpublickey string SSH public key for access via ssh and console. [optional] - Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". - If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. - --tags strings tags to add to the machine, use it like: --tags "tag1,tag2" or --tags "tag3". - --timestamps when used with --file (bulk operation): prints timestamps in-between the operations - --userdata string cloud-init.io compatible userdata. [optional] - Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg". + --allocation-type string allocation type, can be either machine|firewall (default "machine") + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + --description string Description of the machine to create. [optional] + --dns-servers strings dns servers to add to the machine or firewall. [optional] + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine create -f - + $ # or via file + $ metalctlv2 machine create -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + --filesystem-layout string Filesystemlayout to use during machine installation. [optional] + -h, --help help for create + --hostname string Hostname of the machine. [required] + --image string OS Image to install. [required] + --labels strings labels to add to the machine, use it like: --labels "a=b" or --labels "a=". + --name string Name of the machine. [optional] + --networks strings Adds a network. Usage: [--networks NETWORK[:ip[;ip]][,NETWORK[:ip[;ip]]... + NETWORK specifies the name or id of an existing network. + IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. + + --ntp-servers strings ntp servers to add to the machine or firewall. [optional] + --partition string partition/datacenter where the machine is created. [required, except for reserved machines] + --placement-tags strings placement tags used for rack spreading + -p, --project string Project where the machine should belong to. [required] + --size string Size of the machine. [required, except for reserved machines] + --skip-security-prompts skips security prompt for bulk operations + -i, --ssh-public-key string SSH public key for access via ssh and console. [optional] + Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". + If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations + --userdata string cloud-init.io compatible userdata. [optional] + Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg". ``` ### Options inherited from parent commands diff --git a/docs/metalctlv2_machine_list.md b/docs/metalctlv2_machine_list.md index 8717404..a71a75e 100644 --- a/docs/metalctlv2_machine_list.md +++ b/docs/metalctlv2_machine_list.md @@ -2,6 +2,22 @@ list all machines +### Synopsis + +list all machines + +Meaning of the emojis: + +🚧 Machine is reserved. Reserved machines are not considered for random allocation until the reservation flag is removed. +🔒 Machine is locked. Locked machines can not be deleted until the lock is removed. +💀 Machine is dead. The metal-api does not receive any events from this machine. +❗ Machine has a last event error. The machine has recently encountered an error during the provisioning lifecycle. +❓ Machine is in unknown condition. The metal-api does not receive phoned home events anymore or has never booted successfully. +⭕ Machine is in a provisioning crash loop. Flag can be reset through an API-triggered reboot or when the machine reaches the phoned home state. +🚑 Machine reclaim has failed. The machine was deleted but it is not going back into the available machine pool. +🛡 Machine is connected to our VPN, ssh access only possible via this VPN. + + ``` metalctlv2 machine list [flags] ``` diff --git a/docs/metalctlv2_machine_update.md b/docs/metalctlv2_machine_update.md index aa574cc..bd72d06 100644 --- a/docs/metalctlv2_machine_update.md +++ b/docs/metalctlv2_machine_update.md @@ -3,27 +3,35 @@ updates the machine ``` -metalctlv2 machine update [flags] +metalctlv2 machine update [flags] ``` ### Options ``` + --add-labels strings labels to add to the machine --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + --description string description of the machine -f, --file string filename of the create or update request in yaml format, or - for stdin. Example: $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml $ vi machine.yaml $ # either via stdin - $ cat machine.yaml | metalctlv2 machine update -f - + $ cat machine.yaml | metalctlv2 machine update -f - $ # or via file - $ metalctlv2 machine update -f machine.yaml + $ metalctlv2 machine update -f machine.yaml the file can also contain multiple documents and perform a bulk operation. -h, --help help for update + --labels strings labels to replace for the machine + -p, --project string project from where machines should be listed + --remove-labels strings labels to remove to the machine --skip-security-prompts skips security prompt for bulk operations + -i, --ssh-public-key string SSH public key for access via ssh and console. [optional] + Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". + If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. --timestamps when used with --file (bulk operation): prints timestamps in-between the operations ``` From e8de89aaedf8438b654a1dc9bd3a56372109ab13 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 15:00:11 +0200 Subject: [PATCH 07/12] Fix linter. --- cmd/admin/v2/machine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index 6d8b13e..67c0bc3 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -410,7 +410,7 @@ func (c *machine) firewallSSH(args []string) (err error) { } if machine.Allocation.AllocationType != apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL { - return fmt.Errorf("ssh can only be made to firewalls.") + return fmt.Errorf("ssh can only be used for connecting to firewalls") } projectID := machine.Allocation.Project From 78470c10e1fa1b8c7f7feaffcbbb50ae0e7c61ad Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 15:18:03 +0200 Subject: [PATCH 08/12] More admin commands. --- cmd/admin/v2/machine.go | 91 +++++++++++++------ cmd/api/v2/machine.go | 66 +------------- .../admin/metalctlv2_admin_machine_console.md | 5 +- pkg/helpers/machine.go | 70 ++++++++++++++ 4 files changed, 139 insertions(+), 93 deletions(-) create mode 100644 pkg/helpers/machine.go diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index 67c0bc3..eb4e488 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" + "github.com/metal-stack/api/go/errorutil" adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/metal-stack/cli/cmd/config" @@ -34,7 +35,7 @@ func newMachineCmd(c *config.Config) *cobra.Command { c: c, } - cmdsConfig := &genericcli.CmdsConfig[any, any, *apiv2.Machine]{ + cmdsConfig := &genericcli.CmdsConfig[*apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, *apiv2.Machine]{ BinaryName: config.BinaryName, GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), Singular: "machine", @@ -144,18 +145,19 @@ Once created the machine installation can not be modified anymore. Use: "console", Short: "establishes a connection to the serial console of a machine. for authentication at the metal-console it uses the token such that no machine ssh key is required for access (unlike the corresponding user API command).", RunE: func(cmd *cobra.Command, args []string) error { - return w.console(args) + return w.console(cmd.Context(), args) }, ValidArgsFunction: c.Completion.Machine, } consoleCmd.Flags().Bool("ipmi", false, "if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present)") + consoleCmd.Flags().Int("metal-console-port", 5222, "port open on our control-plane to connect via ssh to get machine console access") firewallSSHCmd := &cobra.Command{ Use: "ssh ", Short: "SSH to a firewall", Long: `SSH to a firewall via VPN.`, RunE: func(cmd *cobra.Command, args []string) error { - return w.firewallSSH(args) + return w.firewallSSH(cmd.Context(), args) }, ValidArgsFunction: c.Completion.Firewall, } @@ -165,12 +167,34 @@ Once created the machine installation can not be modified anymore. return genericcli.NewCmds(cmdsConfig, bmcCommandCmd, lockCmd, taintCmd, consoleCmd, firewallSSHCmd) } -func (c *machine) Create(rq any) (*apiv2.Machine, error) { - panic("unimplemented") +func (c *machine) Create(rq *apiv2.MachineServiceCreateRequest) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Apiv2().Machine().Create(ctx, rq) + if err != nil { + if errorutil.IsConflict(err) { + return nil, genericcli.AlreadyExistsError() + } + + return nil, err + } + + return resp.Machine, nil } func (c *machine) Delete(id string) (*apiv2.Machine, error) { - panic("unimplemented") + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + resp, err := c.c.Client.Adminv2().Machine().Delete(ctx, &adminv2.MachineServiceDeleteRequest{ + Uuid: id, + }) + if err != nil { + return nil, err + } + + return resp.Machine, nil } func (c *machine) Get(id string) (*apiv2.Machine, error) { @@ -242,21 +266,36 @@ func (c *machine) List() ([]*apiv2.Machine, error) { return resp.Machines, nil } -func (c *machine) Update(rq any) (*apiv2.Machine, error) { - panic("unimplemented") -} +func (c *machine) Update(rq *apiv2.MachineServiceUpdateRequest) (*apiv2.Machine, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() -func (c *machine) Convert(r *apiv2.Machine) (string, any, any, error) { - panic("unimplemented") + resp, err := c.c.Client.Apiv2().Machine().Update(ctx, rq) + if err != nil { + return nil, err + } + return resp.Machine, nil } -func (c *machine) MachineResponseToCreate(r *apiv2.Machine) any { - panic("unimplemented") -} +func (c *machine) Convert(r *apiv2.Machine) (string, *apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, error) { + update, err := helpers.MachineResponseToUpdate(r) + if err != nil { + return "", nil, nil, err + } -func (c *machine) MachineResponseToUpdate(desired *apiv2.Machine) (any, error) { - panic("unimplemented") + create, err := helpers.MachineResponseToCreate(r) + if err != nil { + return "", nil, nil, err + } + + if r.Uuid != "" { + create.Uuid = &r.Uuid + create.Partition = nil + create.Size = nil + } + + return r.Uuid, create, update, err } func (c *machine) lockOrTaint(args []string, state apiv2.MachineState) error { @@ -311,10 +350,7 @@ func (c *machine) bmcCommand(args []string) error { return err } -// Port open on our control-plane to connect via ssh to get machine console access. -const bmcConsolePort = 5222 - -func (c *machine) console(args []string) error { +func (c *machine) console(ctx context.Context, args []string) error { id, err := genericcli.GetExactlyOneArg(args) if err != nil { return err @@ -322,7 +358,7 @@ func (c *machine) console(args []string) error { useIpmi := viper.GetBool("ipmi") if useIpmi { - return c.impitool(id) + return c.impitool(ctx, id) } parsedurl, err := url.Parse(pointer.SafeDeref(c.c.Context.ApiURL)) @@ -330,7 +366,7 @@ func (c *machine) console(args []string) error { return err } - err = sshClient(id, viper.GetString("sshidentity"), parsedurl.Host, bmcConsolePort, &c.c.Context.Token, true) + err = sshClient(id, viper.GetString("sshidentity"), parsedurl.Host, viper.GetInt("metal-console-port"), &c.c.Context.Token, true) if err != nil { return fmt.Errorf("machine console error:%w", err) } @@ -338,7 +374,7 @@ func (c *machine) console(args []string) error { return nil } -func (c *machine) impitool(id string) error { +func (c *machine) impitool(ctx context.Context, id string) error { path, err := exec.LookPath("ipmitool") if err != nil { return fmt.Errorf("unable to locate ipmitool in path") @@ -387,14 +423,15 @@ func (c *machine) impitool(id string) error { args := []string{"-I", intf, "-H", hostAndPort[0], "-p", hostAndPort[1], "-U", usr, "-E", "sol", "activate"} _, _ = fmt.Fprintf(c.c.Out, "connecting to console with:\n%s %s\nExit with ~.\n\n", path, strings.Join(args, " ")) - cmd := exec.Command(path, args...) + cmd := exec.CommandContext(ctx, path, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stdout + return cmd.Run() } -func (c *machine) firewallSSH(args []string) (err error) { +func (c *machine) firewallSSH(ctx context.Context, args []string) (err error) { id, err := genericcli.GetExactlyOneArg(args) if err != nil { return err @@ -415,7 +452,7 @@ func (c *machine) firewallSSH(args []string) (err error) { projectID := machine.Allocation.Project _, _ = fmt.Fprintf(c.c.Out, "accessing firewall through vpn ") - authKeyResp, err := c.c.Client.Adminv2().VPN().AuthKey(context.Background(), &adminv2.VPNServiceAuthKeyRequest{ + authKeyResp, err := c.c.Client.Adminv2().VPN().AuthKey(ctx, &adminv2.VPNServiceAuthKeyRequest{ Project: projectID, Ephemeral: true, Reason: viper.GetString("reason"), @@ -423,7 +460,7 @@ func (c *machine) firewallSSH(args []string) (err error) { if err != nil { return fmt.Errorf("failed to get VPN auth key: %w", err) } - ctx := context.Background() + v, err := metalvpn.Connect(ctx, machine.Uuid, authKeyResp.Address, authKeyResp.AuthKey) if err != nil { return err diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index 1a40720..382b522 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -190,12 +190,12 @@ func (c *machine) Update(rq *apiv2.MachineServiceUpdateRequest) (*apiv2.Machine, } func (c *machine) Convert(r *apiv2.Machine) (string, *apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, error) { - update, err := c.MachineResponseToUpdate(r) + update, err := helpers.MachineResponseToUpdate(r) if err != nil { return "", nil, nil, err } - create, err := c.MachineResponseToCreate(r) + create, err := helpers.MachineResponseToCreate(r) if err != nil { return "", nil, nil, err } @@ -203,68 +203,6 @@ func (c *machine) Convert(r *apiv2.Machine) (string, *apiv2.MachineServiceCreate return helpers.EncodeProject(r.Uuid, r.Allocation.Project), create, update, err } -func (c *machine) MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServiceCreateRequest, error) { - if r.Allocation == nil { - return nil, fmt.Errorf("allocation is nil") - } - - var ( - networks []*apiv2.MachineAllocationNetwork - firewallSpec *apiv2.FirewallSpec - ) - - for _, nw := range r.Allocation.Networks { - networks = append(networks, &apiv2.MachineAllocationNetwork{ - Network: nw.Network, - Ips: nw.Ips, - }) - } - - if r.Allocation.AllocationType.Enum() == apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL.Enum() { - firewallSpec = &apiv2.FirewallSpec{ - FirewallRules: &apiv2.FirewallRules{ - Egress: r.Allocation.FirewallRules.Egress, - Ingress: r.Allocation.FirewallRules.Ingress, - }, - } - } - - return &apiv2.MachineServiceCreateRequest{ - Project: r.Allocation.Project, - Name: r.Allocation.Name, - Description: &r.Allocation.Description, - Hostname: &r.Allocation.Hostname, - Partition: new(pointer.SafeDeref(r.Partition).Id), - Size: new(pointer.SafeDeref(r.Size).Id), - Image: r.Allocation.Image.Id, - FilesystemLayout: pointer.PointerOrNil(r.Allocation.FilesystemLayout.Id), - SshPublicKeys: r.Allocation.SshPublicKeys, - Userdata: pointer.PointerOrNil(r.Allocation.Userdata), - Labels: pointer.SafeDeref(r.Meta).Labels, - Networks: networks, - DnsServers: r.Allocation.DnsServers, - NtpServers: r.Allocation.NtpServers, - AllocationType: r.Allocation.AllocationType, - FirewallSpec: firewallSpec, - // PlacementTags: r.Allocation.Plac, // TODO: should be stored in the allocation to see what was provided - }, nil -} - -func (c *machine) MachineResponseToUpdate(r *apiv2.Machine) (*apiv2.MachineServiceUpdateRequest, error) { - if r.Allocation == nil { - return nil, fmt.Errorf("allocation is nil") - } - - return &apiv2.MachineServiceUpdateRequest{ - Uuid: r.Uuid, - UpdateMeta: helpers.UpdateMetaFromMeta(r.Meta), - Labels: helpers.UpdateLabelsFromMeta(r.Meta), - Project: r.Allocation.Project, - Description: &r.Allocation.Description, - SshPublicKeys: r.Allocation.SshPublicKeys, - }, nil -} - func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, error) { var ( keys []string diff --git a/docs/admin/metalctlv2_admin_machine_console.md b/docs/admin/metalctlv2_admin_machine_console.md index 3c54808..95c011a 100644 --- a/docs/admin/metalctlv2_admin_machine_console.md +++ b/docs/admin/metalctlv2_admin_machine_console.md @@ -9,8 +9,9 @@ metalctlv2 admin machine console [flags] ### Options ``` - -h, --help help for console - --ipmi if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present) + -h, --help help for console + --ipmi if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present) + --metal-console-port int port open on our control-plane to connect via ssh to get machine console access (default 5222) ``` ### Options inherited from parent commands diff --git a/pkg/helpers/machine.go b/pkg/helpers/machine.go new file mode 100644 index 0000000..2629058 --- /dev/null +++ b/pkg/helpers/machine.go @@ -0,0 +1,70 @@ +package helpers + +import ( + "fmt" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServiceCreateRequest, error) { + if r.Allocation == nil { + return nil, fmt.Errorf("allocation is nil") + } + + var ( + networks []*apiv2.MachineAllocationNetwork + firewallSpec *apiv2.FirewallSpec + ) + + for _, nw := range r.Allocation.Networks { + networks = append(networks, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + if r.Allocation.AllocationType.Enum() == apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL.Enum() { + firewallSpec = &apiv2.FirewallSpec{ + FirewallRules: &apiv2.FirewallRules{ + Egress: r.Allocation.FirewallRules.Egress, + Ingress: r.Allocation.FirewallRules.Ingress, + }, + } + } + + return &apiv2.MachineServiceCreateRequest{ + Project: r.Allocation.Project, + Name: r.Allocation.Name, + Description: &r.Allocation.Description, + Hostname: &r.Allocation.Hostname, + Partition: new(pointer.SafeDeref(r.Partition).Id), + Size: new(pointer.SafeDeref(r.Size).Id), + Image: r.Allocation.Image.Id, + FilesystemLayout: pointer.PointerOrNil(r.Allocation.FilesystemLayout.Id), + SshPublicKeys: r.Allocation.SshPublicKeys, + Userdata: pointer.PointerOrNil(r.Allocation.Userdata), + Labels: pointer.SafeDeref(r.Meta).Labels, + Networks: networks, + DnsServers: r.Allocation.DnsServers, + NtpServers: r.Allocation.NtpServers, + AllocationType: r.Allocation.AllocationType, + FirewallSpec: firewallSpec, + // PlacementTags: r.Allocation.Plac, // TODO: should be stored in the allocation to see what was provided + }, nil +} + +func MachineResponseToUpdate(r *apiv2.Machine) (*apiv2.MachineServiceUpdateRequest, error) { + if r.Allocation == nil { + return nil, fmt.Errorf("allocation is nil") + } + + return &apiv2.MachineServiceUpdateRequest{ + Uuid: r.Uuid, + UpdateMeta: UpdateMetaFromMeta(r.Meta), + Labels: UpdateLabelsFromMeta(r.Meta), + Project: r.Allocation.Project, + Description: &r.Allocation.Description, + SshPublicKeys: r.Allocation.SshPublicKeys, + }, nil +} From 33b33826caa2fadcaf6c394bec023f7ca94e56e0 Mon Sep 17 00:00:00 2001 From: Stefan Majer Date: Fri, 14 Aug 2026 15:19:46 +0200 Subject: [PATCH 09/12] simplify vpn connection --- cmd/admin/v2/machine.go | 17 ++++++++++++++++- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index eb4e488..7fa7b25 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -3,6 +3,7 @@ package v2 import ( "context" "fmt" + "net/netip" "net/url" "os" "os/exec" @@ -461,7 +462,21 @@ func (c *machine) firewallSSH(ctx context.Context, args []string) (err error) { return fmt.Errorf("failed to get VPN auth key: %w", err) } - v, err := metalvpn.Connect(ctx, machine.Uuid, authKeyResp.Address, authKeyResp.AuthKey) + var vpnopts = []metalvpn.ConnectOpt{} + + if machine.Allocation.Vpn != nil { + for _, ip := range machine.Allocation.Vpn.Ips { + parsed, err := netip.ParseAddr(ip) + if err != nil { + return err + } + if parsed.Is4() { + vpnopts = append(vpnopts, metalvpn.ConnectOptWithFirewallVPNIPAddress(ip)) + } + } + } + + v, err := metalvpn.Connect(ctx, machine.Uuid, authKeyResp.Address, authKeyResp.AuthKey, vpnopts...) if err != nil { return err } diff --git a/go.mod b/go.mod index 600a825..d26466a 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/metal-stack/api v0.4.4 - github.com/metal-stack/metal-lib v0.26.2 + github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f github.com/metal-stack/v v1.0.3 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index aed2987..39a8f54 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,8 @@ github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg= github.com/metal-stack/api v0.4.4 h1:NwJKCFHnbmsU7DjHLtBmpdPuPniXc2wDabHjdpXk3qA= github.com/metal-stack/api v0.4.4/go.mod h1:E7f2GkKNSr4vBxhQwWrr/Mjnf4ctPqCX2dpDtXBqkBk= -github.com/metal-stack/metal-lib v0.26.2 h1:ajPbC9wGe8LySw9J3JT03IGLZghvxsXSb7iceFp/FuQ= -github.com/metal-stack/metal-lib v0.26.2/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= +github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f h1:zetg2RWnqvFWFT3vW2fSSSXxfJN8Qfm/3HWQzQ6P9i0= +github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= From 178af1d02e5c99d8a4e2a95f1d5ca783b323febf Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 15:45:27 +0200 Subject: [PATCH 10/12] Admin tests. --- cmd/admin/v2/machine.go | 89 +-- cmd/api/v2/machine.go | 312 +--------- cmd/completion/machine.go | 19 + docs/admin/metalctlv2_admin_machine_create.md | 55 +- docs/admin/metalctlv2_admin_machine_list.md | 2 +- docs/admin/metalctlv2_admin_machine_update.md | 14 +- pkg/helpers/machine.go | 289 +++++++++ tests/e2e/admin/machine_test.go | 546 ++++++++++++++++++ 8 files changed, 967 insertions(+), 359 deletions(-) create mode 100644 tests/e2e/admin/machine_test.go diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index 7fa7b25..2c08d68 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -7,7 +7,6 @@ import ( "net/url" "os" "os/exec" - "os/user" "path/filepath" "strings" @@ -46,7 +45,7 @@ func newMachineCmd(c *config.Config) *cobra.Command { DescribePrinter: func() printers.Printer { return c.DescribePrinter }, ListPrinter: func() printers.Printer { return c.ListPrinter }, ListCmdMutateFn: func(cmd *cobra.Command) { - cmd.Flags().String("uuid", "", "allocation uuid of machine which should be listed") + cmd.Flags().String("id", "", "id of machine which should be listed") cmd.Flags().String("name", "", "name from machines which should be listed") cmd.Flags().String("hostname", "", "hostname from machines which should be listed") cmd.Flags().String("size", "", "size from machines which should be listed") @@ -58,6 +57,7 @@ func newMachineCmd(c *config.Config) *cobra.Command { genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.Completion.Size)) genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.Completion.Image)) genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.Partition)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.Completion.AdminMachine)) cmd.Long = cmd.Short + "\n" + helpers.EmojiHelpText() }, @@ -66,8 +66,24 @@ func newMachineCmd(c *config.Config) *cobra.Command { genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) }, + CreateRequestFromCLI: func() (*apiv2.MachineServiceCreateRequest, error) { + rq, err := helpers.MachineCreateRequestFromCLI(c) + if err != nil { + return nil, err + } + + if viper.IsSet("id") { + rq.Uuid = new(viper.GetString("id")) + } + + return rq, nil + }, CreateCmdMutateFn: func(cmd *cobra.Command) { - // w.addMachineCreateFlags(cmd, "machine") + helpers.AddMachineCreateFlags(cmd, "machine", c.Completion) + + cmd.Flags().String("id", "", "id of the machine to create. [optional]") + genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.Completion.AdminMachine)) + cmd.Aliases = []string{"allocate"} cmd.Example = `machine create can be done in two different ways: @@ -105,7 +121,23 @@ metalctl machine reserve 00000000-0000-0000-0000-0cc47ae54694 --remove Once created the machine installation can not be modified anymore. ` }, - ValidArgsFn: c.Completion.Machine, + UpdateCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("project", "p", "", "project from where machines should be listed") + cmd.Flags().String("description", "", "description of the machine") + cmd.Flags().StringSlice("labels", nil, "labels to replace for the machine") + cmd.Flags().StringSlice("add-labels", nil, "labels to add to the machine") + cmd.Flags().StringSlice("remove-labels", nil, "labels to remove to the machine") + cmd.Flags().StringP("ssh-public-key", "i", "", + `SSH public key for access via ssh and console. [optional] +Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". +If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) + + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) + }, + UpdateRequestFromCLI: func(args []string) (*apiv2.MachineServiceUpdateRequest, error) { + return helpers.MachineUpdateRequestFromCLI(c, args) + }, + ValidArgsFn: c.Completion.AdminMachine, } bmcCommandCmd := &cobra.Command{ @@ -114,7 +146,7 @@ Once created the machine installation can not be modified anymore. RunE: func(cmd *cobra.Command, args []string) error { return w.bmcCommand(args) }, - ValidArgsFunction: c.Completion.Machine, + ValidArgsFunction: c.Completion.AdminMachine, } bmcCommandCmd.Flags().String("command", "", "the actual command to send to the machine") genericcli.Must(bmcCommandCmd.RegisterFlagCompletionFunc("command", c.Completion.BMCCommands)) @@ -126,7 +158,7 @@ Once created the machine installation can not be modified anymore. RunE: func(cmd *cobra.Command, args []string) error { return w.lockOrTaint(args, apiv2.MachineState_MACHINE_STATE_LOCKED) }, - ValidArgsFunction: c.Completion.Machine, + ValidArgsFunction: c.Completion.AdminMachine, } lockCmd.Flags().String("description", "", "description of why the machine was locked") lockCmd.Flags().Bool("remove", false, "if set to true, machine will be unlocked") @@ -137,7 +169,7 @@ Once created the machine installation can not be modified anymore. RunE: func(cmd *cobra.Command, args []string) error { return w.lockOrTaint(args, apiv2.MachineState_MACHINE_STATE_TAINTED) }, - ValidArgsFunction: c.Completion.Machine, + ValidArgsFunction: c.Completion.AdminMachine, } taintCmd.Flags().String("description", "", "description of why the machine was tainted") taintCmd.Flags().Bool("remove", false, "if set to true, machine will be untainted") @@ -148,7 +180,7 @@ Once created the machine installation can not be modified anymore. RunE: func(cmd *cobra.Command, args []string) error { return w.console(cmd.Context(), args) }, - ValidArgsFunction: c.Completion.Machine, + ValidArgsFunction: c.Completion.AdminMachine, } consoleCmd.Flags().Bool("ipmi", false, "if set to true, the serial console will be opened using ipmitool (requires ipmitool to be present)") consoleCmd.Flags().Int("metal-console-port", 5222, "port open on our control-plane to connect via ssh to get machine console access") @@ -218,9 +250,10 @@ func (c *machine) List() ([]*apiv2.Machine, error) { var allocation *apiv2.MachineAllocationQuery - if viper.IsSet("hostname") || viper.IsSet("project") || viper.IsSet("image") { + if viper.IsSet("hostname") || viper.IsSet("name") || viper.IsSet("project") || viper.IsSet("image") { allocation = &apiv2.MachineAllocationQuery{ Hostname: pointer.PointerOrNil(viper.GetString("hostname")), + Name: pointer.PointerOrNil(viper.GetString("name")), Project: pointer.PointerOrNil(viper.GetString("project")), Image: pointer.PointerOrNil(viper.GetString("image")), } @@ -229,7 +262,6 @@ func (c *machine) List() ([]*apiv2.Machine, error) { resp, err := c.c.Client.Adminv2().Machine().List(ctx, &adminv2.MachineServiceListRequest{ Query: &apiv2.MachineQuery{ Uuid: pointer.PointerOrNil(viper.GetString("id")), - Name: pointer.PointerOrNil(viper.GetString("name")), Partition: pointer.PointerOrNil(viper.GetString("partition")), Size: pointer.PointerOrNil(viper.GetString("size")), Allocation: allocation, @@ -506,14 +538,14 @@ func (c *machine) firewallSSH(ctx context.Context, args []string) (err error) { // sshClient opens an interactive ssh session to the host on port with user, authenticated by the key. func sshClient(user, keyfile, host string, port int, idToken *string, passwordAuth bool) error { - var opts []metalssh.ConnectOpt + if passwordAuth { opts = append(opts, metalssh.ConnectOptOutputPassword(*idToken)) } else { if keyfile == "" { var err error - keyfile, err = searchSSHKey() + keyfile, err = helpers.SearchSSHKey() if err != nil { return err } @@ -531,39 +563,12 @@ func sshClient(user, keyfile, host string, port int, idToken *string, passwordAu if err != nil { return err } + var env *metalssh.Env + if idToken != nil { env = &metalssh.Env{"LC_METAL_STACK_OIDC_TOKEN": *idToken} } - return s.Connect(env) -} - -var ( - defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} -) -func searchSSHKey() (string, error) { - currentUser, err := user.Current() - if err != nil { - return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) - } - homeDir := currentUser.HomeDir - defaultDir := filepath.Join(homeDir, "/.ssh/") - var key string - for _, k := range defaultSSHKeys { - possibleKey := filepath.Join(defaultDir, k) - _, err := os.ReadFile(possibleKey) - if err == nil { - fmt.Printf("using SSH identity: %s. Another identity can be specified with --sshidentity/-p\n", - possibleKey) - key = possibleKey - break - } - } - - if key == "" { - return "", fmt.Errorf("failure to locate a SSH identity in default location (%s), "+ - "another identity can be specified with --sshidentity/-p", defaultDir) - } - return key, nil + return s.Connect(env) } diff --git a/cmd/api/v2/machine.go b/cmd/api/v2/machine.go index 382b522..4ef84ed 100644 --- a/cmd/api/v2/machine.go +++ b/cmd/api/v2/machine.go @@ -1,14 +1,6 @@ package v2 import ( - "encoding/base64" - "fmt" - "net/netip" - "os" - osuser "os/user" - "path/filepath" - "strings" - "github.com/metal-stack/api/go/errorutil" apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/metal-stack/cli/cmd/config" @@ -17,7 +9,6 @@ import ( "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" "github.com/metal-stack/metal-lib/pkg/pointer" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -32,17 +23,19 @@ func newMachineCmd(c *config.Config) *cobra.Command { } cmdsConfig := &genericcli.CmdsConfig[*apiv2.MachineServiceCreateRequest, *apiv2.MachineServiceUpdateRequest, *apiv2.Machine]{ - BinaryName: config.BinaryName, - GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), - Singular: "machine", - Plural: "machines", - Description: "allocate a machine", - Sorter: sorters.MachineSorter(), - DescribePrinter: func() printers.Printer { return c.DescribePrinter }, - ListPrinter: func() printers.Printer { return c.ListPrinter }, - CreateRequestFromCLI: w.createRequestFromCLI, + BinaryName: config.BinaryName, + GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.Fs), + Singular: "machine", + Plural: "machines", + Description: "allocate a machine", + Sorter: sorters.MachineSorter(), + DescribePrinter: func() printers.Printer { return c.DescribePrinter }, + ListPrinter: func() printers.Printer { return c.ListPrinter }, + CreateRequestFromCLI: func() (*apiv2.MachineServiceCreateRequest, error) { + return helpers.MachineCreateRequestFromCLI(c) + }, CreateCmdMutateFn: func(cmd *cobra.Command) { - w.addMachineCreateFlags(cmd, "machine") + helpers.AddMachineCreateFlags(cmd, "machine", c.Completion) cmd.Aliases = []string{"allocate"} }, ListCmdMutateFn: func(cmd *cobra.Command) { @@ -74,7 +67,9 @@ If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picke genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.Completion.Project)) }, - UpdateRequestFromCLI: w.updateRequestFromCLI, + UpdateRequestFromCLI: func(args []string) (*apiv2.MachineServiceUpdateRequest, error) { + return helpers.MachineUpdateRequestFromCLI(c, args) + }, DescribeCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().StringP("project", "p", "", "project of the machine") @@ -202,280 +197,3 @@ func (c *machine) Convert(r *apiv2.Machine) (string, *apiv2.MachineServiceCreate return helpers.EncodeProject(r.Uuid, r.Allocation.Project), create, update, err } - -func (c *machine) createRequestFromCLI() (*apiv2.MachineServiceCreateRequest, error) { - var ( - keys []string - dnsServers []*apiv2.DNSServer - ntpServers []*apiv2.NTPServer - allocationType apiv2.MachineAllocationType - firewallSpec *apiv2.FirewallSpec - - sshPublicKeyArgument = viper.GetString("ssh-public-key") - dnsServersArgument = viper.GetStringSlice("dns-servers") - ntpServersArgument = viper.GetStringSlice("ntp-servers") - ) - - if strings.HasPrefix(sshPublicKeyArgument, "@") { - var err error - sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKeyArgument[1:]) - if err != nil { - return nil, err - } - } - - if len(sshPublicKeyArgument) == 0 { - sshKey, err := searchSSHKey() - if err != nil { - return nil, err - } - sshPublicKey := sshKey + ".pub" - sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKey) - if err != nil { - return nil, err - } - } - - if sshPublicKeyArgument != "" { - keys = append(keys, sshPublicKeyArgument) - } - - userDataArgument := viper.GetString("userdata") - if strings.HasPrefix(userDataArgument, "@") { - var err error - userDataArgument, err = readFromFile(c.c.Fs, userDataArgument[1:]) - if err != nil { - return nil, err - } - } - if userDataArgument != "" { - userDataArgument = base64.StdEncoding.EncodeToString([]byte(userDataArgument)) - } - - possibleNetworks := viper.GetStringSlice("networks") - networks, err := parseNetworks(possibleNetworks) - if err != nil { - return nil, err - } - - for _, s := range dnsServersArgument { - dnsServers = append(dnsServers, &apiv2.DNSServer{Ip: s}) - } - - for _, s := range ntpServersArgument { - ntpServers = append(ntpServers, &apiv2.NTPServer{Address: s}) - } - - allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE - if viper.GetString("allocation-type") == "firewall" { - allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL - } - - labels, err := helpers.LabelsFromSlice(viper.GetStringSlice("labels")) - if err != nil { - return nil, err - } - - var filesystemlayout *string - if viper.IsSet("filesystem-layout") { - filesystemlayout = new(viper.GetString("filesystem-layout")) - } - var size *string - if viper.IsSet("size") { - size = new(viper.GetString("size")) - } - var partition *string - if viper.IsSet("partition") { - partition = new(viper.GetString("partition")) - } - var hostname *string - if viper.IsSet("hostname") { - hostname = new(viper.GetString("hostname")) - } - var description *string - if viper.IsSet("description") { - description = new(viper.GetString("description")) - } - - return &apiv2.MachineServiceCreateRequest{ - Description: description, - Partition: partition, - Hostname: hostname, - Image: viper.GetString("image"), - Name: viper.GetString("name"), - Project: viper.GetString("project"), - Size: size, - SshPublicKeys: keys, - Labels: labels, - Userdata: new(userDataArgument), - Networks: networks, - DnsServers: dnsServers, - NtpServers: ntpServers, - FilesystemLayout: filesystemlayout, - PlacementTags: viper.GetStringSlice("placement-tags"), - AllocationType: allocationType, - FirewallSpec: firewallSpec, - }, nil -} - -func (c *machine) updateRequestFromCLI(args []string) (*apiv2.MachineServiceUpdateRequest, error) { - id, err := genericcli.GetExactlyOneArg(args) - if err != nil { - return nil, err - } - - updateLabels, err := helpers.UpdateLabelsFromCLI() - if err != nil { - return nil, err - } - - sshPublicKeyArgument := viper.GetString("ssh-public-key") - - if strings.HasPrefix(sshPublicKeyArgument, "@") { - var err error - sshPublicKeyArgument, err = readFromFile(c.c.Fs, sshPublicKeyArgument[1:]) - if err != nil { - return nil, err - } - } - - return &apiv2.MachineServiceUpdateRequest{ - Uuid: id, - UpdateMeta: &apiv2.UpdateMeta{ - LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, - }, - Project: c.c.GetProject(), - Description: pointer.PointerOrNil(viper.GetString("description")), - Labels: updateLabels, - SshPublicKeys: []string{sshPublicKeyArgument}, - }, nil -} - -var defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} - -func searchSSHKey() (string, error) { - currentUser, err := osuser.Current() - if err != nil { - return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) - } - homeDir := currentUser.HomeDir - defaultDir := filepath.Join(homeDir, "/.ssh/") - var key string - for _, k := range defaultSSHKeys { - possibleKey := filepath.Join(defaultDir, k) - _, err := os.ReadFile(possibleKey) - if err == nil { - fmt.Printf("using SSH identity: %s. Another identity can be specified with --sshidentity/-p\n", - possibleKey) - key = possibleKey - break - } - } - - if key == "" { - return "", fmt.Errorf("failure to locate a SSH identity in default location (%s), "+ - "another identity can be specified with --sshidentity/-p", defaultDir) - } - return key, nil -} - -func readFromFile(fs *afero.Afero, filePath string) (string, error) { - filePath, err := expandFilepath(filePath) - if err != nil { - return "", err - } - - content, err := fs.ReadFile(filePath) - if err != nil { - return "", fmt.Errorf("unable to read from given file %q: %w", filePath, err) - } - - return strings.TrimSpace(string(content)), nil -} - -func expandFilepath(filePath string) (string, error) { - currentUser, err := osuser.Current() - if err != nil { - return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) - } - homeDir := currentUser.HomeDir - - if filePath == "~" { - filePath = homeDir - } else if strings.HasPrefix(filePath, "~/") { - filePath = filepath.Join(homeDir, filePath[2:]) - } - - return filePath, nil -} - -func parseNetworks(possibleNetworks []string) ([]*apiv2.MachineAllocationNetwork, error) { - var result []*apiv2.MachineAllocationNetwork - - for _, n := range possibleNetworks { - if n == "" { - continue - } - man := &apiv2.MachineAllocationNetwork{ - Network: n, - } - nw, ipsString, found := strings.Cut(n, ":") - if found { - man.Network = nw - for ip := range strings.SplitSeq(ipsString, ";") { - if ip == "" { - continue - } - _, err := netip.ParseAddr(ip) - if err != nil { - return nil, fmt.Errorf("malformed ip %q: %w", ip, err) - } - man.Ips = append(man.Ips, ip) - } - } - result = append(result, man) - } - - return result, nil -} - -func (c *machine) addMachineCreateFlags(cmd *cobra.Command, name string) { - cmd.Flags().String("description", "", "Description of the "+name+" to create. [optional]") - cmd.Flags().String("partition", "", "partition/datacenter where the "+name+" is created. [required, except for reserved machines]") - cmd.Flags().String("hostname", "", "Hostname of the "+name+". [required]") - cmd.Flags().String("image", "", "OS Image to install. [required]") - cmd.Flags().String("filesystem-layout", "", "Filesystemlayout to use during machine installation. [optional]") - cmd.Flags().String("name", "", "Name of the "+name+". [optional]") - cmd.Flags().StringP("project", "p", "", "Project where the "+name+" should belong to. [required]") - cmd.Flags().String("size", "", "Size of the "+name+". [required, except for reserved machines]") - cmd.Flags().String("allocation-type", "machine", "allocation type, can be either machine|firewall") - cmd.Flags().StringP("ssh-public-key", "i", "", - `SSH public key for access via ssh and console. [optional] -Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". -If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) - cmd.Flags().StringSlice("labels", []string{}, "labels to add to the "+name+", use it like: --labels \"a=b\" or --labels \"a=\".") - cmd.Flags().String("userdata", "", `cloud-init.io compatible userdata. [optional] -Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg".`) - cmd.Flags().StringSlice("dns-servers", []string{}, "dns servers to add to the machine or firewall. [optional]") - cmd.Flags().StringSlice("ntp-servers", []string{}, "ntp servers to add to the machine or firewall. [optional]") - - cmd.Flags().StringSlice("networks", []string{}, - `Adds a network. Usage: [--networks NETWORK[:ip[;ip]][,NETWORK[:ip[;ip]]... -NETWORK specifies the name or id of an existing network. -IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. -`) - cmd.Flags().StringSlice("placement-tags", []string{}, "placement tags used for rack spreading") - - cmd.MarkFlagsMutuallyExclusive("file", "project") - cmd.MarkFlagsRequiredTogether("project", "networks", "hostname", "image") - cmd.MarkFlagsRequiredTogether("size", "partition") - - // Completion for arguments - genericcli.Must(cmd.RegisterFlagCompletionFunc("networks", c.c.Completion.Network)) - genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.c.Completion.Partition)) - genericcli.Must(cmd.RegisterFlagCompletionFunc("size", c.c.Completion.Size)) - genericcli.Must(cmd.RegisterFlagCompletionFunc("project", c.c.Completion.Project)) - genericcli.Must(cmd.RegisterFlagCompletionFunc("image", c.c.Completion.Image)) - // FIXME implement - // genericcli.Must(cmd.RegisterFlagCompletionFunc("filesystem-layout", c.c.Completion.FilesystemLayoutListCompletion)) -} diff --git a/cmd/completion/machine.go b/cmd/completion/machine.go index 6110485..6f930f0 100644 --- a/cmd/completion/machine.go +++ b/cmd/completion/machine.go @@ -28,6 +28,25 @@ func (c *Completion) Machine(cmd *cobra.Command, args []string, toComplete strin return names, cobra.ShellCompDirectiveNoFileComp } +func (c *Completion) AdminMachine(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Machine().List(cmd.Context(), &adminv2.MachineServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var names []string + + for _, m := range resp.Machines { + name := m.Uuid + if m.Allocation != nil { + name = m.Uuid + "\t" + m.Allocation.Hostname + } + names = append(names, name) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + func (c *Completion) Firewall(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { resp, err := c.Client.Adminv2().Machine().List(cmd.Context(), &adminv2.MachineServiceListRequest{ Query: &apiv2.MachineQuery{ diff --git a/docs/admin/metalctlv2_admin_machine_create.md b/docs/admin/metalctlv2_admin_machine_create.md index 5c31835..8145e72 100644 --- a/docs/admin/metalctlv2_admin_machine_create.md +++ b/docs/admin/metalctlv2_admin_machine_create.md @@ -49,22 +49,45 @@ Once created the machine installation can not be modified anymore. ### Options ``` - --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. - -f, --file string filename of the create or update request in yaml format, or - for stdin. - - Example: - $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml - $ vi machine.yaml - $ # either via stdin - $ cat machine.yaml | metalctlv2 machine create -f - - $ # or via file - $ metalctlv2 machine create -f machine.yaml - - the file can also contain multiple documents and perform a bulk operation. - - -h, --help help for create - --skip-security-prompts skips security prompt for bulk operations - --timestamps when used with --file (bulk operation): prints timestamps in-between the operations + --allocation-type string allocation type, can be either machine|firewall (default "machine") + --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + --description string Description of the machine to create. [optional] + --dns-servers strings dns servers to add to the machine or firewall. [optional] + -f, --file string filename of the create or update request in yaml format, or - for stdin. + + Example: + $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml + $ vi machine.yaml + $ # either via stdin + $ cat machine.yaml | metalctlv2 machine create -f - + $ # or via file + $ metalctlv2 machine create -f machine.yaml + + the file can also contain multiple documents and perform a bulk operation. + + --filesystem-layout string Filesystemlayout to use during machine installation. [optional] + -h, --help help for create + --hostname string Hostname of the machine. [required] + --id string id of the machine to create. [optional] + --image string OS Image to install. [required] + --labels strings labels to add to the machine, use it like: --labels "a=b" or --labels "a=". + --name string Name of the machine. [optional] + --networks strings Adds a network. Usage: [--networks NETWORK[:ip[;ip]][,NETWORK[:ip[;ip]]... + NETWORK specifies the name or id of an existing network. + IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. + + --ntp-servers strings ntp servers to add to the machine or firewall. [optional] + --partition string partition/datacenter where the machine is created. [required, except for reserved machines] + --placement-tags strings placement tags used for rack spreading + -p, --project string Project where the machine should belong to. [required] + --size string Size of the machine. [required, except for reserved machines] + --skip-security-prompts skips security prompt for bulk operations + -i, --ssh-public-key string SSH public key for access via ssh and console. [optional] + Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". + If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. + --timestamps when used with --file (bulk operation): prints timestamps in-between the operations + --userdata string cloud-init.io compatible userdata. [optional] + Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg". ``` ### Options inherited from parent commands diff --git a/docs/admin/metalctlv2_admin_machine_list.md b/docs/admin/metalctlv2_admin_machine_list.md index fdf9e28..a1463d7 100644 --- a/docs/admin/metalctlv2_admin_machine_list.md +++ b/docs/admin/metalctlv2_admin_machine_list.md @@ -27,13 +27,13 @@ metalctlv2 admin machine list [flags] ``` -h, --help help for list --hostname string hostname from machines which should be listed + --id string id of machine which should be listed --image string image --name string name from machines which should be listed --partition string partition from where machines should be listed -p, --project string project from where machines should be listed --size string size from machines which should be listed --sort-by strings sort by (comma separated) column(s), sort direction can be changed by appending :asc or :desc behind the column identifier. possible values: age|image|partition|project|rack|size|uuid - --uuid string allocation uuid of machine which should be listed ``` ### Options inherited from parent commands diff --git a/docs/admin/metalctlv2_admin_machine_update.md b/docs/admin/metalctlv2_admin_machine_update.md index 2c70e24..2fa6d58 100644 --- a/docs/admin/metalctlv2_admin_machine_update.md +++ b/docs/admin/metalctlv2_admin_machine_update.md @@ -3,27 +3,35 @@ updates the machine ``` -metalctlv2 admin machine update [flags] +metalctlv2 admin machine update [flags] ``` ### Options ``` + --add-labels strings labels to add to the machine --bulk-output when used with --file (bulk operation): prints results at the end as a list. default is printing results intermediately during the operation, which causes single entities to be printed in a row. + --description string description of the machine -f, --file string filename of the create or update request in yaml format, or - for stdin. Example: $ metalctlv2 machine describe machine-1 -o yaml > machine.yaml $ vi machine.yaml $ # either via stdin - $ cat machine.yaml | metalctlv2 machine update -f - + $ cat machine.yaml | metalctlv2 machine update -f - $ # or via file - $ metalctlv2 machine update -f machine.yaml + $ metalctlv2 machine update -f machine.yaml the file can also contain multiple documents and perform a bulk operation. -h, --help help for update + --labels strings labels to replace for the machine + -p, --project string project from where machines should be listed + --remove-labels strings labels to remove to the machine --skip-security-prompts skips security prompt for bulk operations + -i, --ssh-public-key string SSH public key for access via ssh and console. [optional] + Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". + If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order. --timestamps when used with --file (bulk operation): prints timestamps in-between the operations ``` diff --git a/pkg/helpers/machine.go b/pkg/helpers/machine.go index 2629058..1cd547d 100644 --- a/pkg/helpers/machine.go +++ b/pkg/helpers/machine.go @@ -1,10 +1,22 @@ package helpers import ( + "encoding/base64" "fmt" + "net/netip" + "os" + osuser "os/user" + "path/filepath" + "strings" apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/cmd/completion" + "github.com/metal-stack/cli/cmd/config" + "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) func MachineResponseToCreate(r *apiv2.Machine) (*apiv2.MachineServiceCreateRequest, error) { @@ -68,3 +80,280 @@ func MachineResponseToUpdate(r *apiv2.Machine) (*apiv2.MachineServiceUpdateReque SshPublicKeys: r.Allocation.SshPublicKeys, }, nil } + +func MachineCreateRequestFromCLI(c *config.Config) (*apiv2.MachineServiceCreateRequest, error) { + var ( + keys []string + dnsServers []*apiv2.DNSServer + ntpServers []*apiv2.NTPServer + allocationType apiv2.MachineAllocationType + firewallSpec *apiv2.FirewallSpec + + sshPublicKeyArgument = viper.GetString("ssh-public-key") + dnsServersArgument = viper.GetStringSlice("dns-servers") + ntpServersArgument = viper.GetStringSlice("ntp-servers") + ) + + if strings.HasPrefix(sshPublicKeyArgument, "@") { + var err error + sshPublicKeyArgument, err = readFromFile(c.Fs, sshPublicKeyArgument[1:]) + if err != nil { + return nil, err + } + } + + if len(sshPublicKeyArgument) == 0 { + sshKey, err := SearchSSHKey() + if err != nil { + return nil, err + } + sshPublicKey := sshKey + ".pub" + sshPublicKeyArgument, err = readFromFile(c.Fs, sshPublicKey) + if err != nil { + return nil, err + } + } + + if sshPublicKeyArgument != "" { + keys = append(keys, sshPublicKeyArgument) + } + + userDataArgument := viper.GetString("userdata") + if strings.HasPrefix(userDataArgument, "@") { + var err error + userDataArgument, err = readFromFile(c.Fs, userDataArgument[1:]) + if err != nil { + return nil, err + } + } + if userDataArgument != "" { + userDataArgument = base64.StdEncoding.EncodeToString([]byte(userDataArgument)) + } + + possibleNetworks := viper.GetStringSlice("networks") + networks, err := parseNetworks(possibleNetworks) + if err != nil { + return nil, err + } + + for _, s := range dnsServersArgument { + dnsServers = append(dnsServers, &apiv2.DNSServer{Ip: s}) + } + + for _, s := range ntpServersArgument { + ntpServers = append(ntpServers, &apiv2.NTPServer{Address: s}) + } + + allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE + if viper.GetString("allocation-type") == "firewall" { + allocationType = apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_FIREWALL + } + + labels, err := LabelsFromSlice(viper.GetStringSlice("labels")) + if err != nil { + return nil, err + } + + var filesystemlayout *string + if viper.IsSet("filesystem-layout") { + filesystemlayout = new(viper.GetString("filesystem-layout")) + } + var size *string + if viper.IsSet("size") { + size = new(viper.GetString("size")) + } + var partition *string + if viper.IsSet("partition") { + partition = new(viper.GetString("partition")) + } + var hostname *string + if viper.IsSet("hostname") { + hostname = new(viper.GetString("hostname")) + } + var description *string + if viper.IsSet("description") { + description = new(viper.GetString("description")) + } + + return &apiv2.MachineServiceCreateRequest{ + Description: description, + Partition: partition, + Hostname: hostname, + Image: viper.GetString("image"), + Name: viper.GetString("name"), + Project: viper.GetString("project"), + Size: size, + SshPublicKeys: keys, + Labels: labels, + Userdata: new(userDataArgument), + Networks: networks, + DnsServers: dnsServers, + NtpServers: ntpServers, + FilesystemLayout: filesystemlayout, + PlacementTags: viper.GetStringSlice("placement-tags"), + AllocationType: allocationType, + FirewallSpec: firewallSpec, + }, nil +} + +func MachineUpdateRequestFromCLI(c *config.Config, args []string) (*apiv2.MachineServiceUpdateRequest, error) { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return nil, err + } + + updateLabels, err := UpdateLabelsFromCLI() + if err != nil { + return nil, err + } + + sshPublicKeyArgument := viper.GetString("ssh-public-key") + + if strings.HasPrefix(sshPublicKeyArgument, "@") { + var err error + sshPublicKeyArgument, err = readFromFile(c.Fs, sshPublicKeyArgument[1:]) + if err != nil { + return nil, err + } + } + + return &apiv2.MachineServiceUpdateRequest{ + Uuid: id, + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Project: c.GetProject(), + Description: pointer.PointerOrNil(viper.GetString("description")), + Labels: updateLabels, + SshPublicKeys: []string{sshPublicKeyArgument}, + }, nil +} + +var defaultSSHKeys = [...]string{"id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"} + +func SearchSSHKey() (string, error) { + currentUser, err := osuser.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) + } + homeDir := currentUser.HomeDir + defaultDir := filepath.Join(homeDir, "/.ssh/") + var key string + for _, k := range defaultSSHKeys { + possibleKey := filepath.Join(defaultDir, k) + _, err := os.ReadFile(possibleKey) + if err == nil { + fmt.Printf("using SSH identity: %s. Another identity can be specified with --sshidentity/-p\n", + possibleKey) + key = possibleKey + break + } + } + + if key == "" { + return "", fmt.Errorf("failure to locate a SSH identity in default location (%s), "+ + "another identity can be specified with --sshidentity/-p", defaultDir) + } + return key, nil +} + +func readFromFile(fs *afero.Afero, filePath string) (string, error) { + filePath, err := expandFilepath(filePath) + if err != nil { + return "", err + } + + content, err := fs.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("unable to read from given file %q: %w", filePath, err) + } + + return strings.TrimSpace(string(content)), nil +} + +func expandFilepath(filePath string) (string, error) { + currentUser, err := osuser.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user for expanding userdata path:%w", err) + } + homeDir := currentUser.HomeDir + + if filePath == "~" { + filePath = homeDir + } else if strings.HasPrefix(filePath, "~/") { + filePath = filepath.Join(homeDir, filePath[2:]) + } + + return filePath, nil +} + +func parseNetworks(possibleNetworks []string) ([]*apiv2.MachineAllocationNetwork, error) { + var result []*apiv2.MachineAllocationNetwork + + for _, n := range possibleNetworks { + if n == "" { + continue + } + man := &apiv2.MachineAllocationNetwork{ + Network: n, + } + nw, ipsString, found := strings.Cut(n, ":") + if found { + man.Network = nw + for ip := range strings.SplitSeq(ipsString, ";") { + if ip == "" { + continue + } + _, err := netip.ParseAddr(ip) + if err != nil { + return nil, fmt.Errorf("malformed ip %q: %w", ip, err) + } + man.Ips = append(man.Ips, ip) + } + } + result = append(result, man) + } + + return result, nil +} + +func AddMachineCreateFlags(cmd *cobra.Command, name string, completion *completion.Completion) { + cmd.Flags().String("description", "", "Description of the "+name+" to create. [optional]") + cmd.Flags().String("partition", "", "partition/datacenter where the "+name+" is created. [required, except for reserved machines]") + cmd.Flags().String("hostname", "", "Hostname of the "+name+". [required]") + cmd.Flags().String("image", "", "OS Image to install. [required]") + cmd.Flags().String("filesystem-layout", "", "Filesystemlayout to use during machine installation. [optional]") + cmd.Flags().String("name", "", "Name of the "+name+". [optional]") + cmd.Flags().StringP("project", "p", "", "Project where the "+name+" should belong to. [required]") + cmd.Flags().String("size", "", "Size of the "+name+". [required, except for reserved machines]") + cmd.Flags().String("allocation-type", "machine", "allocation type, can be either machine|firewall") + cmd.Flags().StringP("ssh-public-key", "i", "", + `SSH public key for access via ssh and console. [optional] +Can be either the public key as string, or pointing to the public key file to use e.g.: "@~/.ssh/id_rsa.pub". +If ~/.ssh/[id_ed25519.pub | id_rsa.pub | id_dsa.pub] is present it will be picked as default, matching the first one in this order.`) + cmd.Flags().StringSlice("labels", []string{}, "labels to add to the "+name+", use it like: --labels \"a=b\" or --labels \"a=\".") + cmd.Flags().String("userdata", "", `cloud-init.io compatible userdata. [optional] +Can be either the userdata as string, or pointing to the userdata file to use e.g.: "@/tmp/userdata.cfg".`) + cmd.Flags().StringSlice("dns-servers", []string{}, "dns servers to add to the machine or firewall. [optional]") + cmd.Flags().StringSlice("ntp-servers", []string{}, "ntp servers to add to the machine or firewall. [optional]") + + cmd.Flags().StringSlice("networks", []string{}, + `Adds a network. Usage: [--networks NETWORK[:ip[;ip]][,NETWORK[:ip[;ip]]... +NETWORK specifies the name or id of an existing network. +IPs can be added per network colon separated, these ips must be already allocated upfront. If no ip(s) are specified per network, one ip per network is allocated. +`) + cmd.Flags().StringSlice("placement-tags", []string{}, "placement tags used for rack spreading") + + cmd.MarkFlagsMutuallyExclusive("file", "project") + cmd.MarkFlagsRequiredTogether("project", "networks", "hostname", "image") + cmd.MarkFlagsRequiredTogether("size", "partition") + + // Completion for arguments + genericcli.Must(cmd.RegisterFlagCompletionFunc("networks", completion.Network)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", completion.Partition)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("size", completion.Size)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("project", completion.Project)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("image", completion.Image)) + // FIXME implement + // genericcli.Must(cmd.RegisterFlagCompletionFunc("filesystem-layout", c.c.Completion.FilesystemLayoutListCompletion)) +} diff --git a/tests/e2e/admin/machine_test.go b/tests/e2e/admin/machine_test.go new file mode 100644 index 0000000..efa33e3 --- /dev/null +++ b/tests/e2e/admin/machine_test.go @@ -0,0 +1,546 @@ +package admin_e2e + +import ( + "encoding/base64" + "fmt" + "os" + "strings" + "testing" + + "connectrpc.com/connect" + "github.com/metal-stack/api/go/client" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + e2erootcmd "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/e2e" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func Test_MachineCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceListResponse, apiv2.Machine]{ + { + Name: "list", + CmdArgs: []string{"admin", "machine", "list", + "--name", "name", + "--hostname", "hostname", + "--image", "image", + "--partition", "partition", + "--project", "project", + "--size", "size", + "--id", "uuid", + }, + AssertExhaustiveArgs: true, + AssertExhaustiveExcludes: []string{"sort-by"}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.MachineServiceListRequest{ + Query: &apiv2.MachineQuery{ + Allocation: &apiv2.MachineAllocationQuery{ + Hostname: new("hostname"), + Name: new("name"), + Image: new("image"), + Project: new("project"), + }, + Partition: new("partition"), + Size: new("size"), + Uuid: new("uuid"), + }, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.MachineServiceListResponse{ + Machines: []*apiv2.Machine{ + testresources.Machine2(), + testresources.Machine1(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 5fa2bbe1-407c-4142-92d5-e4419daf9646 Alive 1m v1-medium-x86 partition-1 rack-1 + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + WantWideTable: new(` + ID LAST EVENT WHEN AGE DESCRIPTION NAME HOSTNAME PROJECT IPS SIZE IMAGE PARTITION RACK STARTED TAGS STATE + 5fa2bbe1-407c-4142-92d5-e4419daf9646 Alive 1m v1-medium-x86 partition-1 rack-1 a=b available + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd Phoned Home 1m 1m machine 2 machine-2 machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c 4.5.6.7 v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 1999-12-31T23:59:00Z c=d available + 192.1.1.1 + `), + Template: new("{{ .allocation.uuid }} {{ .allocation.project }}"), + WantTemplate: new(` + +4f94e87b-b08f-4f82-b053-9b8305de60ad f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c + `), + WantMarkdown: new(` + | ID | | LAST EVENT | WHEN | AGE | HOSTNAME | PROJECT | SIZE | IMAGE | PARTITION | RACK | + |---------------------------------------|---|-------------|------|-----|-----------|--------------------------------------|---------------|--------------|-------------|--------| + | 5fa2bbe1-407c-4142-92d5-e4419daf9646 | | Alive | 1m | | | | v1-medium-x86 | | partition-1 | rack-1 | + | 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd | 🛡 | Phoned Home | 1m | 1m | machine-2 | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | v1-medium-x86 | Ubuntu 24.04 | partition-2 | rack-1 | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ + { + Name: "describe", + CmdArgs: []string{"admin", "machine", "describe", testresources.Machine2().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.MachineServiceGetRequest{ + Uuid: testresources.Machine2().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.MachineServiceGetResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + WantWideTable: new(` + ID LAST EVENT WHEN AGE DESCRIPTION NAME HOSTNAME PROJECT IPS SIZE IMAGE PARTITION RACK STARTED TAGS STATE + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd Phoned Home 1m 1m machine 2 machine-2 machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c 4.5.6.7 v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 1999-12-31T23:59:00Z c=d available + 192.1.1.1 + `), + Template: new("{{ .allocation.uuid }} {{ .allocation.project }}"), + WantTemplate: new(` + 4f94e87b-b08f-4f82-b053-9b8305de60ad f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c + `), + WantMarkdown: new(` + | ID | | LAST EVENT | WHEN | AGE | HOSTNAME | PROJECT | SIZE | IMAGE | PARTITION | RACK | + |---------------------------------------|---|-------------|------|-----|-----------|--------------------------------------|---------------|--------------|-------------|--------| + | 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd | 🛡 | Phoned Home | 1m | 1m | machine-2 | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | v1-medium-x86 | Ubuntu 24.04 | partition-2 | rack-1 | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Create(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceGetResponse, *apiv2.Machine]{ + { + Name: "create", + CmdArgs: []string{"admin", "machine", "create", + "--id", testresources.Machine2().Uuid, + "--project", testresources.Machine2().Allocation.Project, + "--networks", func() string { + var names []string + + for _, nw := range testresources.Machine2().Allocation.Networks { + nwWithIps := nw.Network + if len(nw.Ips) > 0 { + nwWithIps += ":" + strings.Join(nw.Ips, ";") + } + names = append(names, nwWithIps) + } + + return strings.Join(names, ",") + }(), + "--allocation-type", testresources.Machine2().Allocation.AllocationType.String(), + "--description", testresources.Machine2().Allocation.Description, + "--dns-servers", "1.1.1.1", + "--ntp-servers", "2.2.2.2,3.3.3.3", + "--filesystem-layout", "fsl1", + "--hostname", testresources.Machine2().Allocation.Hostname, + "--image", testresources.Image1().Id, + "--name", testresources.Machine2().Allocation.Name, + "--partition", testresources.Machine2().Partition.Id, + "--size", testresources.Machine2().Size.Id, + "--ssh-public-key", "@.ssh/id_rsa.pub", + "--labels", "a=b", + "--userdata", "@ignition.json", + "--placement-tags", "cluster-id=cluster-uuid", + }, + AssertExhaustiveArgs: true, + AssertExhaustiveExcludes: e2e.CommonExcludedFileArgs(), + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + genericcli.Must(fs.WriteFile(".ssh/id_rsa.pub", []byte("12345"), os.ModeAppend)) + genericcli.Must(fs.WriteFile("ignition.json", []byte("{}"), os.ModeAppend)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Uuid: &testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Partition: &testresources.Machine2().Partition.Id, + Size: &testresources.Machine2().Size.Id, + Image: testresources.Machine2().Allocation.Image.Id, + FilesystemLayout: new("fsl1"), + SshPublicKeys: []string{"12345"}, + Userdata: new(base64.StdEncoding.EncodeToString([]byte("{}"))), + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "a": "b", + }, + }, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + PlacementTags: []string{"cluster-id=cluster-uuid"}, + DnsServers: []*apiv2.DNSServer{{Ip: "1.1.1.1"}}, + NtpServers: []*apiv2.NTPServer{{Address: "2.2.2.2"}, {Address: "3.3.3.3"}}, + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + }, + { + Name: "create from file", + CmdArgs: append([]string{"admin", "machine", "create"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Uuid: &testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Delete(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceDeleteResponse, *apiv2.Machine]{ + { + Name: "delete", + CmdArgs: []string{"admin", "machine", "delete", testresources.Machine2().Uuid}, + NewRootCmd: e2erootcmd.NewRootCmd(t, &e2erootcmd.TestConfig{ + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.MachineServiceDeleteRequest{ + Uuid: testresources.Machine2().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.MachineServiceDeleteResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }), + WantProtoObject: testresources.Machine2(), + }, + { + Name: "delete from file", + CmdArgs: append([]string{"admin", "machine", "delete"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &adminv2.MachineServiceDeleteRequest{ + Uuid: testresources.Machine2().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.MachineServiceDeleteResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Update(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ + { + Name: "update", + CmdArgs: []string{"admin", "machine", "update", + "--project", testresources.Project2().Uuid, + testresources.Machine2().Uuid, + "--description", "42", + "--labels", "1=2", + "--ssh-public-key", "@.ssh/id_rsa.pub", + }, + AssertExhaustiveArgs: true, + AssertExhaustiveExcludes: append(e2e.CommonExcludedFileArgs(), "add-labels", "remove-labels"), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + genericcli.Must(fs.WriteFile(".ssh/id_rsa.pub", []byte("12345"), os.ModeAppend)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Project2().Uuid, + Description: new("42"), + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: &apiv2.Labels{ + Labels: map[string]string{ + "1": "2", + }, + }, + }, + }, + SshPublicKeys: []string{"12345"}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantProtoObject: testresources.Machine2(), + }, + { + Name: "update from file", + CmdArgs: append([]string{"admin", "machine", "update"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Description: &testresources.Machine2().Allocation.Description, + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: testresources.Machine2().Meta.Labels, + }, + }, + SshPublicKeys: []string{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_MachineCmd_Apply(t *testing.T) { + tests := []*e2e.Test[apiv2.MachineServiceUpdateResponse, *apiv2.Machine]{ + { + Name: "apply", + CmdArgs: append([]string{"admin", "machine", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Uuid: &testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceCreateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + { + Name: "apply already exists", + CmdArgs: append([]string{"admin", "machine", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2erootcmd.NewRootCmd(t, + &e2erootcmd.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Machine2()), 0755)) + }, + ClientCalls: []client.ClientCall{ + { + WantRequest: &apiv2.MachineServiceCreateRequest{ + Uuid: &testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Name: testresources.Machine2().Allocation.Name, + Description: &testresources.Machine2().Allocation.Description, + Hostname: &testresources.Machine2().Allocation.Hostname, + Image: testresources.Machine2().Allocation.Image.Id, + Userdata: nil, + Labels: testresources.Machine2().Meta.Labels, + Networks: func() []*apiv2.MachineAllocationNetwork { + var nws []*apiv2.MachineAllocationNetwork + + for _, nw := range testresources.Machine2().Allocation.Networks { + nws = append(nws, &apiv2.MachineAllocationNetwork{ + Network: nw.Network, + Ips: nw.Ips, + }) + } + + return nws + }(), + AllocationType: apiv2.MachineAllocationType_MACHINE_ALLOCATION_TYPE_MACHINE, + FirewallSpec: nil, + }, + WantError: connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("already exists")), + }, + { + WantRequest: &apiv2.MachineServiceUpdateRequest{ + UpdateMeta: &apiv2.UpdateMeta{ + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Uuid: testresources.Machine2().Uuid, + Project: testresources.Machine2().Allocation.Project, + Description: &testresources.Machine2().Allocation.Description, + Labels: &apiv2.UpdateLabels{ + Strategy: &apiv2.UpdateLabels_Replace{ + Replace: testresources.Machine2().Meta.Labels, + }, + }, + SshPublicKeys: []string{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.MachineServiceUpdateResponse{ + Machine: testresources.Machine2(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID LAST EVENT WHEN AGE HOSTNAME PROJECT SIZE IMAGE PARTITION RACK + 673fc473-63ca-4ea4-b9dd-b45cb2127a6fd 🛡 Phoned Home 1m 1m machine-2 f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c v1-medium-x86 Ubuntu 24.04 partition-2 rack-1 + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} From c982224cf9b375d89938699ea1f5188fbb54b1cd Mon Sep 17 00:00:00 2001 From: Stefan Majer Date: Fri, 14 Aug 2026 15:59:14 +0200 Subject: [PATCH 11/12] Pin metal-lib --- cmd/admin/v2/machine.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/admin/v2/machine.go b/cmd/admin/v2/machine.go index 2c08d68..9c970e5 100644 --- a/cmd/admin/v2/machine.go +++ b/cmd/admin/v2/machine.go @@ -503,7 +503,7 @@ func (c *machine) firewallSSH(ctx context.Context, args []string) (err error) { return err } if parsed.Is4() { - vpnopts = append(vpnopts, metalvpn.ConnectOptWithFirewallVPNIPAddress(ip)) + vpnopts = append(vpnopts, metalvpn.ConnectOptWithVpnIPAddress(ip)) } } } diff --git a/go.mod b/go.mod index d26466a..eb2fabe 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/metal-stack/api v0.4.4 - github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f + github.com/metal-stack/metal-lib v0.26.3 github.com/metal-stack/v v1.0.3 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 39a8f54..11b6c18 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,8 @@ github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg= github.com/metal-stack/api v0.4.4 h1:NwJKCFHnbmsU7DjHLtBmpdPuPniXc2wDabHjdpXk3qA= github.com/metal-stack/api v0.4.4/go.mod h1:E7f2GkKNSr4vBxhQwWrr/Mjnf4ctPqCX2dpDtXBqkBk= -github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f h1:zetg2RWnqvFWFT3vW2fSSSXxfJN8Qfm/3HWQzQ6P9i0= -github.com/metal-stack/metal-lib v0.26.3-0.20260814130818-94b999d88c4f/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= +github.com/metal-stack/metal-lib v0.26.3 h1:K5gLoD65m6p3l6qCPrfavIdvNdWfmF2QdXrvU2URaZs= +github.com/metal-stack/metal-lib v0.26.3/go.mod h1:cNXjPBs8SFnjqfBobuSbm5mDk6E/jS8PVeIOrV/7POE= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= From b88d02a62a9388e56229cf0dad86380cab259825 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Fri, 14 Aug 2026 16:14:51 +0200 Subject: [PATCH 12/12] Fix sorter nil pointers. --- cmd/sorters/machine.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/sorters/machine.go b/cmd/sorters/machine.go index 0683409..064ed1d 100644 --- a/cmd/sorters/machine.go +++ b/cmd/sorters/machine.go @@ -9,16 +9,16 @@ import ( func MachineSorter() *multisort.Sorter[*apiv2.Machine] { return multisort.New(multisort.FieldMap[*apiv2.Machine]{ "partition": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { - return multisort.Compare(a.Partition.Id, b.Partition.Id, descending) + return multisort.Compare(pointer.SafeDeref(a.Partition).Id, pointer.SafeDeref(b.Partition).Id, descending) }, "size": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { - return multisort.Compare(a.Size.Id, b.Size.Id, descending) + return multisort.Compare(pointer.SafeDeref(a.Size).Id, pointer.SafeDeref(b.Size).Id, descending) }, "uuid": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { return multisort.Compare(a.Uuid, b.Uuid, descending) }, "image": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { - return multisort.Compare(pointer.SafeDeref(a.Allocation).Image.Id, pointer.SafeDeref(b.Allocation).Image.Id, descending) + return multisort.Compare(pointer.SafeDeref(pointer.SafeDeref(a.Allocation).Image).Id, pointer.SafeDeref(pointer.SafeDeref(b.Allocation).Image).Id, descending) }, "rack": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { return multisort.Compare(a.Rack, b.Rack, descending) @@ -27,7 +27,11 @@ func MachineSorter() *multisort.Sorter[*apiv2.Machine] { return multisort.Compare(pointer.SafeDeref(a.Allocation).Project, pointer.SafeDeref(b.Allocation).Project, descending) }, "age": func(a, b *apiv2.Machine, descending bool) multisort.CompareResult { - return multisort.Compare(pointer.SafeDeref(a.Allocation).Meta.CreatedAt.AsTime().Unix(), pointer.SafeDeref(b.Allocation).Meta.CreatedAt.AsTime().Unix(), descending) + return multisort.Compare( + new(pointer.SafeDeref(pointer.SafeDeref(pointer.SafeDeref(a.Allocation).Meta).CreatedAt)).AsTime().Unix(), + new(pointer.SafeDeref(pointer.SafeDeref(pointer.SafeDeref(b.Allocation).Meta).CreatedAt)).AsTime().Unix(), + descending, + ) }, }, multisort.Keys{{ID: "partition"}, {ID: "size"}, {ID: "project"}, {ID: "uuid"}}) }