From 2c3bea64e52915d3257e82b58fa587f5717e928b Mon Sep 17 00:00:00 2001 From: Evgenii Orlov <4623930+OrlovEvgeny@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:45:19 +0200 Subject: [PATCH] feat: impl of functionality for renaming and updating machines (#91) --- .github/workflows/go-tests.yml | 4 +- Makefile | 11 +- cmd/uncloud/machine/add.go | 4 +- cmd/uncloud/machine/constants.go | 6 + cmd/uncloud/machine/init.go | 4 +- cmd/uncloud/machine/rename.go | 47 +++ cmd/uncloud/machine/root.go | 2 + cmd/uncloud/machine/update.go | 128 ++++++ internal/machine/api/pb/cluster.pb.go | 284 ++++++++++--- internal/machine/api/pb/cluster.proto | 15 + internal/machine/api/pb/cluster_grpc.pb.go | 38 ++ internal/machine/cluster/cluster.go | 83 ++++ internal/machine/store/store.go | 76 +++- pkg/client/machine.go | 31 ++ test/e2e/machine_test.go | 450 +++++++++++++++++++++ 15 files changed, 1121 insertions(+), 62 deletions(-) create mode 100644 cmd/uncloud/machine/constants.go create mode 100644 cmd/uncloud/machine/rename.go create mode 100644 cmd/uncloud/machine/update.go create mode 100644 test/e2e/machine_test.go diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index b1fd9e50..3ff372c6 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -37,7 +37,9 @@ jobs: (echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1) - name: Run tests - run: make test + run: | + make ucind-image + make test timeout-minutes: 10 check-protobuf: diff --git a/Makefile b/Makefile index 35469261..e88db1e9 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,11 @@ proto: protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \ --proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto +.PHONY: proto-mise +proto-mise: + mise exec -- protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + --proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto + .PHONY: corrosion-image corrosion-image: docker build -t "$(CORROSION_IMAGE)" --target corrosion . @@ -67,9 +72,13 @@ test: ifeq ($(TEST_NAME),) go test -count=1 -v ./... else - go test -count=1 -v -run ^$(TEST_NAME)$$ ./... + go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./... endif +.PHONY: test-e2e +test-e2e: + go test -race -count=1 -v ./test/e2e + .PHONY: test-clean test-clean: @CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \ diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index 31c81a87..a23ab2a0 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -59,7 +59,7 @@ func NewAddCommand() *cobra.Command { cmd.Flags().StringVar( &opts.publicIP, "public-ip", "auto", "Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+ - "blank '' or 'none' to disable ingress on this machine, or specify an IP address.", + fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone), ) cmd.Flags().StringVarP( &opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519", @@ -82,7 +82,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine cli.RemoteMachine, o switch opts.publicIP { case "auto": publicIP = &netip.Addr{} - case "", "none": + case "", PublicIPNone: publicIP = nil default: ip, err := netip.ParseAddr(opts.publicIP) diff --git a/cmd/uncloud/machine/constants.go b/cmd/uncloud/machine/constants.go new file mode 100644 index 00000000..cc221a41 --- /dev/null +++ b/cmd/uncloud/machine/constants.go @@ -0,0 +1,6 @@ +package machine + +const ( + // PublicIPNone is the value used to indicate removal of public IP + PublicIPNone = "none" +) diff --git a/cmd/uncloud/machine/init.go b/cmd/uncloud/machine/init.go index 238fb80d..3ab0dd3e 100644 --- a/cmd/uncloud/machine/init.go +++ b/cmd/uncloud/machine/init.go @@ -77,7 +77,7 @@ func NewInitCommand() *cobra.Command { cmd.Flags().StringVar( &opts.publicIP, "public-ip", "auto", "Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+ - "blank '' or 'none' to disable ingress on this machine, or specify an IP address.", + fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone), ) cmd.Flags().StringVarP( &opts.sshKey, "ssh-key", "i", "~/.ssh/id_ed25519", @@ -105,7 +105,7 @@ func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteM switch opts.publicIP { case "auto": publicIP = &netip.Addr{} - case "", "none": + case "", PublicIPNone: publicIP = nil default: ip, err := netip.ParseAddr(opts.publicIP) diff --git a/cmd/uncloud/machine/rename.go b/cmd/uncloud/machine/rename.go new file mode 100644 index 00000000..9a8a8407 --- /dev/null +++ b/cmd/uncloud/machine/rename.go @@ -0,0 +1,47 @@ +package machine + +import ( + "context" + "fmt" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/spf13/cobra" +) + +func NewRenameCommand() *cobra.Command { + var contextName string + cmd := &cobra.Command{ + Use: "rename OLD_NAME NEW_NAME", + Short: "Rename a machine in the cluster.", + Long: `Rename a machine in the cluster. + +This command changes the name of an existing machine while preserving all other +configuration including network settings, public IP, and cluster membership.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return rename(cmd.Context(), uncli, contextName, args[0], args[1]) + }, + } + cmd.Flags().StringVarP( + &contextName, "context", "c", "", + "Name of the cluster context. (default is the current context)", + ) + return cmd +} + +func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error { + client, err := uncli.ConnectCluster(ctx, contextName) + if err != nil { + return err + } + defer client.Close() + + machine, err := client.RenameMachine(ctx, oldName, newName) + if err != nil { + return fmt.Errorf("rename machine: %w", err) + } + + fmt.Printf("Machine %q renamed to %q (ID: %s)\n", oldName, machine.Name, machine.Id) + return nil +} diff --git a/cmd/uncloud/machine/root.go b/cmd/uncloud/machine/root.go index 32dba67f..16e5b360 100644 --- a/cmd/uncloud/machine/root.go +++ b/cmd/uncloud/machine/root.go @@ -14,7 +14,9 @@ func NewRootCommand() *cobra.Command { NewAddCommand(), NewInitCommand(), NewListCommand(), + NewRenameCommand(), NewRmCommand(), + NewUpdateCommand(), NewTokenCommand(), ) return cmd diff --git a/cmd/uncloud/machine/update.go b/cmd/uncloud/machine/update.go new file mode 100644 index 00000000..7349a1c8 --- /dev/null +++ b/cmd/uncloud/machine/update.go @@ -0,0 +1,128 @@ +package machine + +import ( + "context" + "fmt" + "net/netip" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/spf13/cobra" +) + +type updateOptions struct { + name string + publicIP string + context string +} + +func NewUpdateCommand() *cobra.Command { + opts := updateOptions{} + cmd := &cobra.Command{ + Use: "update", + Short: "Update machine configuration in the cluster.", + Long: `Update machine configuration in the cluster. + +This command allows setting various machine properties including: +- Machine name (--name) +- Public IP address (--public-ip) + +At least one flag must be specified to perform an update operation.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return update(cmd.Context(), uncli, cmd, opts, args[0]) + }, + } + + cmd.Flags().StringVar( + &opts.name, "name", "", + "New name for the machine", + ) + cmd.Flags().StringVar( + &opts.publicIP, "public-ip", "", + fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone), + ) + cmd.Flags().StringVarP( + &opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)", + ) + + return cmd +} + +func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error { + // Check if at least one flag was explicitly set + if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") { + return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)") + } + + client, err := uncli.ConnectCluster(ctx, opts.context) + if err != nil { + return err + } + defer client.Close() + + // First, resolve the machine to get its ID + machine, err := client.InspectMachine(ctx, machineNameOrID) + if err != nil { + return fmt.Errorf("find machine: %w", err) + } + + // Build the update request + req := &pb.UpdateMachineRequest{ + MachineId: machine.Machine.Id, + } + + if opts.name != "" { + req.Name = &opts.name + } + + // Check if --public-ip flag was explicitly provided + if cmd.Flags().Changed("public-ip") { + if opts.publicIP == "" || opts.publicIP == PublicIPNone { + req.PublicIp = &pb.IP{} // Empty IP to signal removal + } else { + // Parse and validate the public IP + ip, err := netip.ParseAddr(opts.publicIP) + if err != nil { + return fmt.Errorf("invalid public IP address %q: %w", opts.publicIP, err) + } + req.PublicIp = pb.NewIP(ip) + } + } + + // Perform the update operation + updatedMachine, err := client.UpdateMachine(ctx, req) + if err != nil { + return fmt.Errorf("update machine: %w", err) + } + + // Report what was changed + changes := make([]string, 0) + if opts.name != "" { + changes = append(changes, fmt.Sprintf("name: %q -> %q", machine.Machine.Name, updatedMachine.Name)) + } + if cmd.Flags().Changed("public-ip") { + oldIP := PublicIPNone + if machine.Machine.PublicIp != nil { + if addr, err := machine.Machine.PublicIp.ToAddr(); err == nil { + oldIP = addr.String() + } + } + newIP := PublicIPNone + if updatedMachine.PublicIp != nil { + if addr, err := updatedMachine.PublicIp.ToAddr(); err == nil { + newIP = addr.String() + } + } + changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP)) + } + + fmt.Printf("Machine %q (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id) + for _, change := range changes { + fmt.Printf(" %s\n", change) + } + + return nil +} diff --git a/internal/machine/api/pb/cluster.pb.go b/internal/machine/api/pb/cluster.pb.go index 2ceb09e6..d0d33752 100644 --- a/internal/machine/api/pb/cluster.pb.go +++ b/internal/machine/api/pb/cluster.pb.go @@ -590,6 +590,126 @@ func (x *DNSRecord) GetValues() []string { return nil } +type UpdateMachineRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Machine to update + MachineId string `protobuf:"bytes,1,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"` + // Updated machine information + Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` + PublicIp *IP `protobuf:"bytes,3,opt,name=public_ip,json=publicIp,proto3,oneof" json:"public_ip,omitempty"` + Endpoints []*IPPort `protobuf:"bytes,4,rep,name=endpoints,proto3" json:"endpoints,omitempty"` +} + +func (x *UpdateMachineRequest) Reset() { + *x = UpdateMachineRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateMachineRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateMachineRequest) ProtoMessage() {} + +func (x *UpdateMachineRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateMachineRequest.ProtoReflect.Descriptor instead. +func (*UpdateMachineRequest) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{9} +} + +func (x *UpdateMachineRequest) GetMachineId() string { + if x != nil { + return x.MachineId + } + return "" +} + +func (x *UpdateMachineRequest) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *UpdateMachineRequest) GetPublicIp() *IP { + if x != nil { + return x.PublicIp + } + return nil +} + +func (x *UpdateMachineRequest) GetEndpoints() []*IPPort { + if x != nil { + return x.Endpoints + } + return nil +} + +type UpdateMachineResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Machine *MachineInfo `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"` +} + +func (x *UpdateMachineResponse) Reset() { + *x = UpdateMachineResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateMachineResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateMachineResponse) ProtoMessage() {} + +func (x *UpdateMachineResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_cluster_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateMachineResponse.ProtoReflect.Descriptor instead. +func (*UpdateMachineResponse) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_cluster_proto_rawDescGZIP(), []int{10} +} + +func (x *UpdateMachineResponse) GetMachine() *MachineInfo { + if x != nil { + return x.Machine + } + return nil +} + var File_internal_machine_api_pb_cluster_proto protoreflect.FileDescriptor var file_internal_machine_api_pb_cluster_proto_rawDesc = []byte{ @@ -654,35 +774,56 @@ var file_internal_machine_api_pb_cluster_proto_rawDesc = []byte{ 0x22, 0x2e, 0x0a, 0x0a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x05, 0x0a, 0x01, 0x41, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x41, 0x41, 0x41, 0x10, 0x02, - 0x32, 0x86, 0x03, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x3d, 0x0a, 0x0a, - 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, - 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x0c, 0x4c, - 0x69, 0x73, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, - 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, - 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x30, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x34, 0x0a, 0x0d, 0x52, 0x65, 0x6c, - 0x65, 0x61, 0x73, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, - 0x58, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, + 0x22, 0xbb, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x63, + 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, + 0x01, 0x12, 0x29, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x48, 0x01, 0x52, + 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x09, + 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09, 0x65, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x22, 0x43, + 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, + 0x69, 0x6e, 0x65, 0x32, 0xce, 0x03, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, + 0x3d, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x64, 0x64, 0x4d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, + 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x16, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x46, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x0d, 0x52, 0x65, 0x73, + 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x19, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x12, 0x30, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x34, 0x0a, 0x0d, 0x52, 0x65, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x44, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0b, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x58, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, - 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x12, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, + 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -698,7 +839,7 @@ func file_internal_machine_api_pb_cluster_proto_rawDescGZIP() []byte { } var file_internal_machine_api_pb_cluster_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_internal_machine_api_pb_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_internal_machine_api_pb_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_internal_machine_api_pb_cluster_proto_goTypes = []any{ (MachineMember_MembershipState)(0), // 0: api.MachineMember.MembershipState (DNSRecord_RecordType)(0), // 1: api.DNSRecord.RecordType @@ -711,38 +852,46 @@ var file_internal_machine_api_pb_cluster_proto_goTypes = []any{ (*CreateDomainRecordsRequest)(nil), // 8: api.CreateDomainRecordsRequest (*CreateDomainRecordsResponse)(nil), // 9: api.CreateDomainRecordsResponse (*DNSRecord)(nil), // 10: api.DNSRecord - (*NetworkConfig)(nil), // 11: api.NetworkConfig - (*IP)(nil), // 12: api.IP - (*MachineInfo)(nil), // 13: api.MachineInfo - (*emptypb.Empty)(nil), // 14: google.protobuf.Empty + (*UpdateMachineRequest)(nil), // 11: api.UpdateMachineRequest + (*UpdateMachineResponse)(nil), // 12: api.UpdateMachineResponse + (*NetworkConfig)(nil), // 13: api.NetworkConfig + (*IP)(nil), // 14: api.IP + (*MachineInfo)(nil), // 15: api.MachineInfo + (*IPPort)(nil), // 16: api.IPPort + (*emptypb.Empty)(nil), // 17: google.protobuf.Empty } var file_internal_machine_api_pb_cluster_proto_depIdxs = []int32{ - 11, // 0: api.AddMachineRequest.network:type_name -> api.NetworkConfig - 12, // 1: api.AddMachineRequest.public_ip:type_name -> api.IP - 13, // 2: api.AddMachineResponse.machine:type_name -> api.MachineInfo - 13, // 3: api.MachineMember.machine:type_name -> api.MachineInfo + 13, // 0: api.AddMachineRequest.network:type_name -> api.NetworkConfig + 14, // 1: api.AddMachineRequest.public_ip:type_name -> api.IP + 15, // 2: api.AddMachineResponse.machine:type_name -> api.MachineInfo + 15, // 3: api.MachineMember.machine:type_name -> api.MachineInfo 0, // 4: api.MachineMember.state:type_name -> api.MachineMember.MembershipState 4, // 5: api.ListMachinesResponse.machines:type_name -> api.MachineMember 10, // 6: api.CreateDomainRecordsRequest.records:type_name -> api.DNSRecord 10, // 7: api.CreateDomainRecordsResponse.records:type_name -> api.DNSRecord 1, // 8: api.DNSRecord.type:type_name -> api.DNSRecord.RecordType - 2, // 9: api.Cluster.AddMachine:input_type -> api.AddMachineRequest - 14, // 10: api.Cluster.ListMachines:input_type -> google.protobuf.Empty - 7, // 11: api.Cluster.ReserveDomain:input_type -> api.ReserveDomainRequest - 14, // 12: api.Cluster.GetDomain:input_type -> google.protobuf.Empty - 14, // 13: api.Cluster.ReleaseDomain:input_type -> google.protobuf.Empty - 8, // 14: api.Cluster.CreateDomainRecords:input_type -> api.CreateDomainRecordsRequest - 3, // 15: api.Cluster.AddMachine:output_type -> api.AddMachineResponse - 5, // 16: api.Cluster.ListMachines:output_type -> api.ListMachinesResponse - 6, // 17: api.Cluster.ReserveDomain:output_type -> api.Domain - 6, // 18: api.Cluster.GetDomain:output_type -> api.Domain - 6, // 19: api.Cluster.ReleaseDomain:output_type -> api.Domain - 9, // 20: api.Cluster.CreateDomainRecords:output_type -> api.CreateDomainRecordsResponse - 15, // [15:21] is the sub-list for method output_type - 9, // [9:15] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 14, // 9: api.UpdateMachineRequest.public_ip:type_name -> api.IP + 16, // 10: api.UpdateMachineRequest.endpoints:type_name -> api.IPPort + 15, // 11: api.UpdateMachineResponse.machine:type_name -> api.MachineInfo + 2, // 12: api.Cluster.AddMachine:input_type -> api.AddMachineRequest + 17, // 13: api.Cluster.ListMachines:input_type -> google.protobuf.Empty + 11, // 14: api.Cluster.UpdateMachine:input_type -> api.UpdateMachineRequest + 7, // 15: api.Cluster.ReserveDomain:input_type -> api.ReserveDomainRequest + 17, // 16: api.Cluster.GetDomain:input_type -> google.protobuf.Empty + 17, // 17: api.Cluster.ReleaseDomain:input_type -> google.protobuf.Empty + 8, // 18: api.Cluster.CreateDomainRecords:input_type -> api.CreateDomainRecordsRequest + 3, // 19: api.Cluster.AddMachine:output_type -> api.AddMachineResponse + 5, // 20: api.Cluster.ListMachines:output_type -> api.ListMachinesResponse + 12, // 21: api.Cluster.UpdateMachine:output_type -> api.UpdateMachineResponse + 6, // 22: api.Cluster.ReserveDomain:output_type -> api.Domain + 6, // 23: api.Cluster.GetDomain:output_type -> api.Domain + 6, // 24: api.Cluster.ReleaseDomain:output_type -> api.Domain + 9, // 25: api.Cluster.CreateDomainRecords:output_type -> api.CreateDomainRecordsResponse + 19, // [19:26] is the sub-list for method output_type + 12, // [12:19] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_internal_machine_api_pb_cluster_proto_init() } @@ -861,14 +1010,39 @@ func file_internal_machine_api_pb_cluster_proto_init() { return nil } } + file_internal_machine_api_pb_cluster_proto_msgTypes[9].Exporter = func(v any, i int) any { + switch v := v.(*UpdateMachineRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_machine_api_pb_cluster_proto_msgTypes[10].Exporter = func(v any, i int) any { + switch v := v.(*UpdateMachineResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } + file_internal_machine_api_pb_cluster_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_internal_machine_api_pb_cluster_proto_rawDesc, NumEnums: 2, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/machine/api/pb/cluster.proto b/internal/machine/api/pb/cluster.proto index 7ad5002b..c937b7b9 100644 --- a/internal/machine/api/pb/cluster.proto +++ b/internal/machine/api/pb/cluster.proto @@ -11,6 +11,7 @@ import "internal/machine/api/pb/machine.proto"; service Cluster { rpc AddMachine(AddMachineRequest) returns (AddMachineResponse); rpc ListMachines(google.protobuf.Empty) returns (ListMachinesResponse); + rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse); rpc ReserveDomain(ReserveDomainRequest) returns (Domain); rpc GetDomain(google.protobuf.Empty) returns (Domain); @@ -76,3 +77,17 @@ message DNSRecord { RecordType type = 2; repeated string values = 3; } + +message UpdateMachineRequest { + // Machine to update + string machine_id = 1; + + // Updated machine information + optional string name = 2; + optional IP public_ip = 3; + repeated IPPort endpoints = 4; +} + +message UpdateMachineResponse { + MachineInfo machine = 1; +} diff --git a/internal/machine/api/pb/cluster_grpc.pb.go b/internal/machine/api/pb/cluster_grpc.pb.go index 2362075a..b88f418f 100644 --- a/internal/machine/api/pb/cluster_grpc.pb.go +++ b/internal/machine/api/pb/cluster_grpc.pb.go @@ -22,6 +22,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( Cluster_AddMachine_FullMethodName = "/api.Cluster/AddMachine" Cluster_ListMachines_FullMethodName = "/api.Cluster/ListMachines" + Cluster_UpdateMachine_FullMethodName = "/api.Cluster/UpdateMachine" Cluster_ReserveDomain_FullMethodName = "/api.Cluster/ReserveDomain" Cluster_GetDomain_FullMethodName = "/api.Cluster/GetDomain" Cluster_ReleaseDomain_FullMethodName = "/api.Cluster/ReleaseDomain" @@ -34,6 +35,7 @@ const ( type ClusterClient interface { AddMachine(ctx context.Context, in *AddMachineRequest, opts ...grpc.CallOption) (*AddMachineResponse, error) ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListMachinesResponse, error) + UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error) ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error) GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) ReleaseDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) @@ -68,6 +70,16 @@ func (c *clusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opt return out, nil } +func (c *clusterClient) UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateMachineResponse) + err := c.cc.Invoke(ctx, Cluster_UpdateMachine_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *clusterClient) ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Domain) @@ -114,6 +126,7 @@ func (c *clusterClient) CreateDomainRecords(ctx context.Context, in *CreateDomai type ClusterServer interface { AddMachine(context.Context, *AddMachineRequest) (*AddMachineResponse, error) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) + UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error) ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error) GetDomain(context.Context, *emptypb.Empty) (*Domain, error) ReleaseDomain(context.Context, *emptypb.Empty) (*Domain, error) @@ -134,6 +147,9 @@ func (UnimplementedClusterServer) AddMachine(context.Context, *AddMachineRequest func (UnimplementedClusterServer) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListMachines not implemented") } +func (UnimplementedClusterServer) UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateMachine not implemented") +} func (UnimplementedClusterServer) ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error) { return nil, status.Errorf(codes.Unimplemented, "method ReserveDomain not implemented") } @@ -203,6 +219,24 @@ func _Cluster_ListMachines_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _Cluster_UpdateMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateMachineRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClusterServer).UpdateMachine(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Cluster_UpdateMachine_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClusterServer).UpdateMachine(ctx, req.(*UpdateMachineRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Cluster_ReserveDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ReserveDomainRequest) if err := dec(in); err != nil { @@ -290,6 +324,10 @@ var Cluster_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListMachines", Handler: _Cluster_ListMachines_Handler, }, + { + MethodName: "UpdateMachine", + Handler: _Cluster_UpdateMachine_Handler, + }, { MethodName: "ReserveDomain", Handler: _Cluster_ReserveDomain_Handler, diff --git a/internal/machine/cluster/cluster.go b/internal/machine/cluster/cluster.go index d2e5c1e8..719cca37 100644 --- a/internal/machine/cluster/cluster.go +++ b/internal/machine/cluster/cluster.go @@ -198,6 +198,89 @@ func (c *Cluster) AddMachine(ctx context.Context, req *pb.AddMachineRequest) (*p return resp, nil } +// UpdateMachine updates machine configuration in the cluster. +func (c *Cluster) UpdateMachine(ctx context.Context, req *pb.UpdateMachineRequest) (*pb.UpdateMachineResponse, error) { + if err := c.checkInitialised(ctx); err != nil { + return nil, err + } + + if req.MachineId == "" { + return nil, status.Error(codes.InvalidArgument, "machine_id not set") + } + + // Get the current machine info + currentMachine, err := c.store.GetMachine(ctx, req.MachineId) + if err != nil { + if errors.Is(err, store.ErrMachineNotFound) { + return nil, status.Errorf(codes.NotFound, "machine not found: %s", req.MachineId) + } + return nil, status.Errorf(codes.Internal, "failed to get machine: %v", err) + } + + // Create a copy of the current machine for updating + updatedMachine := &pb.MachineInfo{ + Id: currentMachine.Id, + Name: currentMachine.Name, + Network: currentMachine.Network, + PublicIp: currentMachine.PublicIp, + } + + // Apply updates from the request + if req.Name != nil { + // Check for empty name + if *req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "machine name cannot be empty") + } + // Check for duplicate names (excluding the current machine) + if *req.Name != currentMachine.Name { + machines, err := c.store.ListMachines(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "list machines: %v", err) + } + for _, m := range machines { + if m.Id != req.MachineId && m.Name == *req.Name { + return nil, status.Errorf(codes.AlreadyExists, "machine with name %q already exists", *req.Name) + } + } + } + updatedMachine.Name = *req.Name + } + if req.PublicIp != nil { + // Check if this is an empty IP (used to signal removal) + if len(req.PublicIp.Ip) == 0 { + // User wants to remove public IP + updatedMachine.PublicIp = nil + } else { + // Validate and set the new IP + ip, err := req.PublicIp.ToAddr() + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid public IP: %v", err) + } + if !ip.IsValid() { + return nil, status.Error(codes.InvalidArgument, "invalid public IP") + } + updatedMachine.PublicIp = req.PublicIp + } + } + if req.Endpoints != nil { + updatedMachine.Network.Endpoints = req.Endpoints + } + + // Update the machine in the store + if err = c.store.UpdateMachine(ctx, updatedMachine); err != nil { + if errors.Is(err, store.ErrMachineNotFound) { + return nil, status.Errorf(codes.NotFound, "machine not found: %s", req.MachineId) + } + return nil, status.Errorf(codes.Internal, "update machine: %v", err) + } + + slog.Info("Machine configuration updated in the cluster.", + "id", updatedMachine.Id, "name", updatedMachine.Name) + + resp := &pb.UpdateMachineResponse{Machine: updatedMachine} + return resp, nil +} + // ListMachines lists all machines in the cluster including their membership states. func (c *Cluster) ListMachines(ctx context.Context, _ *emptypb.Empty) (*pb.ListMachinesResponse, error) { if err := c.checkInitialised(ctx); err != nil { diff --git a/internal/machine/store/store.go b/internal/machine/store/store.go index 785eb854..45b0d8fb 100644 --- a/internal/machine/store/store.go +++ b/internal/machine/store/store.go @@ -16,7 +16,8 @@ var ( //go:embed schema.sql Schema string - ErrKeyNotFound = errors.New("key not found") + ErrKeyNotFound = errors.New("key not found") + ErrMachineNotFound = errors.New("machine not found") ) // Store is a cluster store backed by a distributed Corrosion database. @@ -67,6 +68,79 @@ func (s *Store) CreateMachine(ctx context.Context, m *pb.MachineInfo) error { return nil } +func (s *Store) UpdateMachine(ctx context.Context, m *pb.MachineInfo) error { + if m == nil { + return fmt.Errorf("machine info cannot be nil") + } + if m.Id == "" { + return fmt.Errorf("machine ID cannot be empty") + } + + mJSON, err := protojson.Marshal(m) + if err != nil { + return fmt.Errorf("marshal machine info: %w", err) + } + + result, err := s.corro.ExecContext(ctx, "UPDATE machines SET info = ? WHERE id = ?", string(mJSON), m.Id) + if err != nil { + return fmt.Errorf("update machine: %w", err) + } + + // Check if machine exists + if result.RowsAffected == 0 { + return fmt.Errorf("%w: %s", ErrMachineNotFound, m.Id) + } + + return nil +} + +func (s *Store) GetMachine(ctx context.Context, machineID string) (*pb.MachineInfo, error) { + if machineID == "" { + return nil, fmt.Errorf("machine ID cannot be empty") + } + + rows, err := s.corro.QueryContext(ctx, "SELECT info FROM machines WHERE id = ?", machineID) + if err != nil { + return nil, fmt.Errorf("query machine: %w", err) + } + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("query error: %w", err) + } + return nil, fmt.Errorf("%w: %s", ErrMachineNotFound, machineID) + } + + var mJSON string + if err = rows.Scan(&mJSON); err != nil { + return nil, fmt.Errorf("scan machine info: %w", err) + } + + if mJSON == "" { + return nil, fmt.Errorf("machine info is empty for id %s", machineID) + } + + protojsonParser := protojson.UnmarshalOptions{DiscardUnknown: true} + var m pb.MachineInfo + if err = protojsonParser.Unmarshal([]byte(mJSON), &m); err != nil { + return nil, fmt.Errorf("unmarshal machine info for id %s: %w", machineID, err) + } + + // Validate the unmarshaled data. just in case + if m.Id != machineID { + return nil, fmt.Errorf("machine ID mismatch: expected %s, got %s", machineID, m.Id) + } + + if m.Network != nil { + if err = m.Network.Validate(); err != nil { + return nil, fmt.Errorf("invalid network configuration for machine %s: %w", m.Id, err) + } + } + + return &m, nil +} + func (s *Store) ListMachines(ctx context.Context) ([]*pb.MachineInfo, error) { rows, err := s.corro.QueryContext(ctx, "SELECT info FROM machines ORDER BY name") if err != nil { diff --git a/pkg/client/machine.go b/pkg/client/machine.go index db4638b4..b58549b7 100644 --- a/pkg/client/machine.go +++ b/pkg/client/machine.go @@ -6,6 +6,8 @@ import ( "github.com/psviderski/uncloud/internal/machine/api/pb" "github.com/psviderski/uncloud/pkg/api" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" ) @@ -46,6 +48,35 @@ func (cli *Client) ListMachines(ctx context.Context, filter *api.MachineFilter) return machines, nil } +// UpdateMachine updates machine configuration in the cluster. +func (cli *Client) UpdateMachine(ctx context.Context, req *pb.UpdateMachineRequest) (*pb.MachineInfo, error) { + resp, err := cli.ClusterClient.UpdateMachine(ctx, req) + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { + return nil, api.ErrNotFound + } + return nil, err + } + return resp.Machine, nil +} + +// RenameMachine renames an existing machine in the cluster. +func (cli *Client) RenameMachine(ctx context.Context, nameOrID, newName string) (*pb.MachineInfo, error) { + // First, resolve the machine to get its ID + machine, err := cli.InspectMachine(ctx, nameOrID) + if err != nil { + return nil, err + } + + // Update the machine with the new name + req := &pb.UpdateMachineRequest{ + MachineId: machine.Machine.Id, + Name: &newName, + } + + return cli.UpdateMachine(ctx, req) +} + func MachineMatchesFilter(machine *pb.MachineMember, filter *api.MachineFilter) bool { if filter == nil { return true diff --git a/test/e2e/machine_test.go b/test/e2e/machine_test.go new file mode 100644 index 00000000..156601b6 --- /dev/null +++ b/test/e2e/machine_test.go @@ -0,0 +1,450 @@ +package e2e + +import ( + "context" + "errors" + "testing" + + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/psviderski/uncloud/internal/ucind" + "github.com/psviderski/uncloud/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMachineRename(t *testing.T) { + name := "ucind-test.machine-rename" + ctx := context.Background() + c, _ := createTestCluster(t, name, ucind.CreateClusterOptions{Machines: 3}, true) + + cli, err := c.Machines[0].Connect(ctx) + require.NoError(t, err) + defer cli.Close() + + t.Run("rename machine by name", func(t *testing.T) { + // Get initial machine state + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + // Select the second machine to rename + originalMachine := machines[1] + originalName := originalMachine.Machine.Name + // nolint:goconst + newName := "renamed-machine-1" + + // Rename the machine + updatedMachine, err := cli.RenameMachine(ctx, originalName, newName) + require.NoError(t, err) + assert.Equal(t, newName, updatedMachine.Name) + assert.Equal(t, originalMachine.Machine.Id, updatedMachine.Id) + + // Verify the machine list reflects the change + machines, err = cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + // Find the renamed machine + var found bool + for _, m := range machines { + if m.Machine.Id == originalMachine.Machine.Id { + assert.Equal(t, newName, m.Machine.Name) + found = true + } else { + // Ensure other machines are unaffected + assert.NotEqual(t, newName, m.Machine.Name) + } + } + assert.True(t, found, "Renamed machine should be in the list") + + // Verify we can inspect the machine by its new name + inspectedMachine, err := cli.InspectMachine(ctx, newName) + require.NoError(t, err) + assert.Equal(t, newName, inspectedMachine.Machine.Name) + assert.Equal(t, originalMachine.Machine.Id, inspectedMachine.Machine.Id) + + // Verify the old name no longer works + _, err = cli.InspectMachine(ctx, originalName) + assert.ErrorIs(t, err, api.ErrNotFound) + }) + + t.Run("rename machine by ID", func(t *testing.T) { + // Get the third machine + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + // Find a machine that hasn't been renamed yet + var targetMachine *pb.MachineMember + for _, m := range machines { + if m.Machine.Name != "renamed-machine-1" { + targetMachine = m + break + } + } + require.NotNil(t, targetMachine) + + originalName := targetMachine.Machine.Name + machineID := targetMachine.Machine.Id + newName := "renamed-machine-2" + + // Rename using ID instead of name + updatedMachine, err := cli.RenameMachine(ctx, machineID, newName) + require.NoError(t, err) + assert.Equal(t, newName, updatedMachine.Name) + assert.Equal(t, machineID, updatedMachine.Id) + + // Verify the rename was successful + inspectedMachine, err := cli.InspectMachine(ctx, newName) + require.NoError(t, err) + assert.Equal(t, newName, inspectedMachine.Machine.Name) + assert.Equal(t, machineID, inspectedMachine.Machine.Id) + + // Verify the old name no longer works + _, err = cli.InspectMachine(ctx, originalName) + assert.ErrorIs(t, err, api.ErrNotFound) + }) + + t.Run("rename non-existent machine", func(t *testing.T) { + // Try to rename a machine that doesn't exist + _, err := cli.RenameMachine(ctx, "non-existent-machine", "new-name") + assert.ErrorIs(t, err, api.ErrNotFound) + + // Try with a non-existent ID + _, err = cli.RenameMachine(ctx, "non-existent-id-12345", "new-name") + assert.ErrorIs(t, err, api.ErrNotFound) + }) + + t.Run("rename to existing name", func(t *testing.T) { + // Get current machines + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + // Try to rename machine 0 to the name of machine 1 + machine0Name := machines[0].Machine.Name + machine1Name := machines[1].Machine.Name + + // This should fail because the name is already taken + _, err = cli.RenameMachine(ctx, machine0Name, machine1Name) + assert.Error(t, err) + }) + + t.Run("rename with empty name", func(t *testing.T) { + // Get a machine to rename + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + machineName := machines[0].Machine.Name + + // Try to rename with empty string + _, err = cli.RenameMachine(ctx, machineName, "") + assert.Error(t, err) + }) + + t.Run("service continuity after rename", func(t *testing.T) { + // Deploy a service on a specific machine + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + + // Find a machine that hasn't been renamed to test with + var targetMachine *pb.MachineMember + for _, m := range machines { + if m.Machine.Name != "renamed-machine-1" && m.Machine.Name != "renamed-machine-2" { + targetMachine = m + break + } + } + require.NotNil(t, targetMachine) + + originalMachineName := targetMachine.Machine.Name + serviceName := "test-service-rename-continuity" + + // Create a service on the specific machine + spec := api.ServiceSpec{ + Name: serviceName, + Mode: api.ServiceModeGlobal, + Container: api.ContainerSpec{ + Image: "portainer/pause:latest", + }, + Placement: api.Placement{ + Machines: []string{originalMachineName}, + }, + } + + _, err = cli.RunService(ctx, spec) + require.NoError(t, err) + + t.Cleanup(func() { + err := cli.RemoveService(ctx, serviceName) + if err != nil && !errors.Is(err, api.ErrNotFound) { + assert.NoError(t, err) + } + }) + + // Verify service is running on the machine + svc, err := cli.InspectService(ctx, serviceName) + require.NoError(t, err) + assert.Len(t, svc.Containers, 1) + assert.Equal(t, targetMachine.Machine.Id, svc.Containers[0].MachineID) + + // Rename the machine + newMachineName := "renamed-for-service-test" + _, err = cli.RenameMachine(ctx, originalMachineName, newMachineName) + require.NoError(t, err) + + // Verify service is still running on the renamed machine + svc, err = cli.InspectService(ctx, serviceName) + require.NoError(t, err) + assert.Len(t, svc.Containers, 1) + assert.Equal(t, targetMachine.Machine.Id, svc.Containers[0].MachineID) + + // The service spec's placement still references the old name, + // but the service should continue to run on the same machine id + }) +} + +func TestUpdateMachine(t *testing.T) { + name := "ucind-test.machine-update" + ctx := context.Background() + c, _ := createTestCluster(t, name, ucind.CreateClusterOptions{Machines: 3}, true) + + cli, err := c.Machines[0].Connect(ctx) + require.NoError(t, err) + defer cli.Close() + + t.Run("update machine name", func(t *testing.T) { + // Get initial machine state + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + // Select a machine to update + targetMachine := machines[1] + originalName := targetMachine.Machine.Name + //TODO: From my point of view, the most correct option is to use exclusions.rules list for `_test\.go$` files + // nolint:goconst + newName := "updated-machine-name" + + // Update the machine name using UpdateMachine directly + req := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + Name: &newName, + } + updatedMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + assert.Equal(t, newName, updatedMachine.Name) + assert.Equal(t, targetMachine.Machine.Id, updatedMachine.Id) + + // Verify the change persisted + inspected, err := cli.InspectMachine(ctx, updatedMachine.Id) + require.NoError(t, err) + assert.Equal(t, newName, inspected.Machine.Name) + + // Verify old name no longer works + _, err = cli.InspectMachine(ctx, originalName) + assert.ErrorIs(t, err, api.ErrNotFound) + }) + + t.Run("update machine public IP", func(t *testing.T) { + // Get a machine to update + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + + // Find a machine that hasn't been renamed + var targetMachine *pb.MachineMember + for _, m := range machines { + if m.Machine.Name != "updated-machine-name" { + targetMachine = m + break + } + } + require.NotNil(t, targetMachine) + + // Create a new public IP (must be a valid public IP address) + newPublicIP := &pb.IP{ + Ip: []byte{8, 8, 8, 8}, + } + + // Update the public IP + req := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + PublicIp: newPublicIP, + } + updatedMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + assert.Equal(t, newPublicIP.Ip, updatedMachine.PublicIp.Ip) + + // Verify the change persisted + inspected, err := cli.InspectMachine(ctx, targetMachine.Machine.Id) + require.NoError(t, err) + assert.Equal(t, newPublicIP.Ip, inspected.Machine.PublicIp.Ip) + }) + + t.Run("remove machine public IP", func(t *testing.T) { + // Get machines + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.True(t, len(machines) > 0, "Need at least one machine") + + // First, set a public IP on a machine + targetMachine := machines[0] + setIPReq := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + PublicIp: &pb.IP{Ip: []byte{192, 0, 2, 1}}, // TEST-NET-1 address + } + updatedMachine, err := cli.UpdateMachine(ctx, setIPReq) + require.NoError(t, err) + require.NotNil(t, updatedMachine.PublicIp) + + // Now test removing the public IP + + // Remove the public IP by setting it to empty + emptyIP := &pb.IP{} + req := &pb.UpdateMachineRequest{ + MachineId: updatedMachine.Id, + PublicIp: emptyIP, + } + removedIPMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + assert.Nil(t, removedIPMachine.PublicIp) + + // Verify the change persisted + inspected, err := cli.InspectMachine(ctx, updatedMachine.Id) + require.NoError(t, err) + assert.Nil(t, inspected.Machine.PublicIp) + }) + + t.Run("update machine endpoints", func(t *testing.T) { + // Get a machine to update + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + + var targetMachine *pb.MachineMember + for _, m := range machines { + targetMachine = m + break + } + require.NotNil(t, targetMachine) + + newEndpoints := []*pb.IPPort{ + { + Ip: &pb.IP{Ip: []byte{10, 0, 0, 10}}, + Port: 8080, + }, + { + Ip: &pb.IP{Ip: []byte{10, 0, 0, 10}}, + Port: 8443, + }, + } + + req := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + Endpoints: newEndpoints, + } + updatedMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + assert.Equal(t, len(newEndpoints), len(updatedMachine.Network.Endpoints)) + + // Verify endpoints were updated + for i, endpoint := range updatedMachine.Network.Endpoints { + assert.Equal(t, newEndpoints[i].Ip.Ip, endpoint.Ip.Ip) + assert.Equal(t, newEndpoints[i].Port, endpoint.Port) + } + + // Verify other network fields remain unchanged + assert.Equal(t, targetMachine.Machine.Network.Subnet.Ip.Ip, updatedMachine.Network.Subnet.Ip.Ip) + assert.Equal(t, targetMachine.Machine.Network.Subnet.Bits, updatedMachine.Network.Subnet.Bits) + assert.Equal(t, targetMachine.Machine.Network.ManagementIp.Ip, updatedMachine.Network.ManagementIp.Ip) + assert.Equal(t, targetMachine.Machine.Network.PublicKey, updatedMachine.Network.PublicKey) + }) + + t.Run("update multiple fields simultaneously", func(t *testing.T) { + // Get a machine to update + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + + var targetMachine *pb.MachineMember + for _, m := range machines { + if m.Machine.Name != "updated-machine-name" { + targetMachine = m + break + } + } + require.NotNil(t, targetMachine) + + // Update both name and public IP + newName := "multi-update-machine" + newPublicIP := &pb.IP{ + Ip: []byte{1, 1, 1, 1}, + } + + req := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + Name: &newName, + PublicIp: newPublicIP, + } + updatedMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + assert.Equal(t, newName, updatedMachine.Name) + assert.Equal(t, newPublicIP.Ip, updatedMachine.PublicIp.Ip) + + // Verify both changes persisted + inspected, err := cli.InspectMachine(ctx, updatedMachine.Id) + require.NoError(t, err) + assert.Equal(t, newName, inspected.Machine.Name) + assert.Equal(t, newPublicIP.Ip, inspected.Machine.PublicIp.Ip) + }) + + t.Run("update non-existent machine", func(t *testing.T) { + // Try to update properties on a machine that doesn't exist + nonExistentName := "should-be-updated" + req := &pb.UpdateMachineRequest{ + MachineId: "non-existent-machine-id", + Name: &nonExistentName, + } + _, err := cli.UpdateMachine(ctx, req) + assert.Error(t, err) + }) + + t.Run("update to duplicate name", func(t *testing.T) { + // Get two machines + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + require.Len(t, machines, 3) + + machine1 := machines[0] + machine2 := machines[1] + + // Try to update machine2 with machine1's name + req := &pb.UpdateMachineRequest{ + MachineId: machine2.Machine.Id, + Name: &machine1.Machine.Name, + } + _, err = cli.UpdateMachine(ctx, req) + assert.Error(t, err) + }) + + t.Run("update with empty request", func(t *testing.T) { + // Get a machine + machines, err := cli.ListMachines(ctx, nil) + require.NoError(t, err) + + targetMachine := machines[0] + + // Update with no fields set (should be a no-op) + req := &pb.UpdateMachineRequest{ + MachineId: targetMachine.Machine.Id, + } + updatedMachine, err := cli.UpdateMachine(ctx, req) + require.NoError(t, err) + + // Machine should remain unchanged + assert.Equal(t, targetMachine.Machine.Name, updatedMachine.Name) + if targetMachine.Machine.PublicIp != nil && updatedMachine.PublicIp != nil { + assert.Equal(t, targetMachine.Machine.PublicIp.Ip, updatedMachine.PublicIp.Ip) + } + assert.Equal(t, len(targetMachine.Machine.Network.Endpoints), len(updatedMachine.Network.Endpoints)) + }) +}