Skip to content

Commit cb2e541

Browse files
committed
feat(alb): add update
1 parent fee2070 commit cb2e541

File tree

4 files changed

+586
-0
lines changed

4 files changed

+586
-0
lines changed

internal/cmd/beta/alb/alb.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/create"
55
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list"
66
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template"
7+
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/update"
78
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
89
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
910

@@ -29,5 +30,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
2930
list.NewCmd(p),
3031
template.NewCmd(p),
3132
create.NewCmd(p),
33+
update.NewCmd(p),
3234
)
3335
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
{
2+
"externalAddress": "10.100.42.1",
3+
"listeners": [
4+
{
5+
"displayName": "listener1",
6+
"http": {},
7+
"https": {
8+
"certificateConfig": {
9+
"certificateIds": [
10+
"cert-1",
11+
"cert-2",
12+
"cert-3"
13+
]
14+
}
15+
},
16+
"port": 443,
17+
"protocol": "PROTOCOL_HTTPS",
18+
"rules": [
19+
{
20+
"host": "front.facing.host",
21+
"http": {
22+
"subRules": [
23+
{
24+
"cookiePersistence": {
25+
"name": "cookie1",
26+
"ttl": "120s"
27+
},
28+
"headers": [
29+
{
30+
"name": "testheader1",
31+
"exactMatch": "X-test-header1"
32+
},
33+
{
34+
"name": "testheader2",
35+
"exactMatch": "X-test-header2"
36+
},
37+
{
38+
"name": "testheader3",
39+
"exactMatch": "X-test-header3"
40+
}
41+
],
42+
"pathPrefix": "/foo",
43+
"queryParameters": [
44+
{
45+
"name": "query-param",
46+
"exactMatch": "q"
47+
},
48+
{
49+
"name": "region",
50+
"exactMatch": "region"
51+
}
52+
],
53+
"targetPool": "my-target-pool",
54+
"webSocket": false
55+
}
56+
]
57+
}
58+
}
59+
]
60+
}
61+
],
62+
"name": "my-load-balancer",
63+
"networks": [
64+
{
65+
"networkId": "00000000-0000-0000-0000-000000000000",
66+
"role": "ROLE_LISTENERS_AND_TARGETS"
67+
},
68+
{
69+
"networkId": "00000000-0000-0000-0000-000000000001",
70+
"role": "ROLE_LISTENERS_AND_TARGETS"
71+
}
72+
],
73+
"options": {
74+
"accessControl": {
75+
"allowedSourceRanges": [
76+
"192.168.42.0-192.168.42.10",
77+
"192.168.54.0-192.168.54.10"
78+
]
79+
},
80+
"ephemeralAddress": true,
81+
"observability": {
82+
"logs": {
83+
"credentialsRef": "my-credentials",
84+
"pushUrl": "https://my.observability.host/<observability-instance-id>/loki/api/v1/push"
85+
},
86+
"metrics": {
87+
"credentialsRef": "my-credentials",
88+
"pushUrl": "https://my.observability.host/<observability-instance-id>/<argus-instance-id>/api/v1/receive"
89+
}
90+
},
91+
"privateNetworkOnly": true
92+
},
93+
"planId": "p10",
94+
"targetPools": [
95+
{
96+
"activeHealthCheck": {
97+
"healthyThreshold": 3,
98+
"httpHealthChecks": {
99+
"okStatuses": [
100+
"200",
101+
"204"
102+
],
103+
"path": "/health"
104+
},
105+
"interval": "10s",
106+
"intervalJitter": "3s",
107+
"timeout": "5s",
108+
"unhealthyThreshold": 1
109+
},
110+
"name": "my-target-pool",
111+
"targetPort": 5732,
112+
"targets": [
113+
{
114+
"displayName": "my-target1",
115+
"ip": "192.11.2.5"
116+
}
117+
],
118+
"tlsConfig": {
119+
"customCa": "my.private.ca",
120+
"enabled": true,
121+
"skipCertificateValidation": false
122+
}
123+
}
124+
]
125+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package update
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"strings"
10+
11+
"github.com/goccy/go-yaml"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
17+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
18+
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
19+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client"
20+
"github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
21+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
22+
23+
"github.com/spf13/cobra"
24+
"github.com/stackitcloud/stackit-sdk-go/services/alb"
25+
"github.com/stackitcloud/stackit-sdk-go/services/alb/wait"
26+
)
27+
28+
const (
29+
configurationFlag = "configuration"
30+
)
31+
32+
type inputModel struct {
33+
*globalflags.GlobalFlagModel
34+
Configuration *string
35+
}
36+
37+
func NewCmd(p *print.Printer) *cobra.Command {
38+
cmd := &cobra.Command{
39+
Use: "update",
40+
Short: "Updates an application loadbalancer",
41+
Long: "Updates an application loadbalancer.",
42+
Args: args.NoArgs,
43+
Example: examples.Build(
44+
examples.NewExample(
45+
`Update an application loadbalancer from a configuration file`,
46+
"$ stackit beta alb update --configuration my-loadbalancer.json"),
47+
),
48+
RunE: func(cmd *cobra.Command, _ []string) error {
49+
ctx := context.Background()
50+
model, err := parseInput(p, cmd)
51+
if err != nil {
52+
return err
53+
}
54+
55+
// Configure API client
56+
apiClient, err := client.ConfigureClient(p)
57+
if err != nil {
58+
return err
59+
}
60+
61+
projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
62+
if err != nil {
63+
p.Debug(print.ErrorLevel, "get project name: %v", err)
64+
projectLabel = model.ProjectId
65+
}
66+
67+
if !model.AssumeYes {
68+
prompt := fmt.Sprintf("Are you sure you want to update an application loadbalancer for project %q?", projectLabel)
69+
err = p.PromptForConfirmation(prompt)
70+
if err != nil {
71+
return err
72+
}
73+
}
74+
75+
// Call API
76+
req, err := buildRequest(ctx, model, apiClient)
77+
if err != nil {
78+
return err
79+
}
80+
resp, err := req.Execute()
81+
if err != nil {
82+
return fmt.Errorf("update application loadbalancer: %w", err)
83+
}
84+
85+
// Wait for async operation, if async mode not enabled
86+
if !model.Async {
87+
s := spinner.New(p)
88+
s.Start("updating loadbalancer")
89+
_, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx)
90+
if err != nil {
91+
return fmt.Errorf("wait for loadbalancer creation: %w", err)
92+
}
93+
s.Stop()
94+
}
95+
96+
return outputResult(p, model, projectLabel, resp)
97+
},
98+
}
99+
configureFlags(cmd)
100+
return cmd
101+
}
102+
103+
func configureFlags(cmd *cobra.Command) {
104+
cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file")
105+
err := flags.MarkFlagsRequired(cmd, configurationFlag)
106+
cobra.CheckErr(err)
107+
}
108+
109+
func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
110+
globalFlags := globalflags.Parse(p, cmd)
111+
if globalFlags.ProjectId == "" {
112+
return nil, &errors.ProjectIdError{}
113+
}
114+
115+
model := inputModel{
116+
GlobalFlagModel: globalFlags,
117+
Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag),
118+
}
119+
120+
if p.IsVerbosityDebug() {
121+
modelStr, err := print.BuildDebugStrFromInputModel(model)
122+
if err != nil {
123+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
124+
} else {
125+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
126+
}
127+
}
128+
129+
return &model, nil
130+
}
131+
132+
func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateLoadBalancerRequest, err error) {
133+
payload, err := readPayload(ctx, model)
134+
if err != nil {
135+
return req, err
136+
}
137+
req = apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.Region, *payload.Name)
138+
return req.UpdateLoadBalancerPayload(payload), nil
139+
}
140+
141+
func readPayload(ctx context.Context, model *inputModel) (payload alb.UpdateLoadBalancerPayload, err error) {
142+
if model.Configuration == nil {
143+
return payload, fmt.Errorf("no configuration file defined")
144+
}
145+
file, err := os.Open(*model.Configuration)
146+
if err != nil {
147+
return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err)
148+
}
149+
defer file.Close()
150+
151+
if strings.HasSuffix(*model.Configuration, ".yaml") {
152+
decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler())
153+
if err := decoder.Decode(&payload); err != nil {
154+
return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err)
155+
}
156+
} else if strings.HasSuffix(*model.Configuration, ".json") {
157+
decoder := json.NewDecoder(bufio.NewReader(file))
158+
if err := decoder.Decode(&payload); err != nil {
159+
return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err)
160+
}
161+
} else {
162+
return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", err)
163+
}
164+
165+
return payload, nil
166+
}
167+
168+
func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.LoadBalancer) error {
169+
if resp == nil {
170+
return fmt.Errorf("update loadbalancer response is empty")
171+
}
172+
switch model.OutputFormat {
173+
case print.JSONOutputFormat:
174+
details, err := json.MarshalIndent(resp, "", " ")
175+
if err != nil {
176+
return fmt.Errorf("marshal loadbalancer: %w", err)
177+
}
178+
p.Outputln(string(details))
179+
180+
return nil
181+
case print.YAMLOutputFormat:
182+
details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
183+
if err != nil {
184+
return fmt.Errorf("marshal loadbalancer: %w", err)
185+
}
186+
p.Outputln(string(details))
187+
188+
return nil
189+
default:
190+
operationState := "Updated"
191+
if model.Async {
192+
operationState = "Triggered creation of"
193+
}
194+
p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name))
195+
return nil
196+
}
197+
}

0 commit comments

Comments
 (0)