From 4ff4f08e8341e19e6d02eabb1590ed2752882d0a Mon Sep 17 00:00:00 2001 From: lukasmetzner Date: Thu, 23 Jul 2026 13:41:08 +0200 Subject: [PATCH 1/4] feat(load-balancer): support http timeout-idle annotation Add the `load-balancer.hetzner.cloud/http-timeout-idle` annotation to configure the idle timeout for the client and server side of an HTTP/HTTPS Load Balancer service. It is documented as the new `duration` type (a Go duration string, e.g. `30s`). Closes #1233 --- docs/reference/load_balancer_annotations.md | 2 ++ internal/annotation/load_balancer.go | 6 ++++++ internal/hcops/load_balancer.go | 15 +++++++++++++++ internal/hcops/load_balancer_internal_test.go | 3 +++ tools/load_balancer_annotations.md.tmpl | 1 + 5 files changed, 27 insertions(+) diff --git a/docs/reference/load_balancer_annotations.md b/docs/reference/load_balancer_annotations.md index 367c5fa09..633543729 100644 --- a/docs/reference/load_balancer_annotations.md +++ b/docs/reference/load_balancer_annotations.md @@ -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 | | --- | --- | --- | --- | --- | @@ -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. | | `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". | diff --git a/internal/annotation/load_balancer.go b/internal/annotation/load_balancer.go index df95f53db..63b09a5d5 100644 --- a/internal/annotation/load_balancer.go +++ b/internal/annotation/load_balancer.go @@ -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. + // + // Type: duration + LBSvcHTTPTimeoutIdle Name = "load-balancer.hetzner.cloud/http-timeout-idle" + // LBSvcHTTPCertificateType defines the type of certificate the Load // Balancer should use. // diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index b4012bf1f..34155fc3f 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -1042,6 +1042,7 @@ type hclbServiceOptsBuilder struct { Certificates []*hcloud.Certificate RedirectHTTP *bool StickySessions *bool + TimeoutIdle *time.Duration } addHTTP bool healthCheckOpts struct { @@ -1115,6 +1116,18 @@ 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 + return nil + }) + b.do(func() error { certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service) if ok && certtyp == string(hcloud.CertificateTypeManaged) { @@ -1367,6 +1380,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 { @@ -1421,6 +1435,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 { diff --git a/internal/hcops/load_balancer_internal_test.go b/internal/hcops/load_balancer_internal_test.go index d7be5ccd2..ebbd75b3b 100644 --- a/internal/hcops/load_balancer_internal_test.go +++ b/internal/hcops/load_balancer_internal_test.go @@ -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), @@ -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, @@ -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, diff --git a/tools/load_balancer_annotations.md.tmpl b/tools/load_balancer_annotations.md.tmpl index cc50d3251..5f56df870 100644 --- a/tools/load_balancer_annotations.md.tmpl +++ b/tools/load_balancer_annotations.md.tmpl @@ -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}} From 4b2ca4ac942cd3d612623fed302ffb12b2f30989 Mon Sep 17 00:00:00 2001 From: lukasmetzner Date: Thu, 23 Jul 2026 15:43:15 +0200 Subject: [PATCH 2/4] fix: add http opts --- internal/hcops/load_balancer.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index 34155fc3f..83016aff8 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -1125,6 +1125,7 @@ func (b *hclbServiceOptsBuilder) extract() { return fmt.Errorf("%s: %w", op, err) } b.httpOpts.TimeoutIdle = &timeout + b.addHTTP = true return nil }) From dd4d6d4144252ffc4c40323c31600023b5b77bc6 Mon Sep 17 00:00:00 2001 From: lukasmetzner Date: Mon, 27 Jul 2026 08:40:38 +0200 Subject: [PATCH 3/4] docs: document allowed range --- docs/reference/load_balancer_annotations.md | 2 +- internal/annotation/load_balancer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/load_balancer_annotations.md b/docs/reference/load_balancer_annotations.md index 633543729..8fd25e85f 100644 --- a/docs/reference/load_balancer_annotations.md +++ b/docs/reference/load_balancer_annotations.md @@ -29,7 +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. | +| `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". | diff --git a/internal/annotation/load_balancer.go b/internal/annotation/load_balancer.go index 63b09a5d5..5ea68931f 100644 --- a/internal/annotation/load_balancer.go +++ b/internal/annotation/load_balancer.go @@ -161,7 +161,7 @@ const ( LBSvcHTTPCookieLifetime Name = "load-balancer.hetzner.cloud/http-cookie-lifetime" // LBSvcHTTPTimeoutIdle specifies the idle timeout for the client and - // server side. + // server side. Must be between 30s and 300s. // // Type: duration LBSvcHTTPTimeoutIdle Name = "load-balancer.hetzner.cloud/http-timeout-idle" From 74923a47519e76fbe82241ab039c842dcc795e88 Mon Sep 17 00:00:00 2001 From: lukasmetzner Date: Tue, 28 Jul 2026 17:18:24 +0200 Subject: [PATCH 4/4] feat(load-balancer): improve logging on invalid input --- internal/hcops/certificates.go | 2 +- internal/hcops/errors.go | 28 ++++++++++- internal/hcops/errors_internal_test.go | 68 ++++++++++++++++++++++++++ internal/hcops/load_balancer.go | 18 +++---- internal/hcops/load_balancer_test.go | 39 +++++++++++++++ 5 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 internal/hcops/errors_internal_test.go diff --git a/internal/hcops/certificates.go b/internal/hcops/certificates.go index c8ae0b188..4e9270c1a 100644 --- a/internal/hcops/certificates.go +++ b/internal/hcops/certificates.go @@ -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) diff --git a/internal/hcops/errors.go b/internal/hcops/errors.go index 93db97d46..a8a9abe0a 100644 --- a/internal/hcops/errors.go +++ b/internal/hcops/errors.go @@ -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 @@ -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 { + 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, ", ")) +} diff --git a/internal/hcops/errors_internal_test.go b/internal/hcops/errors_internal_test.go new file mode 100644 index 000000000..4cd16b22a --- /dev/null +++ b/internal/hcops/errors_internal_test.go @@ -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)) + }) + } +} diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index 83016aff8..177f567f4 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -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) @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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) @@ -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)) } } diff --git a/internal/hcops/load_balancer_test.go b/internal/hcops/load_balancer_test.go index 04ed23dd5..ca1d52e4f 100644 --- a/internal/hcops/load_balancer_test.go +++ b/internal/hcops/load_balancer_test.go @@ -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{