Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
205 changes: 205 additions & 0 deletions cmd/compute/load_balancer/nlb_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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 "<METHOD> <path>".
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
}
74 changes: 45 additions & 29 deletions cmd/compute/load_balancer/nlb_service_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
Loading