diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7407786..bf7391643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - kms: add `exo kms crypto sign`, `exo kms crypto verify` and `exo kms key get-public-key`, and a `--key-spec` flag on `exo kms key create` to create asymmetric keys - kms: add `--encryption-algorithm` to `exo kms crypto encrypt` and `decrypt`, and `--source-encryption-algorithm` and `--dest-encryption-algorithm` to `reencrypt`, to use RSA keys - kms: show the key spec in `exo kms key show` and `exo kms key list` +- compute: add a `--label-selector` flag to `exo compute load-balancer service add` and `update`, to select backend instances by labels, and show the selector in `service show` (#946) - dbaas: add `exo dbaas acl list` for Kafka and OpenSearch, `exo dbaas acl create` and `delete` for Kafka, and Kafka and OpenSearch support in `exo dbaas acl show` (#923) - compute: add `--vpc` and `--vpc-subnet` to `exo compute instance create` to attach an instance to VPC Subnets at creation (#927) diff --git a/cmd/compute/load_balancer/nlb_helpers_test.go b/cmd/compute/load_balancer/nlb_helpers_test.go new file mode 100644 index 000000000..eff97953f --- /dev/null +++ b/cmd/compute/load_balancer/nlb_helpers_test.go @@ -0,0 +1,205 @@ +package load_balancer + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/testutils" + v3 "github.com/exoscale/egoscale/v3" +) + +const ( + testNLBID = "8f3a0000-0000-4000-8000-0000000000a1" + testNLBName = "nlb-1" + testInstancePoolID = "8f3a0000-0000-4000-8000-0000000000b1" + testInstancePoolName = "pool-1" + testPoolServiceID = "8f3a0000-0000-4000-8000-0000000000c1" + testPoolServiceName = "pool-svc" + testSelectorSvcID = "8f3a0000-0000-4000-8000-0000000000c2" + testSelectorSvcName = "selector-svc" + testOperationID = "00000000-0000-0000-0000-0000000000f1" +) + +// nlbRequest is a single request made against the mock API. Body is the decoded +// JSON request body (nil for requests without one). +type nlbRequest struct { + Method string + Path string + Body map[string]any +} + +// nlbTestAPI is a mock NLB API that records every request, so tests can make +// assertions on calls. Responses are registered per method and path with Serve. +type nlbTestAPI struct { + requests []nlbRequest + responses map[string]any +} + +// Serve registers a JSON response for " ". +func (a *nlbTestAPI) Serve(method, path string, v any) { + a.responses[method+" "+path] = v +} + +// serveOperation marks the given request as a successful asynchronous call. +// The real API answers writes with a pending Operation that the client polls +// until it completes. The mock returns the operation already completed, so +// client.Wait returns without polling and no GET /operation request happens. +func (a *nlbTestAPI) serveOperation(method, path string) { + a.Serve(method, path, v3.Operation{ + ID: v3.UUID(testOperationID), + State: v3.OperationStateSuccess, + }) +} + +// serveNLB registers a single Network Load Balancer with the given services. +func (a *nlbTestAPI) serveNLB(services ...v3.LoadBalancerService) { + a.Serve(http.MethodGet, "/load-balancer", v3.ListLoadBalancersResponse{ + LoadBalancers: []v3.LoadBalancer{{ + ID: v3.UUID(testNLBID), + Name: testNLBName, + Services: services, + }}, + }) +} + +// baseService returns a service with valid default settings. +func baseService() v3.LoadBalancerService { + return v3.LoadBalancerService{ + Healthcheck: &v3.LoadBalancerServiceHealthcheck{ + Mode: v3.LoadBalancerServiceHealthcheckModeTCP, + Port: 8443, + Interval: 10, + Timeout: 2, + Retries: 1, + }, + Protocol: v3.LoadBalancerServiceProtocolTCP, + Strategy: v3.LoadBalancerServiceStrategyRoundRobin, + Port: 443, + TargetPort: 8443, + State: v3.LoadBalancerServiceStateRunning, + } +} + +// poolService returns a service backed by the test Instance Pool. +func poolService(name, id string) v3.LoadBalancerService { + svc := baseService() + svc.ID = v3.UUID(id) + svc.Name = name + svc.InstancePool = &v3.InstancePool{ID: v3.UUID(testInstancePoolID)} + return svc +} + +// selectorService returns a service backed only by a label selector. +func selectorService(name, id string, labels map[string]string) v3.LoadBalancerService { + svc := baseService() + svc.ID = v3.UUID(id) + svc.Name = name + svc.LabelSelector = &v3.LoadBalancerServiceLabelSelector{Labels: labels} + return svc +} + +// called returns the number of requests made to the given path with method. +func (a *nlbTestAPI) called(method, path string) int { + var n int + for _, r := range a.requests { + if r.Method == method && r.Path == path { + n++ + } + } + return n +} + +// body returns the JSON body of the last matching request with method and path, +// or nil if the request was never made. +func (a *nlbTestAPI) body(method, path string) map[string]any { + var body map[string]any + for _, r := range a.requests { + if r.Method == method && r.Path == path { + body = r.Body + } + } + return body +} + +// newNLBTestAPI starts a mock NLB API server and points the global v3 client +// at it. The server is closed when the test ends. Tests register mock +// responses with Serve, serveNLB and serveOperation. Unregistered requests +// return 404. +func newNLBTestAPI(t *testing.T) *nlbTestAPI { + t.Helper() + + api := &nlbTestAPI{responses: map[string]any{}} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := nlbRequest{Method: r.Method, Path: r.URL.Path} + if r.Body != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } else if len(body) > 0 { + if err := json.Unmarshal(body, &req.Body); err != nil { + t.Errorf("unmarshal request body: %v", err) + } + } + } + api.requests = append(api.requests, req) + + resp, ok := api.responses[req.Method+" "+req.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + testutils.WriteJSON(t, w, http.StatusOK, resp) + })) + t.Cleanup(srv.Close) + + testutils.SetupV3Client(t, srv.URL) + return api +} + +func nlbServicePath(serviceID string) string { + return "/load-balancer/" + testNLBID + "/service/" + serviceID +} + +// bodySelectorLabels returns the label-selector labels of a captured +// request body, or fails the test if they are missing. +func bodySelectorLabels(t *testing.T, body map[string]any) map[string]any { + t.Helper() + + selector, ok := body["label-selector"].(map[string]any) + if !ok { + t.Fatalf("label-selector missing from request body: %v", body) + } + labels, ok := selector["labels"].(map[string]any) + if !ok { + t.Fatalf("label-selector.labels missing from request body: %v", selector) + } + return labels +} + +// newServiceUpdateFlagSet builds a cobra command carrying the update +// command's generated flags, so cmd.Flags().Changed() behaves as it does in +// production. +func newServiceUpdateFlagSet(t *testing.T, c *nlbServiceUpdateCmd, changed ...string) *cobra.Command { + t.Helper() + + parent := &cobra.Command{Use: "test"} + if err := exocmd.RegisterCLICommand(parent, c); err != nil { + t.Fatalf("register command: %v", err) + } + + cmd := parent.Commands()[0] + for _, name := range changed { + if err := cmd.Flags().Set(name, cmd.Flags().Lookup(name).Value.String()); err != nil { + t.Fatalf("set flag %s: %v", name, err) + } + } + + return cmd +} diff --git a/cmd/compute/load_balancer/nlb_service_add.go b/cmd/compute/load_balancer/nlb_service_add.go index a711c5574..79ea423a4 100644 --- a/cmd/compute/load_balancer/nlb_service_add.go +++ b/cmd/compute/load_balancer/nlb_service_add.go @@ -22,20 +22,21 @@ type nlbServiceAddCmd struct { NetworkLoadBalancer string `cli-arg:"#" cli-usage:"LOAD-BALANCER-NAME|ID"` Name string `cli-arg:"#" cli-usage:"SERVICE-NAME"` - Description string `cli-usage:"service description"` - HealthcheckInterval int64 `cli-usage:"service health checking interval in seconds"` - HealthcheckMode string `cli-usage:"service health checking mode (tcp|http|https)"` - HealthcheckPort int64 `cli-usage:"service health checking port (defaults to target port)"` - HealthcheckRetries int64 `cli-usage:"service health checking retries"` - HealthcheckTLSSNI string `cli-flag:"healthcheck-tls-sni" cli-usage:"service health checking server name to present with SNI in https mode"` - HealthcheckTimeout int64 `cli-usage:"service health checking timeout in seconds"` - HealthcheckURI string `cli-usage:"service health checking URI (required in http(s) mode)"` - InstancePool string `cli-usage:"name or ID of the Instance Pool to forward traffic to"` - Port int64 `cli-usage:"service port"` - Protocol string `cli-usage:"service network protocol (tcp|udp)"` - Strategy string `cli-usage:"load balancing strategy (round-robin|source-hash)"` - TargetPort int64 `cli-usage:"port to forward traffic to on target instances (defaults to service port)"` - Zone v3.ZoneName `cli-short:"z" cli-usage:"Network Load Balancer zone"` + Description string `cli-usage:"service description"` + HealthcheckInterval int64 `cli-usage:"service health checking interval in seconds"` + HealthcheckMode string `cli-usage:"service health checking mode (tcp|http|https)"` + HealthcheckPort int64 `cli-usage:"service health checking port (defaults to target port)"` + HealthcheckRetries int64 `cli-usage:"service health checking retries"` + HealthcheckTLSSNI string `cli-flag:"healthcheck-tls-sni" cli-usage:"service health checking server name to present with SNI in https mode"` + HealthcheckTimeout int64 `cli-usage:"service health checking timeout in seconds"` + HealthcheckURI string `cli-usage:"service health checking URI (required in http(s) mode)"` + InstancePool string `cli-usage:"name or ID of the Instance Pool to forward traffic to (either --instance-pool or --label-selector is required)"` + LabelSelector map[string]string `cli-usage:"labels of instances to forward traffic to (format: key=value)"` + Port int64 `cli-usage:"service port"` + Protocol string `cli-usage:"service network protocol (tcp|udp)"` + Strategy string `cli-usage:"load balancing strategy (round-robin|source-hash)"` + TargetPort int64 `cli-usage:"port to forward traffic to on target instances (defaults to service port)"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"Network Load Balancer zone"` } func (c *nlbServiceAddCmd) CmdAliases() []string { return nil } @@ -64,12 +65,11 @@ func (c *nlbServiceAddCmd) CmdRun(_ *cobra.Command, _ []string) error { Retries: c.HealthcheckRetries, Timeout: c.HealthcheckTimeout, }, - Name: c.Name, - Port: c.Port, - Protocol: v3.AddServiceToLoadBalancerRequestProtocol(c.Protocol), - Strategy: v3.AddServiceToLoadBalancerRequestStrategy(c.Strategy), - TargetPort: c.TargetPort, - InstancePool: &v3.InstancePool{}, + Name: c.Name, + Port: c.Port, + Protocol: v3.AddServiceToLoadBalancerRequestProtocol(c.Protocol), + Strategy: v3.AddServiceToLoadBalancerRequestStrategy(c.Strategy), + TargetPort: c.TargetPort, } ctx := exocmd.GContext @@ -83,6 +83,20 @@ func (c *nlbServiceAddCmd) CmdRun(_ *cobra.Command, _ []string) error { return errors.New(`a healthcheck URI is required in "http(s)" mode`) } + if c.InstancePool == "" && len(c.LabelSelector) == 0 { + return errors.New("either --instance-pool or --label-selector is required") + } + + // an empty label selector is invalid on create (contrary to update, where + // it means "remove the selector") + labels := exocmd.ConvertIfSpecialEmptyMap(c.LabelSelector) + if c.LabelSelector != nil && len(labels) == 0 { + return errors.New("--label-selector must contain at least one label (format: key=value)") + } + if len(labels) > 0 { + service.LabelSelector = &v3.LoadBalancerServiceLabelSelector{Labels: labels} + } + if service.TargetPort == 0 { service.TargetPort = service.Port } @@ -106,16 +120,18 @@ func (c *nlbServiceAddCmd) CmdRun(_ *cobra.Command, _ []string) error { return err } - instancePools, err := client.ListInstancePools(ctx) - if err != nil { - return err - } - - instancePool, err := instancePools.FindInstancePool(c.InstancePool) - if err != nil { - return err + if c.InstancePool != "" { + instancePools, err := client.ListInstancePools(ctx) + if err != nil { + return err + } + + instancePool, err := instancePools.FindInstancePool(c.InstancePool) + if err != nil { + return err + } + service.InstancePool = &v3.InstancePool{ID: instancePool.ID} } - service.InstancePool.ID = instancePool.ID op, err := client.AddServiceToLoadBalancer(ctx, nlb.ID, service) if err != nil { diff --git a/cmd/compute/load_balancer/nlb_service_add_test.go b/cmd/compute/load_balancer/nlb_service_add_test.go new file mode 100644 index 000000000..cdd740a4f --- /dev/null +++ b/cmd/compute/load_balancer/nlb_service_add_test.go @@ -0,0 +1,126 @@ +package load_balancer + +import ( + "net/http" + "reflect" + "strings" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + v3 "github.com/exoscale/egoscale/v3" +) + +const testServiceAddPath = "/load-balancer/" + testNLBID + "/service" + +// testNLBServiceAddCmd returns an add command pre-filled with the same +// defaults that init() sets in production, which CmdRun does not apply. +func testNLBServiceAddCmd() *nlbServiceAddCmd { + return &nlbServiceAddCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + NetworkLoadBalancer: "nlb-1", + Name: "web", + Port: 443, + Protocol: "tcp", + Strategy: "round-robin", + HealthcheckMode: "tcp", + HealthcheckInterval: 10, + HealthcheckTimeout: 5, + HealthcheckRetries: 1, + } +} + +func TestNLBServiceAddSelectorOnly(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB() + api.serveOperation(http.MethodPost, testServiceAddPath) + + c := testNLBServiceAddCmd() + c.LabelSelector = map[string]string{"role": "web", "exoscale.com/managed-by": "karpenter"} + + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("service add: %v", err) + } + + // a selector-only service has no Instance Pool, so there must be no pool + // lookup, and the request must omit the instance-pool key entirely + if n := api.called(http.MethodGet, "/instance-pool"); n > 0 { + t.Errorf("expected no Instance Pool lookup when --instance-pool is not set, got %d requests", n) + } + body := api.body(http.MethodPost, testServiceAddPath) + if body == nil { + t.Fatal("expected a service add request to be issued") + } + if _, ok := body["instance-pool"]; ok { + t.Errorf("instance-pool unexpectedly in request body: %v", body) + } + want := map[string]any{"role": "web", "exoscale.com/managed-by": "karpenter"} + if got := bodySelectorLabels(t, body); !reflect.DeepEqual(got, want) { + t.Errorf("labels: got %v, want %v", got, want) + } +} + +func TestNLBServiceAddPoolAndSelector(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB() + api.serveOperation(http.MethodPost, testServiceAddPath) + api.Serve(http.MethodGet, "/instance-pool", v3.ListInstancePoolsResponse{ + InstancePools: []v3.InstancePool{{ID: v3.UUID(testInstancePoolID), Name: testInstancePoolName}}, + }) + + c := testNLBServiceAddCmd() + c.InstancePool = testInstancePoolName + c.LabelSelector = map[string]string{"role": "web"} + + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("service add: %v", err) + } + + body := api.body(http.MethodPost, testServiceAddPath) + if body == nil { + t.Fatal("expected a service add request to be issued") + } + pool, ok := body["instance-pool"].(map[string]any) + if !ok { + t.Fatalf("instance-pool missing from request body: %v", body) + } + if got := pool["id"]; got != testInstancePoolID { + t.Errorf("instance-pool.id: got %v, want %q", got, testInstancePoolID) + } + want := map[string]any{"role": "web"} + if got := bodySelectorLabels(t, body); !reflect.DeepEqual(got, want) { + t.Errorf("labels: got %v, want %v", got, want) + } +} + +func TestNLBServiceAddRequiresBackendSource(t *testing.T) { + api := newNLBTestAPI(t) + + c := testNLBServiceAddCmd() + + err := c.CmdRun(nil, nil) + if err == nil || !strings.Contains(err.Error(), "either --instance-pool or --label-selector is required") { + t.Fatalf("expected backend source requirement error, got: %v", err) + } + if n := api.called(http.MethodPost, testServiceAddPath); n > 0 { + t.Errorf("expected no service add request to be issued, got %d requests", n) + } +} + +// TestNLBServiceAddEmptySelectorRejected checks that an empty selector is +// rejected on create. Unlike update, where --label-selector '[=]' removes +// the selector, empty labels on create are a 400. +func TestNLBServiceAddEmptySelectorRejected(t *testing.T) { + api := newNLBTestAPI(t) + + c := testNLBServiceAddCmd() + // the map the framework parses from `--label-selector '[=]'` + c.LabelSelector = map[string]string{"": ""} + + err := c.CmdRun(nil, nil) + if err == nil || !strings.Contains(err.Error(), "at least one label") { + t.Fatalf("expected empty selector error, got: %v", err) + } + if n := api.called(http.MethodPost, testServiceAddPath); n > 0 { + t.Errorf("expected no service add request to be issued, got %d requests", n) + } +} diff --git a/cmd/compute/load_balancer/nlb_service_show.go b/cmd/compute/load_balancer/nlb_service_show.go index e61052f1e..a38f32e3e 100644 --- a/cmd/compute/load_balancer/nlb_service_show.go +++ b/cmd/compute/load_balancer/nlb_service_show.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "sort" "strings" "time" @@ -36,6 +37,7 @@ type nlbServiceShowOutput struct { Name string `json:"name"` Description string `json:"description"` InstancePoolID string `json:"instance_pool_id"` + LabelSelector map[string]string `json:"label_selector"` Protocol string `json:"protocol"` Port int64 `json:"port"` TargetPort int64 `json:"target_port"` @@ -55,7 +57,27 @@ func (o *nlbServiceShowOutput) ToTable() { t.Append([]string{"ID", o.ID}) t.Append([]string{"Name", o.Name}) t.Append([]string{"Description", o.Description}) - t.Append([]string{"Instance Pool ID", o.InstancePoolID}) + t.Append([]string{"Instance Pool ID", func() string { + if o.InstancePoolID != "" { + return o.InstancePoolID + } + return "n/a" + }()}) + t.Append([]string{"Label Selector", func() string { + if len(o.LabelSelector) > 0 { + keys := make([]string, 0, len(o.LabelSelector)) + for k := range o.LabelSelector { + keys = append(keys, k) + } + sort.Strings(keys) + selectors := make([]string, len(keys)) + for i, k := range keys { + selectors[i] = fmt.Sprintf("%s = %s", k, o.LabelSelector[k]) + } + return strings.Join(selectors, "\n") + } + return "n/a" + }()}) t.Append([]string{"Protocol", o.Protocol}) t.Append([]string{"Port", fmt.Sprint(o.Port)}) t.Append([]string{"Target Port", fmt.Sprint(o.TargetPort)}) @@ -146,15 +168,14 @@ func (c *nlbServiceShowCmd) CmdRun(_ *cobra.Command, _ []string) error { } out := nlbServiceShowOutput{ - ID: string(svc.ID), - Name: svc.Name, - Description: svc.Description, - InstancePoolID: string(svc.InstancePool.ID), - Protocol: string(svc.Protocol), - Port: svc.Port, - TargetPort: svc.TargetPort, - Strategy: string(svc.Strategy), - State: string(svc.State), + ID: string(svc.ID), + Name: svc.Name, + Description: svc.Description, + Protocol: string(svc.Protocol), + Port: svc.Port, + TargetPort: svc.TargetPort, + Strategy: string(svc.Strategy), + State: string(svc.State), Healthcheck: nlbServiceHealthcheckShowOutput{ Mode: string(svc.Healthcheck.Mode), @@ -178,6 +199,14 @@ func (c *nlbServiceShowCmd) CmdRun(_ *cobra.Command, _ []string) error { }(), } + // a service may have no Instance Pool (label selector-only services) + if svc.InstancePool != nil { + out.InstancePoolID = string(svc.InstancePool.ID) + } + if svc.LabelSelector != nil { + out.LabelSelector = svc.LabelSelector.Labels + } + return c.OutputFunc(&out, nil) } diff --git a/cmd/compute/load_balancer/nlb_service_show_test.go b/cmd/compute/load_balancer/nlb_service_show_test.go new file mode 100644 index 000000000..539c21d3d --- /dev/null +++ b/cmd/compute/load_balancer/nlb_service_show_test.go @@ -0,0 +1,67 @@ +package load_balancer + +import ( + "reflect" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/output" +) + +// TestNLBServiceShowSelectorOnlyService checks that show handles services +// returned without an instance-pool key (the new shape for label-selector +// only services). The command must not fail on the missing Instance Pool, +// and must display the selector. +func TestNLBServiceShowSelectorOnlyService(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB( + poolService(testPoolServiceName, testPoolServiceID), + selectorService(testSelectorSvcName, testSelectorSvcID, map[string]string{"role": "web"}), + ) + + // selector-only service, with no Instance Pool and a label selector + var got *nlbServiceShowOutput + cmd := &nlbServiceShowCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + NetworkLoadBalancer: testNLBName, + Service: testSelectorSvcName, + } + // keep the produced output instead of printing it + cmd.OutputFunc = func(o output.Outputter, err error) error { + got = o.(*nlbServiceShowOutput) + return err + } + + if err := cmd.CmdRun(nil, nil); err != nil { + t.Fatalf("service show: %v", err) + } + if got.InstancePoolID != "" { + t.Errorf("instance_pool_id: got %q, want empty", got.InstancePoolID) + } + wantLabels := map[string]string{"role": "web"} + if !reflect.DeepEqual(got.LabelSelector, wantLabels) { + t.Errorf("label_selector: got %v, want %v", got.LabelSelector, wantLabels) + } + + // pool-backed service, with an Instance Pool and no label selector + got = nil + cmd = &nlbServiceShowCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + NetworkLoadBalancer: testNLBName, + Service: testPoolServiceName, + } + cmd.OutputFunc = func(o output.Outputter, err error) error { + got = o.(*nlbServiceShowOutput) + return err + } + + if err := cmd.CmdRun(nil, nil); err != nil { + t.Fatalf("service show: %v", err) + } + if got.InstancePoolID != testInstancePoolID { + t.Errorf("instance_pool_id: got %q, want %q", got.InstancePoolID, testInstancePoolID) + } + if got.LabelSelector != nil { + t.Errorf("label_selector: got %v, want nil", got.LabelSelector) + } +} diff --git a/cmd/compute/load_balancer/nlb_service_update.go b/cmd/compute/load_balancer/nlb_service_update.go index 4dc9c8934..768f687c9 100644 --- a/cmd/compute/load_balancer/nlb_service_update.go +++ b/cmd/compute/load_balancer/nlb_service_update.go @@ -22,20 +22,21 @@ type nlbServiceUpdateCmd struct { NetworkLoadBalancer string `cli-arg:"#" cli-usage:"LOAD-BALANCER-NAME|ID"` Service string `cli-arg:"#" cli-usage:"SERVICE-NAME|ID"` - Description string `cli-usage:"service description"` - HealthcheckInterval int64 `cli-usage:"service health checking interval in seconds"` - HealthcheckMode string `cli-usage:"service health checking mode (tcp|http|https)"` - HealthcheckPort int64 `cli-usage:"service health checking port"` - HealthcheckRetries int64 `cli-usage:"service health checking retries"` - HealthcheckTLSSNI string `cli-flag:"healthcheck-tls-sni" cli-usage:"service health checking server name to present with SNI in https mode"` - HealthcheckTimeout int64 `cli-usage:"service health checking timeout in seconds"` - HealthcheckURI string `cli-usage:"service health checking URI (required in http(s) mode)"` - Name string `cli-usage:"service name"` - Port int64 `cli-usage:"service port"` - Protocol string `cli-usage:"service network protocol (tcp|udp)"` - Strategy string `cli-usage:"load balancing strategy (round-robin|source-hash)"` - TargetPort int64 `cli-usage:"port to forward traffic to on target instances"` - Zone v3.ZoneName `cli-short:"z" cli-usage:"Network Load Balancer zone"` + Description string `cli-usage:"service description"` + HealthcheckInterval int64 `cli-usage:"service health checking interval in seconds"` + HealthcheckMode string `cli-usage:"service health checking mode (tcp|http|https)"` + HealthcheckPort int64 `cli-usage:"service health checking port"` + HealthcheckRetries int64 `cli-usage:"service health checking retries"` + HealthcheckTLSSNI string `cli-flag:"healthcheck-tls-sni" cli-usage:"service health checking server name to present with SNI in https mode"` + HealthcheckTimeout int64 `cli-usage:"service health checking timeout in seconds"` + HealthcheckURI string `cli-usage:"service health checking URI (required in http(s) mode)"` + LabelSelector map[string]string `cli-usage:"labels of instances to forward traffic to (format: key=value), clearing is possible by passing [=]"` + Name string `cli-usage:"service name"` + Port int64 `cli-usage:"service port"` + Protocol string `cli-usage:"service network protocol (tcp|udp)"` + Strategy string `cli-usage:"load balancing strategy (round-robin|source-hash)"` + TargetPort int64 `cli-usage:"port to forward traffic to on target instances"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"Network Load Balancer zone"` } func (c *nlbServiceUpdateCmd) CmdAliases() []string { return nil } @@ -160,6 +161,19 @@ func (c *nlbServiceUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { updated = true } + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.LabelSelector)) { + labels := exocmd.ConvertIfSpecialEmptyMap(c.LabelSelector) + if len(labels) == 0 { + // an empty label selector means "remove the selector", which the + // API rejects for services backed by no Instance Pool + if service.InstancePool == nil || service.InstancePool.ID == "" { + return fmt.Errorf("cannot remove the label selector from service %q: it has no Instance Pool", service.Name) + } + } + svc.LabelSelector = &v3.LoadBalancerServiceLabelSelector{Labels: labels} + updated = true + } + if updated { op, err := client.UpdateLoadBalancerService(ctx, nlb.ID, service.ID, svc) if err != nil { diff --git a/cmd/compute/load_balancer/nlb_service_update_test.go b/cmd/compute/load_balancer/nlb_service_update_test.go new file mode 100644 index 000000000..2378177de --- /dev/null +++ b/cmd/compute/load_balancer/nlb_service_update_test.go @@ -0,0 +1,129 @@ +package load_balancer + +import ( + "net/http" + "reflect" + "strings" + "testing" + + exocmd "github.com/exoscale/cli/cmd" +) + +func testNLBServiceUpdateCmd(service string) *nlbServiceUpdateCmd { + return &nlbServiceUpdateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + NetworkLoadBalancer: testNLBName, + Service: service, + } +} + +// TestNLBServiceUpdateReplacesSelector checks that the updated selector +// replaces the previous one, and that keys like exoscale.com/managed-by are +// sent as-is, keeping their prefix and case. +func TestNLBServiceUpdateReplacesSelector(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB(poolService(testPoolServiceName, testPoolServiceID)) + api.serveOperation(http.MethodPut, nlbServicePath(testPoolServiceID)) + + c := testNLBServiceUpdateCmd(testPoolServiceName) + c.LabelSelector = map[string]string{"role": "api", "exoscale.com/managed-by": "karpenter"} + cmd := newServiceUpdateFlagSet(t, c, "label-selector") + + if err := c.CmdRun(cmd, nil); err != nil { + t.Fatalf("service update: %v", err) + } + + path := nlbServicePath(testPoolServiceID) + body := api.body(http.MethodPut, path) + if body == nil { + t.Fatal("expected an update request to be issued") + } + want := map[string]any{"role": "api", "exoscale.com/managed-by": "karpenter"} + if got := bodySelectorLabels(t, body); !reflect.DeepEqual(got, want) { + t.Errorf("labels: got %v, want %v", got, want) + } +} + +// TestNLBServiceUpdateLeavesSelectorUntouched checks that a flag the user did +// not pass is omitted from the PUT body entirely. Sending an explicit null +// would do nothing server-side, so the "leave untouched" contract relies on +// the omission. +func TestNLBServiceUpdateLeavesSelectorUntouched(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB(poolService(testPoolServiceName, testPoolServiceID)) + api.serveOperation(http.MethodPut, nlbServicePath(testPoolServiceID)) + + c := testNLBServiceUpdateCmd(testPoolServiceName) + c.Description = "hello" + cmd := newServiceUpdateFlagSet(t, c, "description") + + if err := c.CmdRun(cmd, nil); err != nil { + t.Fatalf("service update: %v", err) + } + + body := api.body(http.MethodPut, nlbServicePath(testPoolServiceID)) + if body == nil { + t.Fatal("expected an update request to be issued") + } + if got := body["description"]; got != "hello" { + t.Errorf("description: got %v, want %q", got, "hello") + } + if _, ok := body["label-selector"]; ok { + t.Errorf("label-selector unexpectedly in request body: %v", body) + } +} + +// TestNLBServiceUpdateRemovesSelector checks that --label-selector '[=]' +// sends an explicitly empty labels object, which is the API's encoding for +// "remove the selector". A null or omitted key would not remove it. +func TestNLBServiceUpdateRemovesSelector(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB(poolService(testPoolServiceName, testPoolServiceID)) + api.serveOperation(http.MethodPut, nlbServicePath(testPoolServiceID)) + + c := testNLBServiceUpdateCmd(testPoolServiceName) + // the map the framework parses from `--label-selector '[=]'` + c.LabelSelector = map[string]string{"": ""} + cmd := newServiceUpdateFlagSet(t, c, "label-selector") + + if err := c.CmdRun(cmd, nil); err != nil { + t.Fatalf("service update: %v", err) + } + + body := api.body(http.MethodPut, nlbServicePath(testPoolServiceID)) + if body == nil { + t.Fatal("expected an update request to be issued") + } + selector, ok := body["label-selector"].(map[string]any) + if !ok { + t.Fatalf("label-selector missing from request body: %v", body) + } + labels, ok := selector["labels"].(map[string]any) + if !ok { + t.Fatalf("label-selector.labels must be an empty object, got: %v", selector["labels"]) + } + if len(labels) != 0 { + t.Errorf("label-selector.labels must be empty, got: %v", labels) + } +} + +// TestNLBServiceUpdateRemoveSelectorOnPoollessService checks that removing +// the selector from a service with no Instance Pool is rejected upfront, +// since it would leave the service with zero backends. Without the guard the +// API would answer with a 400. +func TestNLBServiceUpdateRemoveSelectorOnPoollessService(t *testing.T) { + api := newNLBTestAPI(t) + api.serveNLB(selectorService(testSelectorSvcName, testSelectorSvcID, map[string]string{"role": "web"})) + + c := testNLBServiceUpdateCmd(testSelectorSvcName) + c.LabelSelector = map[string]string{"": ""} + cmd := newServiceUpdateFlagSet(t, c, "label-selector") + + err := c.CmdRun(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "has no Instance Pool") { + t.Fatalf("expected pool-less removal error, got: %v", err) + } + if n := api.called(http.MethodPut, nlbServicePath(testSelectorSvcID)); n > 0 { + t.Errorf("expected no update request to be issued, got %d requests", n) + } +} diff --git a/go.mod b/go.mod index 5bfc67307..6fe159d0b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 github.com/aws/smithy-go v1.27.3 github.com/dustin/go-humanize v1.0.1 - github.com/exoscale/egoscale/v3 v3.1.61 + github.com/exoscale/egoscale/v3 v3.1.63-0.20261029103206-84cbba477ed8 github.com/exoscale/openapi-cli-generator v1.2.0 github.com/fatih/camelcase v1.0.0 github.com/hashicorp/go-multierror v1.1.1 diff --git a/go.sum b/go.sum index 50ac8b472..54d508678 100644 --- a/go.sum +++ b/go.sum @@ -175,8 +175,8 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= -github.com/exoscale/egoscale/v3 v3.1.61 h1:aknaClvdOApFBD+wA7fGdYG2cSYXsXZWwmkWpRSIgQw= -github.com/exoscale/egoscale/v3 v3.1.61/go.mod h1:DUTgeubl5msPAo3SKFed04AxNhyTNOrCTJHZDRYLR10= +github.com/exoscale/egoscale/v3 v3.1.63-0.20261029103206-84cbba477ed8 h1:Fa96ZMDcdrWqExVHrb1f9ilTgYQxIywtnjxTYjFirJQ= +github.com/exoscale/egoscale/v3 v3.1.63-0.20261029103206-84cbba477ed8/go.mod h1:DUTgeubl5msPAo3SKFed04AxNhyTNOrCTJHZDRYLR10= github.com/exoscale/openapi-cli-generator v1.2.0 h1:xgTff1bInBP+JZCauD7Jq9GNBFoKK31Cnv5FIAcxtrk= github.com/exoscale/openapi-cli-generator v1.2.0/go.mod h1:TZBnbT7f3hJ5ImyUphJwRM+X5xF/zCQZ6o8a42gQeTs= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= diff --git a/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar b/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar new file mode 100644 index 000000000..971fc61bb --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar @@ -0,0 +1,43 @@ +# Test: exo compute load-balancer service label selector lifecycle +# Selector-only service create/show/update, selector removal on a +# pool-backed service, and client-side validation errors. +# TEST_ZONE and TEST_RUN_ID are injected by the API test runner. + +# TODO: remove this skip once label-selector support is deployed to the +# production API (service add currently fails with "missing keys 'instance-pool'"). +skip 'label selector API not yet deployed to production' + +# Setup: create the NLB used by all services in this scenario +exec exo --output-format json compute load-balancer create ${TEST_RUN_ID}-nlb --zone ${TEST_ZONE} +defer exo compute load-balancer delete --force ${TEST_RUN_ID}-nlb --zone ${TEST_ZONE} + +# Add a service with a label selector and no Instance Pool +exec exo --output-format json compute load-balancer service add ${TEST_RUN_ID}-nlb web --port 8053 --label-selector role=web --zone ${TEST_ZONE} +defer exo compute load-balancer service delete --force ${TEST_RUN_ID}-nlb web --zone ${TEST_ZONE} + +exec exo --output-format json compute load-balancer service show ${TEST_RUN_ID}-nlb web --zone ${TEST_ZONE} +stdout '"instance_pool_id":""' +stdout '"label_selector":{"role":"web"}' + +# Replace the selector with a different label +exec exo --output-format json compute load-balancer service update ${TEST_RUN_ID}-nlb web --label-selector role=api --zone ${TEST_ZONE} +stdout '"label_selector":{"role":"api"}' + +# Removing the selector is rejected for pool-less services (it would leave them with zero backends) +! exec exo compute load-balancer service update ${TEST_RUN_ID}-nlb web --label-selector '[=]' --zone ${TEST_ZONE} +stderr 'has no Instance Pool' + +# A service can combine an Instance Pool and a label selector... +exec exo --output-format json compute instance-pool create ${TEST_RUN_ID}-pool --zone ${TEST_ZONE} +defer exo compute instance-pool delete --force ${TEST_RUN_ID}-pool --zone ${TEST_ZONE} +exec exo --output-format json compute load-balancer service add ${TEST_RUN_ID}-nlb mix --port 8054 --instance-pool ${TEST_RUN_ID}-pool --label-selector role=web --zone ${TEST_ZONE} +defer exo compute load-balancer service delete --force ${TEST_RUN_ID}-nlb mix --zone ${TEST_ZONE} + +# ...and the selector can then be removed with --label-selector [=] +exec exo --output-format json compute load-balancer service update ${TEST_RUN_ID}-nlb mix --label-selector '[=]' --zone ${TEST_ZONE} +stdout '"label_selector":null' +stdout '"instance_pool_id":"[0-9a-f-]{36}"' + +# Adding a service with neither backend source fails before any API call +! exec exo compute load-balancer service add ${TEST_RUN_ID}-nlb nope --port 8055 --zone ${TEST_ZONE} +stderr 'either --instance-pool or --label-selector is required' diff --git a/vendor/github.com/exoscale/egoscale/v3/operations.go b/vendor/github.com/exoscale/egoscale/v3/operations.go index 0a7a5cf3a..10f240318 100644 --- a/vendor/github.com/exoscale/egoscale/v3/operations.go +++ b/vendor/github.com/exoscale/egoscale/v3/operations.go @@ -15838,7 +15838,9 @@ type AddServiceToLoadBalancerRequest struct { // Load Balancer Service healthcheck Healthcheck *LoadBalancerServiceHealthcheck `json:"healthcheck" validate:"required"` // Instance Pool - InstancePool *InstancePool `json:"instance-pool" validate:"required"` + InstancePool *InstancePool `json:"instance-pool,omitempty"` + // Load Balancer Service label selector + LabelSelector *LoadBalancerServiceLabelSelector `json:"label-selector,omitempty"` // Load Balancer Service name Name string `json:"name" validate:"required,gte=1,lte=255"` // Port exposed on the Load Balancer's public IP @@ -16010,6 +16012,8 @@ type UpdateLoadBalancerServiceRequest struct { Description *string `json:"description,omitempty" validate:"omitempty,lte=255"` // Load Balancer Service healthcheck Healthcheck *LoadBalancerServiceHealthcheck `json:"healthcheck,omitempty"` + // Load Balancer Service label selector + LabelSelector *LoadBalancerServiceLabelSelector `json:"label-selector,omitempty"` // Load Balancer Service name Name *string `json:"name,omitempty" validate:"omitempty,lte=255"` // Port exposed on the Load Balancer's public IP @@ -16076,7 +16080,8 @@ func (c Client) UpdateLoadBalancerService(ctx context.Context, id UUID, serviceI type ResetLoadBalancerServiceFieldField string const ( - ResetLoadBalancerServiceFieldFieldDescription ResetLoadBalancerServiceFieldField = "description" + ResetLoadBalancerServiceFieldFieldDescription ResetLoadBalancerServiceFieldField = "description" + ResetLoadBalancerServiceFieldFieldLabelSelector ResetLoadBalancerServiceFieldField = "label-selector" ) // Reset a Load Balancer Service field to its default value @@ -18484,6 +18489,8 @@ type CreateSKSNodepoolRequest struct { Labels SKSNodepoolLabels `json:"labels,omitempty"` // Nodepool name, lowercase only Name string `json:"name" validate:"required,gte=1,lte=255"` + // Expose the GPUs with the NVIDIA DRA driver instead of the NVIDIA device plugin. Requires a GPU instance type and Kubernetes 1.34 or greater. + NvidiaDraDriver *bool `json:"nvidia-dra-driver,omitempty"` // Nvidia MIG Profiles enabled NvidiaMigProfiles *NvidiaMigProfiles `json:"nvidia-mig-profiles,omitempty"` // Nodepool Private Networks @@ -18667,6 +18674,8 @@ type UpdateSKSNodepoolRequest struct { Labels SKSNodepoolLabels `json:"labels,omitempty"` // Nodepool name, lowercase only Name string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` + // Expose the GPUs with the NVIDIA DRA driver instead of the NVIDIA device plugin. Requires a GPU instance type and Kubernetes 1.34 or greater. + NvidiaDraDriver *bool `json:"nvidia-dra-driver,omitempty"` // Nvidia MIG Profiles enabled NvidiaMigProfiles *NvidiaMigProfiles `json:"nvidia-mig-profiles"` // Nodepool Private Networks @@ -18842,6 +18851,62 @@ func (c Client) ScaleSKSNodepool(ctx context.Context, id UUID, sksNodepoolID UUI return bodyresp, nil } +type SetSKSNodepoolTemplateRequest struct { + // Template reference + Template *TemplateRef `json:"template"` +} + +// Sets the template of the Nodepool instances, among the available templates of its Kubernetes version and variant (see `list-sks-nodepool-templates`). Without template (`null`), the Nodepool follows the latest template of its Kubernetes version. +func (c Client) SetSKSNodepoolTemplate(ctx context.Context, id UUID, sksNodepoolID UUID, req SetSKSNodepoolTemplateRequest) (*Operation, error) { + path := fmt.Sprintf("/sks-cluster/%v/nodepool/%v:set-template", id, sksNodepoolID) + + body, err := prepareJSONBody(req) + if err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: prepare JSON body: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, "PUT", c.serverEndpoint+path, body) + if err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: new request: %w", err) + } + + request.Header.Add("User-Agent", c.getUserAgent()) + + request.Header.Add("Content-Type", "application/json") + + if err := c.executeRequestInterceptors(ctx, request); err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: execute request editors: %w", err) + } + + if err := c.signRequest(request); err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: sign request: %w", err) + } + + if c.trace { + dumpRequest(request, "set-sks-nodepool-template") + } + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: http client do: %w", err) + } + + if c.trace { + dumpResponse(response) + } + + if err := handleHTTPErrorResp(response); err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: http response: %w", err) + } + + bodyresp := new(Operation) + if err := prepareJSONResponse(response, bodyresp); err != nil { + return nil, fmt.Errorf("SetSKSNodepoolTemplate: prepare JSON response: %w", err) + } + + return bodyresp, nil +} + // Rotate Exoscale CCM credentials func (c Client) RotateSKSCcmCredentials(ctx context.Context, id UUID) (*Operation, error) { path := fmt.Sprintf("/sks-cluster/%v/rotate-ccm-credentials", id) @@ -19118,6 +19183,96 @@ func (c Client) UpgradeSKSClusterServiceLevel(ctx context.Context, id UUID) (*Op return bodyresp, nil } +type ListSKSNodepoolTemplatesResponse struct { + SKSNodepoolTemplates []SKSNodepoolTemplate `json:"sks-nodepool-templates,omitempty"` +} + +// FindSKSNodepoolTemplate attempts to find an SKSNodepoolTemplate by nameOrID. +func (l ListSKSNodepoolTemplatesResponse) FindSKSNodepoolTemplate(nameOrID string) (SKSNodepoolTemplate, error) { + var result []SKSNodepoolTemplate + for i, elem := range l.SKSNodepoolTemplates { + if string(elem.Name) == nameOrID || string(elem.ID) == nameOrID { + result = append(result, l.SKSNodepoolTemplates[i]) + } + } + if len(result) == 1 { + return result[0], nil + } + + if len(result) > 1 { + return SKSNodepoolTemplate{}, fmt.Errorf("%q too many found in ListSKSNodepoolTemplatesResponse: %w", nameOrID, ErrConflict) + } + + return SKSNodepoolTemplate{}, fmt.Errorf("%q not found in ListSKSNodepoolTemplatesResponse: %w", nameOrID, ErrNotFound) +} + +type ListSKSNodepoolTemplatesVariant string + +const ( + ListSKSNodepoolTemplatesVariantStandard ListSKSNodepoolTemplatesVariant = "standard" + ListSKSNodepoolTemplatesVariantNvidia ListSKSNodepoolTemplatesVariant = "nvidia" +) + +type ListSKSNodepoolTemplatesOpt func(url.Values) + +func ListSKSNodepoolTemplatesWithVariant(variant ListSKSNodepoolTemplatesVariant) ListSKSNodepoolTemplatesOpt { + return func(q url.Values) { + q.Add("variant", fmt.Sprint(variant)) + } +} + +// List the available templates for a given kube version (major.minor or major.minor.patch), of the given variant (standard | nvidia) when provided, latest first +func (c Client) ListSKSNodepoolTemplates(ctx context.Context, kubeVersion string, opts ...ListSKSNodepoolTemplatesOpt) (*ListSKSNodepoolTemplatesResponse, error) { + path := fmt.Sprintf("/sks-template/%v", kubeVersion) + + request, err := http.NewRequestWithContext(ctx, "GET", c.serverEndpoint+path, nil) + if err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: new request: %w", err) + } + + request.Header.Add("User-Agent", c.getUserAgent()) + + if len(opts) > 0 { + q := request.URL.Query() + for _, opt := range opts { + opt(q) + } + request.URL.RawQuery = q.Encode() + } + + if err := c.executeRequestInterceptors(ctx, request); err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: execute request editors: %w", err) + } + + if err := c.signRequest(request); err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: sign request: %w", err) + } + + if c.trace { + dumpRequest(request, "list-sks-nodepool-templates") + } + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: http client do: %w", err) + } + + if c.trace { + dumpResponse(response) + } + + if err := handleHTTPErrorResp(response); err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: http response: %w", err) + } + + bodyresp := new(ListSKSNodepoolTemplatesResponse) + if err := prepareJSONResponse(response, bodyresp); err != nil { + return nil, fmt.Errorf("ListSKSNodepoolTemplates: prepare JSON response: %w", err) + } + + return bodyresp, nil +} + type GetActiveNodepoolTemplateResponse struct { ActiveTemplate UUID `json:"active-template,omitempty"` } @@ -20491,7 +20646,7 @@ func (l ListVpcsResponse) FindListVpcEntry(nameOrID string) (ListVpcEntry, error return ListVpcEntry{}, fmt.Errorf("%q not found in ListVpcsResponse: %w", nameOrID, ErrNotFound) } -// [BETA] List VPCs +// List VPCs func (c Client) ListVpcs(ctx context.Context) (*ListVpcsResponse, error) { path := "/vpc" @@ -20545,7 +20700,7 @@ type CreateVpcRequest struct { Name string `json:"name" validate:"required,gte=1,lte=255"` } -// [BETA] Create a VPC +// Create a VPC func (c Client) CreateVpc(ctx context.Context, req CreateVpcRequest) (*Operation, error) { path := "/vpc" @@ -20596,7 +20751,7 @@ func (c Client) CreateVpc(ctx context.Context, req CreateVpcRequest) (*Operation return bodyresp, nil } -// [BETA] Delete a VPC +// Delete a VPC func (c Client) DeleteVpc(ctx context.Context, id UUID) error { path := fmt.Sprintf("/vpc/%v", id) @@ -20636,7 +20791,7 @@ func (c Client) DeleteVpc(ctx context.Context, id UUID) error { return nil } -// [BETA] Retrieve VPC details +// Retrieve VPC details func (c Client) GetVpc(ctx context.Context, id UUID) (*Vpc, error) { path := fmt.Sprintf("/vpc/%v", id) @@ -20690,7 +20845,7 @@ type UpdateVpcRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` } -// [BETA] Update a VPC +// Update a VPC func (c Client) UpdateVpc(ctx context.Context, id UUID, req UpdateVpcRequest) (*Vpc, error) { path := fmt.Sprintf("/vpc/%v", id) @@ -20764,7 +20919,7 @@ func (l ListVpcRoutesResponse) FindListRouteEntry(id string) (ListRouteEntry, er return ListRouteEntry{}, fmt.Errorf("%q not found in ListVpcRoutesResponse: %w", id, ErrNotFound) } -// [BETA] List VPC routes +// List VPC routes func (c Client) ListVpcRoutes(ctx context.Context, vpcID UUID) (*ListVpcRoutesResponse, error) { path := fmt.Sprintf("/vpc/%v/route", vpcID) @@ -20831,7 +20986,7 @@ func (l ListSubnetsResponse) FindListSubnetEntry(nameOrID string) (ListSubnetEnt return ListSubnetEntry{}, fmt.Errorf("%q not found in ListSubnetsResponse: %w", nameOrID, ErrNotFound) } -// [BETA] List Subnets +// List Subnets func (c Client) ListSubnets(ctx context.Context, vpcID UUID) (*ListSubnetsResponse, error) { path := fmt.Sprintf("/vpc/%v/subnet", vpcID) @@ -20901,7 +21056,7 @@ type CreateSubnetRequest struct { Name string `json:"name" validate:"required,gte=1,lte=255"` } -// [BETA] Create a Subnet +// Create a Subnet func (c Client) CreateSubnet(ctx context.Context, vpcID UUID, req CreateSubnetRequest) (*Operation, error) { path := fmt.Sprintf("/vpc/%v/subnet", vpcID) @@ -20952,7 +21107,7 @@ func (c Client) CreateSubnet(ctx context.Context, vpcID UUID, req CreateSubnetRe return bodyresp, nil } -// [BETA] Delete a Subnet +// Delete a Subnet func (c Client) DeleteSubnet(ctx context.Context, vpcID UUID, id UUID) error { path := fmt.Sprintf("/vpc/%v/subnet/%v", vpcID, id) @@ -20992,7 +21147,7 @@ func (c Client) DeleteSubnet(ctx context.Context, vpcID UUID, id UUID) error { return nil } -// [BETA] Retrieve Subnet details +// Retrieve Subnet details func (c Client) GetSubnet(ctx context.Context, vpcID UUID, id UUID) (*Subnet, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v", vpcID, id) @@ -21044,7 +21199,7 @@ type UpdateSubnetRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` } -// [BETA] Update a Subnet +// Update a Subnet func (c Client) UpdateSubnet(ctx context.Context, vpcID UUID, id UUID, req UpdateSubnetRequest) (*Subnet, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v", vpcID, id) @@ -21102,7 +21257,7 @@ type AttachInstanceToSubnetRequest struct { Ipv4 net.IP `json:"ipv4,omitempty"` } -// [BETA] Attach a Compute instance to a Subnet +// Attach a Compute instance to a Subnet func (c Client) AttachInstanceToSubnet(ctx context.Context, vpcID UUID, subnetID UUID, req AttachInstanceToSubnetRequest) (*Operation, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v/attach", vpcID, subnetID) @@ -21158,7 +21313,7 @@ type DetachInstanceFromSubnetRequest struct { Instance *InstanceRef `json:"instance" validate:"required"` } -// [BETA] Detach a Compute instance from a Subnet +// Detach a Compute instance from a Subnet func (c Client) DetachInstanceFromSubnet(ctx context.Context, vpcID UUID, subnetID UUID, req DetachInstanceFromSubnetRequest) (*Operation, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v/detach", vpcID, subnetID) @@ -21232,7 +21387,7 @@ func (l ListRoutesResponse) FindListRouteEntry(id string) (ListRouteEntry, error return ListRouteEntry{}, fmt.Errorf("%q not found in ListRoutesResponse: %w", id, ErrNotFound) } -// [BETA] List Subnet routes +// List Subnet routes func (c Client) ListRoutes(ctx context.Context, vpcID UUID, subnetID UUID) (*ListRoutesResponse, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v/route", vpcID, subnetID) @@ -21285,7 +21440,7 @@ type CreateRouteRequest struct { Target string `json:"target" validate:"required"` } -// [BETA] Create a route +// Create a route func (c Client) CreateRoute(ctx context.Context, vpcID UUID, subnetID UUID, req CreateRouteRequest) (*Route, error) { path := fmt.Sprintf("/vpc/%v/subnet/%v/route", vpcID, subnetID) @@ -21336,7 +21491,7 @@ func (c Client) CreateRoute(ctx context.Context, vpcID UUID, subnetID UUID, req return bodyresp, nil } -// [BETA] Delete a route +// Delete a route func (c Client) DeleteRoute(ctx context.Context, vpcID UUID, subnetID UUID, id UUID) error { path := fmt.Sprintf("/vpc/%v/subnet/%v/route/%v", vpcID, subnetID, id) diff --git a/vendor/github.com/exoscale/egoscale/v3/schemas.go b/vendor/github.com/exoscale/egoscale/v3/schemas.go index b86e097c7..f3b96dc54 100644 --- a/vendor/github.com/exoscale/egoscale/v3/schemas.go +++ b/vendor/github.com/exoscale/egoscale/v3/schemas.go @@ -5252,6 +5252,8 @@ type LoadBalancerService struct { ID UUID `json:"id,omitempty"` // Instance Pool InstancePool *InstancePool `json:"instance-pool,omitempty"` + // Load Balancer Service label selector + LabelSelector *LoadBalancerServiceLabelSelector `json:"label-selector,omitempty"` // Load Balancer Service name Name string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` // Port exposed on the Load Balancer's public IP @@ -5292,6 +5294,11 @@ type LoadBalancerServiceHealthcheck struct { URI string `json:"uri,omitempty" validate:"omitempty,gte=1,lte=255"` } +// Load Balancer Service label selector +type LoadBalancerServiceLabelSelector struct { + Labels Labels `json:"labels" validate:"required"` +} + type ManagerType string const ( @@ -5972,6 +5979,8 @@ type SKSClusterRef struct { type SKSKubeconfigRequest struct { // List of roles. The certificate present in the Kubeconfig will have these roles set in the Org field. Groups []string `json:"groups" validate:"required"` + // Cluster and context name in the generated Kubeconfig (default: cluster ID) + Name string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` // Validity in seconds of the Kubeconfig user certificate (default: 30 days) Ttl int64 `json:"ttl,omitempty" validate:"omitempty,gt=0"` // User name in the generated Kubeconfig. The certificate present in the Kubeconfig will also have this name set for the CN field. @@ -6029,6 +6038,8 @@ type SKSNodepool struct { Labels SKSNodepoolLabels `json:"labels,omitempty"` // Nodepool name Name string `json:"name,omitempty" validate:"omitempty,gte=1,lte=255"` + // Expose the GPUs with the NVIDIA DRA driver instead of the NVIDIA device plugin. Requires a GPU instance type and Kubernetes 1.34 or greater. + NvidiaDraDriver *bool `json:"nvidia-dra-driver,omitempty"` // Nvidia MIG Profiles enabled NvidiaMigProfiles *NvidiaMigProfiles `json:"nvidia-mig-profiles,omitempty"` // Nodepool Private Networks @@ -6070,6 +6081,18 @@ type SKSNodepoolTaint struct { type SKSNodepoolTaints map[string]SKSNodepoolTaint +// SKS Nodepool template +type SKSNodepoolTemplate struct { + // Template registration date + CreatedAT time.Time `json:"created-at,omitempty"` + // Template ID + ID UUID `json:"id,omitempty"` + // Template name + Name string `json:"name,omitempty"` + // Template variant: standard | nvidia + Variant string `json:"variant,omitempty"` +} + // SKS Cluster OpenID config map type SKSOidc struct { // OpenID client ID diff --git a/vendor/modules.txt b/vendor/modules.txt index 31df4d70d..b3d595b27 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -202,7 +202,7 @@ github.com/dlclark/regexp2/syntax # github.com/dustin/go-humanize v1.0.1 ## explicit; go 1.16 github.com/dustin/go-humanize -# github.com/exoscale/egoscale/v3 v3.1.61 +# github.com/exoscale/egoscale/v3 v3.1.63-0.20261029103206-84cbba477ed8 ## explicit; go 1.25 github.com/exoscale/egoscale/v3 github.com/exoscale/egoscale/v3/credentials