From d2c0762dbff983230a5f8d06cbf06f4458b45044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Thu, 29 Oct 2026 14:01:16 +0100 Subject: [PATCH 1/3] wip(build): upgrade egoscale Temporary upgrade to github.com/exoscale/egoscale/v3@imiric/sc-205653/feat/nlb-label-selector --- go.mod | 2 +- go.sum | 4 +- .../exoscale/egoscale/v3/operations.go | 191 ++++++++++++++++-- .../exoscale/egoscale/v3/schemas.go | 23 +++ vendor/modules.txt | 2 +- 5 files changed, 200 insertions(+), 22 deletions(-) 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/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 From 2149c5d1f2e2d034757125c4b1640f426ef451e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Thu, 29 Oct 2026 15:17:46 +0100 Subject: [PATCH 2/3] feat(compute): select NLB service backends by instance labels The API now supports selecting NLB service backends by instance labels instead of relying on Instance Pool membership only. This is exposed by making `--instance-pool` optional and adding `--label-selector` to `service add` and `service update`. Requests that would leave a service with no backends are rejected before calling the API, so users get a clear error instead of a 400. Part of [sc-205653][1] [1]: https://app.shortcut.com/exoscale/story/205653/ AI-assisted: true Co-authored-by: qwen3.8-flash-next-iq3_s --- CHANGELOG.md | 1 + cmd/compute/load_balancer/nlb_helpers_test.go | 205 ++++++++++++++++++ cmd/compute/load_balancer/nlb_service_add.go | 74 ++++--- .../load_balancer/nlb_service_add_test.go | 126 +++++++++++ cmd/compute/load_balancer/nlb_service_show.go | 49 ++++- .../load_balancer/nlb_service_show_test.go | 67 ++++++ .../load_balancer/nlb_service_update.go | 42 ++-- .../load_balancer/nlb_service_update_test.go | 129 +++++++++++ .../compute/nlb_service_label_selector.txtar | 39 ++++ 9 files changed, 679 insertions(+), 53 deletions(-) create mode 100644 cmd/compute/load_balancer/nlb_helpers_test.go create mode 100644 cmd/compute/load_balancer/nlb_service_add_test.go create mode 100644 cmd/compute/load_balancer/nlb_service_show_test.go create mode 100644 cmd/compute/load_balancer/nlb_service_update_test.go create mode 100644 tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar 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/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..a9365a576 --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar @@ -0,0 +1,39 @@ +# 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. + +# 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' From 001ac858c6fcb713b68e1187be4f7059c61cf3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Tue, 3 Nov 2026 13:42:03 +0100 Subject: [PATCH 3/3] wip(test): skip E2E test temporarily Resolves https://github.com/exoscale/cli/pull/946#discussion_r4444319559 --- .../with-api/compute/nlb_service_label_selector.txtar | 4 ++++ 1 file changed, 4 insertions(+) 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 index a9365a576..971fc61bb 100644 --- a/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar +++ b/tests/e2e/scenarios/with-api/compute/nlb_service_label_selector.txtar @@ -3,6 +3,10 @@ # 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}