From 96013afe98f1271fc0790e288afd0422d1dc7a4f Mon Sep 17 00:00:00 2001 From: Anton Bozhiy Date: Mon, 27 Apr 2026 15:35:46 +0300 Subject: [PATCH 1/2] Add hosts ebm feature-set command --- cmd/entities/hosts/features.go | 98 +++++++++++++++++++ cmd/entities/hosts/features_test.go | 90 +++++++++++++++++ cmd/entities/hosts/hosts.go | 1 + .../entities/hosts/features/feature_set.json | 4 + 4 files changed, 193 insertions(+) create mode 100644 testdata/entities/hosts/features/feature_set.json diff --git a/cmd/entities/hosts/features.go b/cmd/entities/hosts/features.go index eae5921..5b59f19 100644 --- a/cmd/entities/hosts/features.go +++ b/cmd/entities/hosts/features.go @@ -1,6 +1,9 @@ package hosts import ( + "context" + "fmt" + serverscom "github.com/serverscom/serverscom-go-client/pkg" "github.com/serverscom/srvctl/cmd/base" "github.com/spf13/cobra" @@ -20,3 +23,98 @@ func newListEBMFeaturesCmd(cmdContext *base.CmdContext) *cobra.Command { return cmd } + +type featureSetFlags struct { + Feature string + State string +} + +func newEBMFeatureSetCmd(cmdContext *base.CmdContext) *cobra.Command { + flags := &featureSetFlags{} + + cmd := &cobra.Command{ + Use: "feature-set ", + Short: "Activate or deactivate a feature on an enterprise bare metal server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manager := cmdContext.GetManager() + ctx, cancel := base.SetupContext(cmd, manager) + defer cancel() + base.SetupProxy(cmd, manager) + + scClient := cmdContext.GetClient().SetVerbose(manager.GetVerbose(cmd)).GetScClient() + id := args[0] + + var ( + result *serverscom.DedicatedServerFeature + err error + ) + + switch flags.State { + case "activate": + result, err = activateEBMFeature(ctx, scClient, id, flags.Feature) + case "deactivate": + result, err = deactivateEBMFeature(ctx, scClient, id, flags.Feature) + default: + return fmt.Errorf("invalid state %q: must be activate or deactivate", flags.State) + } + + if err != nil { + return err + } + + if result != nil { + formatter := cmdContext.GetOrCreateFormatter(cmd) + return formatter.Format(result) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flags.Feature, "feature", "", "feature name (required)") + cmd.Flags().StringVar(&flags.State, "state", "", "desired state: activate or deactivate (required)") + + _ = cmd.MarkFlagRequired("feature") + _ = cmd.MarkFlagRequired("state") + + return cmd +} + +func activateEBMFeature(ctx context.Context, client *serverscom.Client, id, feature string) (*serverscom.DedicatedServerFeature, error) { + switch feature { + case "disaggregated_public_ports": + return client.Hosts.ActivateDisaggregatedPublicPortsFeature(ctx, id) + case "disaggregated_private_ports": + return client.Hosts.ActivateDisaggregatedPrivatePortsFeature(ctx, id) + case "no_public_ip_address": + return client.Hosts.ActivateNoPublicIpAddressFeature(ctx, id) + case "no_private_ip": + return client.Hosts.ActivateNoPrivateIpFeature(ctx, id) + case "oob_public_access": + return client.Hosts.ActivateOobPublicAccessFeature(ctx, id) + case "no_public_network": + return client.Hosts.ActivateNoPublicNetworkFeature(ctx, id) + default: + return nil, fmt.Errorf("unsupported feature: %s", feature) + } +} + +func deactivateEBMFeature(ctx context.Context, client *serverscom.Client, id, feature string) (*serverscom.DedicatedServerFeature, error) { + switch feature { + case "disaggregated_public_ports": + return client.Hosts.DeactivateDisaggregatedPublicPortsFeature(ctx, id) + case "disaggregated_private_ports": + return client.Hosts.DeactivateDisaggregatedPrivatePortsFeature(ctx, id) + case "no_public_ip_address": + return client.Hosts.DeactivateNoPublicIpAddressFeature(ctx, id) + case "no_private_ip": + return client.Hosts.DeactivateNoPrivateIpFeature(ctx, id) + case "oob_public_access": + return client.Hosts.DeactivateOobPublicAccessFeature(ctx, id) + case "no_public_network": + return client.Hosts.DeactivateNoPublicNetworkFeature(ctx, id) + default: + return nil, fmt.Errorf("unsupported feature: %s", feature) + } +} diff --git a/cmd/entities/hosts/features_test.go b/cmd/entities/hosts/features_test.go index 2b6e50b..903bd46 100644 --- a/cmd/entities/hosts/features_test.go +++ b/cmd/entities/hosts/features_test.go @@ -14,6 +14,11 @@ import ( var ( featuresFixtureBasePath = filepath.Join("..", "..", "..", "testdata", "entities", "hosts", "features") + + testFeatureResult = serverscom.DedicatedServerFeature{ + Name: "disaggregated_public_ports", + Status: "activation", + } ) func TestListEBMFeaturesCmd(t *testing.T) { @@ -138,3 +143,88 @@ func TestListEBMFeaturesCmd(t *testing.T) { }) } } + +func TestEBMFeatureSetCmd(t *testing.T) { + testCases := []struct { + name string + args []string + expectedOutput []byte + expectError bool + configureMock func(*mocks.MockHostsService) + }{ + { + name: "activate feature", + args: []string{testServerID, "--feature", "disaggregated_public_ports", "--state", "activate", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + ActivateDisaggregatedPublicPortsFeature(gomock.Any(), testServerID). + Return(&testFeatureResult, nil) + }, + }, + { + name: "deactivate feature", + args: []string{testServerID, "--feature", "disaggregated_public_ports", "--state", "deactivate", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + DeactivateDisaggregatedPublicPortsFeature(gomock.Any(), testServerID). + Return(&testFeatureResult, nil) + }, + }, + { + name: "api error", + args: []string{testServerID, "--feature", "disaggregated_public_ports", "--state", "activate"}, + expectError: true, + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + ActivateDisaggregatedPublicPortsFeature(gomock.Any(), testServerID). + Return(nil, errors.New("some error")) + }, + }, + { + name: "invalid state", + args: []string{testServerID, "--feature", "disaggregated_public_ports", "--state", "invalid"}, + expectError: true, + }, + { + name: "unsupported feature", + args: []string{testServerID, "--feature", "unknown_feature", "--state", "activate"}, + expectError: true, + }, + } + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + hostService := mocks.NewMockHostsService(mockCtrl) + + scClient := serverscom.NewClientWithEndpoint("", "") + scClient.Hosts = hostService + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + if tc.configureMock != nil { + tc.configureMock(hostService) + } + + testCmdContext := testutils.NewTestCmdContext(scClient) + hostsCmd := NewCmd(testCmdContext) + + args := append([]string{"hosts", "ebm", "feature-set"}, tc.args...) + builder := testutils.NewTestCommandBuilder(). + WithCommand(hostsCmd). + WithArgs(args) + + cmd := builder.Build() + err := cmd.Execute() + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).To(BeNil()) + g.Expect(builder.GetOutput()).To(BeEquivalentTo(string(tc.expectedOutput))) + } + }) + } +} diff --git a/cmd/entities/hosts/hosts.go b/cmd/entities/hosts/hosts.go index 8eeb1d1..7b0cf0a 100644 --- a/cmd/entities/hosts/hosts.go +++ b/cmd/entities/hosts/hosts.go @@ -59,6 +59,7 @@ func NewCmd(cmdContext *base.CmdContext) *cobra.Command { newListEBMCmd, newListEBMServicesCmd, newListEBMFeaturesCmd, + newEBMFeatureSetCmd, newGetEBMOOBCredsCmd, }, }, diff --git a/testdata/entities/hosts/features/feature_set.json b/testdata/entities/hosts/features/feature_set.json new file mode 100644 index 0000000..096e36b --- /dev/null +++ b/testdata/entities/hosts/features/feature_set.json @@ -0,0 +1,4 @@ +{ + "name": "disaggregated_public_ports", + "status": "activation" +} From 65c9956b43d84e4365b00421c48597e7b4bb72dd Mon Sep 17 00:00:00 2001 From: Anton Bozhiy Date: Mon, 27 Apr 2026 16:01:32 +0300 Subject: [PATCH 2/2] Add private_ipxe_boot and host_rescue_mode support to feature-set command --- cmd/entities/hosts/features.go | 31 +++++++-- cmd/entities/hosts/features_test.go | 65 +++++++++++++++++++ .../feature_set_host_rescue_mode.json | 4 ++ .../feature_set_private_ipxe_boot.json | 4 ++ 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 testdata/entities/hosts/features/feature_set_host_rescue_mode.json create mode 100644 testdata/entities/hosts/features/feature_set_private_ipxe_boot.json diff --git a/cmd/entities/hosts/features.go b/cmd/entities/hosts/features.go index 5b59f19..ccfaa77 100644 --- a/cmd/entities/hosts/features.go +++ b/cmd/entities/hosts/features.go @@ -25,8 +25,11 @@ func newListEBMFeaturesCmd(cmdContext *base.CmdContext) *cobra.Command { } type featureSetFlags struct { - Feature string - State string + Feature string + State string + IPXEConfig string + AuthMethods []string + SSHKeyFingerprints []string } func newEBMFeatureSetCmd(cmdContext *base.CmdContext) *cobra.Command { @@ -52,7 +55,7 @@ func newEBMFeatureSetCmd(cmdContext *base.CmdContext) *cobra.Command { switch flags.State { case "activate": - result, err = activateEBMFeature(ctx, scClient, id, flags.Feature) + result, err = activateEBMFeature(ctx, scClient, id, flags) case "deactivate": result, err = deactivateEBMFeature(ctx, scClient, id, flags.Feature) default: @@ -74,6 +77,9 @@ func newEBMFeatureSetCmd(cmdContext *base.CmdContext) *cobra.Command { cmd.Flags().StringVar(&flags.Feature, "feature", "", "feature name (required)") cmd.Flags().StringVar(&flags.State, "state", "", "desired state: activate or deactivate (required)") + cmd.Flags().StringVar(&flags.IPXEConfig, "ipxe-config", "", "iPXE config script (for private_ipxe_boot)") + cmd.Flags().StringArrayVar(&flags.AuthMethods, "auth-methods", nil, "auth methods: password, ssh_key (for host_rescue_mode)") + cmd.Flags().StringArrayVar(&flags.SSHKeyFingerprints, "ssh-key-fingerprints", nil, "SSH key fingerprints (for host_rescue_mode with ssh_key auth)") _ = cmd.MarkFlagRequired("feature") _ = cmd.MarkFlagRequired("state") @@ -81,8 +87,8 @@ func newEBMFeatureSetCmd(cmdContext *base.CmdContext) *cobra.Command { return cmd } -func activateEBMFeature(ctx context.Context, client *serverscom.Client, id, feature string) (*serverscom.DedicatedServerFeature, error) { - switch feature { +func activateEBMFeature(ctx context.Context, client *serverscom.Client, id string, flags *featureSetFlags) (*serverscom.DedicatedServerFeature, error) { + switch flags.Feature { case "disaggregated_public_ports": return client.Hosts.ActivateDisaggregatedPublicPortsFeature(ctx, id) case "disaggregated_private_ports": @@ -95,8 +101,17 @@ func activateEBMFeature(ctx context.Context, client *serverscom.Client, id, feat return client.Hosts.ActivateOobPublicAccessFeature(ctx, id) case "no_public_network": return client.Hosts.ActivateNoPublicNetworkFeature(ctx, id) + case "host_rescue_mode": + return client.Hosts.ActivateHostRescueModeFeature(ctx, id, serverscom.HostRescueModeFeatureInput{ + AuthMethods: flags.AuthMethods, + SSHKeyFingerprints: flags.SSHKeyFingerprints, + }) + case "private_ipxe_boot": + return client.Hosts.ActivatePrivateIpxeBootFeature(ctx, id, serverscom.PrivateIpxeBootFeatureInput{ + IPXEConfig: flags.IPXEConfig, + }) default: - return nil, fmt.Errorf("unsupported feature: %s", feature) + return nil, fmt.Errorf("unsupported feature: %s", flags.Feature) } } @@ -114,6 +129,10 @@ func deactivateEBMFeature(ctx context.Context, client *serverscom.Client, id, fe return client.Hosts.DeactivateOobPublicAccessFeature(ctx, id) case "no_public_network": return client.Hosts.DeactivateNoPublicNetworkFeature(ctx, id) + case "host_rescue_mode": + return client.Hosts.DeactivateHostRescueModeFeature(ctx, id) + case "private_ipxe_boot": + return client.Hosts.DeactivatePrivateIpxeBootFeature(ctx, id) default: return nil, fmt.Errorf("unsupported feature: %s", feature) } diff --git a/cmd/entities/hosts/features_test.go b/cmd/entities/hosts/features_test.go index 903bd46..d1340c8 100644 --- a/cmd/entities/hosts/features_test.go +++ b/cmd/entities/hosts/features_test.go @@ -19,6 +19,14 @@ var ( Name: "disaggregated_public_ports", Status: "activation", } + testPrivateIpxeBootResult = serverscom.DedicatedServerFeature{ + Name: "private_ipxe_boot", + Status: "activation", + } + testHostRescueModeResult = serverscom.DedicatedServerFeature{ + Name: "host_rescue_mode", + Status: "activation", + } ) func TestListEBMFeaturesCmd(t *testing.T) { @@ -172,6 +180,63 @@ func TestEBMFeatureSetCmd(t *testing.T) { Return(&testFeatureResult, nil) }, }, + { + name: "activate private_ipxe_boot with ipxe-config", + args: []string{testServerID, "--feature", "private_ipxe_boot", "--state", "activate", "--ipxe-config", "#!ipxe\nchain http://boot.example.com", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set_private_ipxe_boot.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + ActivatePrivateIpxeBootFeature(gomock.Any(), testServerID, serverscom.PrivateIpxeBootFeatureInput{ + IPXEConfig: "#!ipxe\nchain http://boot.example.com", + }). + Return(&testPrivateIpxeBootResult, nil) + }, + }, + { + name: "deactivate private_ipxe_boot", + args: []string{testServerID, "--feature", "private_ipxe_boot", "--state", "deactivate", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set_private_ipxe_boot.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + DeactivatePrivateIpxeBootFeature(gomock.Any(), testServerID). + Return(&testPrivateIpxeBootResult, nil) + }, + }, + { + name: "activate host_rescue_mode with password auth", + args: []string{testServerID, "--feature", "host_rescue_mode", "--state", "activate", "--auth-methods", "password", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set_host_rescue_mode.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + ActivateHostRescueModeFeature(gomock.Any(), testServerID, serverscom.HostRescueModeFeatureInput{ + AuthMethods: []string{"password"}, + }). + Return(&testHostRescueModeResult, nil) + }, + }, + { + name: "activate host_rescue_mode with ssh_key auth", + args: []string{testServerID, "--feature", "host_rescue_mode", "--state", "activate", "--auth-methods", "ssh_key", "--ssh-key-fingerprints", "aa:bb:cc", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set_host_rescue_mode.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + ActivateHostRescueModeFeature(gomock.Any(), testServerID, serverscom.HostRescueModeFeatureInput{ + AuthMethods: []string{"ssh_key"}, + SSHKeyFingerprints: []string{"aa:bb:cc"}, + }). + Return(&testHostRescueModeResult, nil) + }, + }, + { + name: "deactivate host_rescue_mode", + args: []string{testServerID, "--feature", "host_rescue_mode", "--state", "deactivate", "--output", "json"}, + expectedOutput: testutils.ReadFixture(filepath.Join(featuresFixtureBasePath, "feature_set_host_rescue_mode.json")), + configureMock: func(mock *mocks.MockHostsService) { + mock.EXPECT(). + DeactivateHostRescueModeFeature(gomock.Any(), testServerID). + Return(&testHostRescueModeResult, nil) + }, + }, { name: "api error", args: []string{testServerID, "--feature", "disaggregated_public_ports", "--state", "activate"}, diff --git a/testdata/entities/hosts/features/feature_set_host_rescue_mode.json b/testdata/entities/hosts/features/feature_set_host_rescue_mode.json new file mode 100644 index 0000000..4fc87f7 --- /dev/null +++ b/testdata/entities/hosts/features/feature_set_host_rescue_mode.json @@ -0,0 +1,4 @@ +{ + "name": "host_rescue_mode", + "status": "activation" +} diff --git a/testdata/entities/hosts/features/feature_set_private_ipxe_boot.json b/testdata/entities/hosts/features/feature_set_private_ipxe_boot.json new file mode 100644 index 0000000..07d389e --- /dev/null +++ b/testdata/entities/hosts/features/feature_set_private_ipxe_boot.json @@ -0,0 +1,4 @@ +{ + "name": "private_ipxe_boot", + "status": "activation" +}