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
2 changes: 2 additions & 0 deletions docs/reference/load_balancer_annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page contains all annotations, which can be specified at a Service of type

- Read-only annotations are set by the Cloud Controller Manager.
- Enums are depicted in the `Type` column and possible options are separated via the pipe symbol `|`.
- The `duration` type is a Go duration string, i.e. a sequence of decimal numbers each with a unit suffix such as `300ms`, `30s`, `5m` or `1h` (see [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration)).

| Name | Type | Default | Read-only | Description |
| --- | --- | --- | --- | --- |
Expand All @@ -28,6 +29,7 @@ This page contains all annotations, which can be specified at a Service of type
| `load-balancer.hetzner.cloud/uses-proxyprotocol` | `bool` | `false` | `No` | Specifies if the Load Balancer services should use the proxy protocol. |
| `load-balancer.hetzner.cloud/http-cookie-name` | `string` | `-` | `No` | Specifies the cookie name when using HTTP or HTTPS as protocol. |
| `load-balancer.hetzner.cloud/http-cookie-lifetime` | `int` | `-` | `No` | Specifies the lifetime of the HTTP cookie. |
| `load-balancer.hetzner.cloud/http-timeout-idle` | `duration` | `-` | `No` | Specifies the idle timeout for the client and server side. Must be between 30s and 300s. |
| `load-balancer.hetzner.cloud/certificate-type` | `uploaded \| managed` | `uploaded` | `No` | Defines the type of certificate the Load Balancer should use. |
| `load-balancer.hetzner.cloud/http-certificates` | `string` | `-` | `No` | A comma separated list of IDs or Names of Certificates assigned to the service. |
| `load-balancer.hetzner.cloud/http-managed-certificate-name` | `string` | `-` | `No` | Contains the name of the managed certificate to create by the Cloud Controller manager. Ignored if `load-balancer.hetzner.cloud/certificate-type` is missing or set to "uploaded". |
Expand Down
6 changes: 6 additions & 0 deletions internal/annotation/load_balancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ const (
// Type: int
LBSvcHTTPCookieLifetime Name = "load-balancer.hetzner.cloud/http-cookie-lifetime"

// LBSvcHTTPTimeoutIdle specifies the idle timeout for the client and
// server side. Must be between 30s and 300s.
//
// Type: duration
LBSvcHTTPTimeoutIdle Name = "load-balancer.hetzner.cloud/http-timeout-idle"

// LBSvcHTTPCertificateType defines the type of certificate the Load
// Balancer should use.
//
Expand Down
2 changes: 1 addition & 1 deletion internal/hcops/certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (co *CertificateOps) CreateManagedCertificate(
return fmt.Errorf("%s: %w", op, ErrAlreadyExists)
}
if err != nil {
return fmt.Errorf("%s: %w", op, err)
return fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}

err = co.ActionClient.WaitFor(ctx, result.Action)
Expand Down
28 changes: 27 additions & 1 deletion internal/hcops/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package hcops

import "errors"
import (
"errors"
"fmt"
"strings"

"github.com/hetznercloud/hcloud-go/v2/hcloud"
)

var (
// ErrNotFound signals that an item was not found by the Hetzner Cloud
Expand All @@ -16,3 +22,23 @@ var (
// resource already exists.
ErrAlreadyExists = errors.New("already exists")
)

// withInvalidInputFields adds the validation errors of an 'invalid_input' API
// error to the error message.
func withInvalidInputFields(err error) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we can't use the hcloud helper here as long as we use klog.

apiErr, ok := errors.AsType[hcloud.Error](err)
if !ok {
return err
}
details, ok := apiErr.Details.(hcloud.ErrorDetailsInvalidInput)
if !ok || len(details.Fields) == 0 {
return err
}

fields := make([]string, 0, len(details.Fields))
for _, field := range details.Fields {
fields = append(fields, fmt.Sprintf("%s (%s)", field.Name, strings.Join(field.Messages, ", ")))
}

return fmt.Errorf("%w: invalid fields: %s", err, strings.Join(fields, ", "))
}
68 changes: 68 additions & 0 deletions internal/hcops/errors_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package hcops

import (
"errors"
"fmt"
"testing"

"github.com/stretchr/testify/assert"

"github.com/hetznercloud/hcloud-go/v2/hcloud"
)

func TestWithInvalidInputFields(t *testing.T) {
invalidInput := hcloud.Error{
Code: hcloud.ErrorCodeInvalidInput,
Message: "invalid input in field 'http'",
Details: hcloud.ErrorDetailsInvalidInput{
Fields: []hcloud.ErrorDetailsInvalidInputField{
{Name: "http.timeout_idle", Messages: []string{"must be between 5 and 3600"}},
},
},
}

tests := []struct {
name string
err error
expected string
}{
{
name: "invalid input error",
err: invalidInput,
expected: "invalid input in field 'http' (invalid_input): invalid fields: http.timeout_idle (must be between 5 and 3600)",
},
{
name: "wrapped invalid input error",
err: fmt.Errorf("hcops/LoadBalancerOps.ReconcileHCLBServices: %w", invalidInput),
expected: "hcops/LoadBalancerOps.ReconcileHCLBServices: invalid input in field 'http' (invalid_input): invalid fields: http.timeout_idle (must be between 5 and 3600)",
},
{
name: "invalid input error without details",
err: hcloud.Error{
Code: hcloud.ErrorCodeInvalidInput,
Message: "invalid input",
},
expected: "invalid input (invalid_input)",
},
{
name: "other api error",
err: hcloud.Error{Code: hcloud.ErrorCodeNotFound, Message: "not found"},
expected: "not found (not_found)",
},
{
name: "other error",
err: errors.New("something went wrong"),
expected: "something went wrong",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := withInvalidInputFields(tt.err)

assert.EqualError(t, err, tt.expected)
// The API error must stay accessible for the callers.
assert.Equal(t, hcloud.IsError(tt.err, hcloud.ErrorCodeInvalidInput), hcloud.IsError(err, hcloud.ErrorCodeInvalidInput))
})
}
}
34 changes: 25 additions & 9 deletions internal/hcops/load_balancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func (l *LoadBalancerOps) Create(

result, _, err := l.LBClient.Create(ctx, opts)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
return nil, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
if err := l.ActionClient.WaitFor(ctx, result.Action); err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
Expand Down Expand Up @@ -302,7 +302,7 @@ func (l *LoadBalancerOps) changeHCLBInfo(ctx context.Context, lb *hcloud.LoadBal

updated, _, err := l.LBClient.Update(ctx, lb, opts)
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
lb.Name = updated.Name
lb.Labels = updated.Labels
Expand All @@ -326,7 +326,7 @@ func (l *LoadBalancerOps) changeIPv4RDNS(ctx context.Context, lb *hcloud.LoadBal

action, _, err := l.LBClient.ChangeDNSPtr(ctx, lb, lb.PublicNet.IPv4.IP.String(), &rdns)
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
err = l.ActionClient.WaitFor(ctx, action)
if err != nil {
Expand All @@ -351,7 +351,7 @@ func (l *LoadBalancerOps) changeIPv6RDNS(ctx context.Context, lb *hcloud.LoadBal

action, _, err := l.LBClient.ChangeDNSPtr(ctx, lb, lb.PublicNet.IPv6.IP.String(), &rdns)
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
err = l.ActionClient.WaitFor(ctx, action)
if err != nil {
Expand Down Expand Up @@ -382,7 +382,7 @@ func (l *LoadBalancerOps) changeAlgorithm(ctx context.Context, lb *hcloud.LoadBa
opts := hcloud.LoadBalancerChangeAlgorithmOpts{Type: at}
action, _, err := l.LBClient.ChangeAlgorithm(ctx, lb, opts)
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
err = l.ActionClient.WaitFor(ctx, action)
if err != nil {
Expand All @@ -409,7 +409,7 @@ func (l *LoadBalancerOps) changeType(ctx context.Context, lb *hcloud.LoadBalance
opts := hcloud.LoadBalancerChangeTypeOpts{LoadBalancerType: &hcloud.LoadBalancerType{Name: lt}}
action, _, err := l.LBClient.ChangeType(ctx, lb, opts)
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
err = l.ActionClient.WaitFor(ctx, action)
if err != nil {
Expand Down Expand Up @@ -515,7 +515,7 @@ func (l *LoadBalancerOps) attachToNetwork(ctx context.Context, lb *hcloud.LoadBa
a, _, err = l.LBClient.AttachToNetwork(ctx, lb, opts)
}
if err != nil {
return false, fmt.Errorf("%s: %w", op, err)
return false, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}

if err := l.ActionClient.WaitFor(ctx, a); err != nil {
Expand Down Expand Up @@ -954,7 +954,7 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
}
action, _, err = l.LBClient.UpdateService(ctx, lb, b.listenPort, updOpts)
if err != nil {
return changed, fmt.Errorf("%s: %w", op, err)
return changed, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
} else {
klog.InfoS("add service", "op", op, "port", portNo, "loadBalancerID", lb.ID)
Expand All @@ -965,7 +965,7 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
}
action, _, err = l.LBClient.AddService(ctx, lb, addOpts)
if err != nil {
return changed, fmt.Errorf("%s: %w", op, err)
return changed, fmt.Errorf("%s: %w", op, withInvalidInputFields(err))
}
}

Expand Down Expand Up @@ -1042,6 +1042,7 @@ type hclbServiceOptsBuilder struct {
Certificates []*hcloud.Certificate
RedirectHTTP *bool
StickySessions *bool
TimeoutIdle *time.Duration
}
addHTTP bool
healthCheckOpts struct {
Expand Down Expand Up @@ -1115,6 +1116,19 @@ func (b *hclbServiceOptsBuilder) extract() {
return nil
})

b.do(func() error {
timeout, err := annotation.LBSvcHTTPTimeoutIdle.DurationFromService(b.Service)
if errors.Is(err, annotation.ErrNotSet) {
return nil
}
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
b.httpOpts.TimeoutIdle = &timeout
b.addHTTP = true
return nil
})

b.do(func() error {
certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service)
if ok && certtyp == string(hcloud.CertificateTypeManaged) {
Expand Down Expand Up @@ -1367,6 +1381,7 @@ func (b *hclbServiceOptsBuilder) buildAddServiceOpts() (hcloud.LoadBalancerAddSe
Certificates: b.httpOpts.Certificates,
RedirectHTTP: b.httpOpts.RedirectHTTP,
StickySessions: b.httpOpts.StickySessions,
TimeoutIdle: b.httpOpts.TimeoutIdle,
}
}
if b.addHealthCheck {
Expand Down Expand Up @@ -1421,6 +1436,7 @@ func (b *hclbServiceOptsBuilder) buildUpdateServiceOpts() (hcloud.LoadBalancerUp
RedirectHTTP: b.httpOpts.RedirectHTTP,
Certificates: b.httpOpts.Certificates,
StickySessions: b.httpOpts.StickySessions,
TimeoutIdle: b.httpOpts.TimeoutIdle,
}
}
if b.addHealthCheck {
Expand Down
3 changes: 3 additions & 0 deletions internal/hcops/load_balancer_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func TestHCLBServiceOptsBuilder(t *testing.T) {
annotation.LBSvcHTTPCertificates: "1,3",
annotation.LBSvcRedirectHTTP: "true",
annotation.LBSvcHTTPStickySessions: "true",
annotation.LBSvcHTTPTimeoutIdle: "30s",
},
expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{
ListenPort: new(82),
Expand All @@ -131,6 +132,7 @@ func TestHCLBServiceOptsBuilder(t *testing.T) {
Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 3}},
RedirectHTTP: new(true),
StickySessions: new(true),
TimeoutIdle: hcloud.Ptr(30 * time.Second),
},
HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
Protocol: hcloud.LoadBalancerServiceProtocolTCP,
Expand All @@ -146,6 +148,7 @@ func TestHCLBServiceOptsBuilder(t *testing.T) {
Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 3}},
RedirectHTTP: new(true),
StickySessions: new(true),
TimeoutIdle: hcloud.Ptr(30 * time.Second),
},
HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{
Protocol: hcloud.LoadBalancerServiceProtocolTCP,
Expand Down
39 changes: 39 additions & 0 deletions internal/hcops/load_balancer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,45 @@ func TestLoadBalancerOps_ReconcileHCLBServices(t *testing.T) {
assert.True(t, changed)
},
},
{
name: "add service rejected by the API",
servicePorts: []corev1.ServicePort{
{Port: 80, NodePort: 8080},
},
initialLB: &hcloud.LoadBalancer{
ID: 4,
LoadBalancerType: &hcloud.LoadBalancerType{
MaxTargets: 25,
},
},
mock: func(_ *testing.T, tt *LBReconcilementTestCase) {
opts := hcloud.LoadBalancerAddServiceOpts{
Protocol: hcloud.LoadBalancerServiceProtocolTCP,
ListenPort: new(80),
DestinationPort: new(8080),
HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
Protocol: hcloud.LoadBalancerServiceProtocolTCP,
Port: new(8080),
},
}
tt.fx.MockAddService(opts, tt.initialLB, hcloud.Error{
Code: hcloud.ErrorCodeInvalidInput,
Message: "invalid input in field 'http'",
Details: hcloud.ErrorDetailsInvalidInput{
Fields: []hcloud.ErrorDetailsInvalidInputField{
{Name: "http.timeout_idle", Messages: []string{"must be between 5 and 3600"}},
},
},
})
},
perform: func(t *testing.T, tt *LBReconcilementTestCase) {
changed, err := tt.fx.LBOps.ReconcileHCLBServices(tt.fx.Ctx, tt.initialLB, tt.service)
assert.EqualError(t, err,
"hcops/LoadBalancerOps.ReconcileHCLBServices: invalid input in field 'http' (invalid_input): "+
"invalid fields: http.timeout_idle (must be between 5 and 3600)")
assert.False(t, changed)
},
},
{
name: "reference TLS certificate by id",
servicePorts: []corev1.ServicePort{
Expand Down
1 change: 1 addition & 0 deletions tools/load_balancer_annotations.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ This page contains all annotations, which can be specified at a Service of type

- Read-only annotations are set by the Cloud Controller Manager.
- Enums are depicted in the `Type` column and possible options are separated via the pipe symbol `|`.
- The `duration` type is a Go duration string, i.e. a sequence of decimal numbers each with a unit suffix such as `300ms`, `30s`, `5m` or `1h` (see [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration)).

{{.ConstTable}}
Loading