Skip to content

Commit 8dbe427

Browse files
authored
Add a field specifying when to delete log files
The Kubernetes "duration" format is similar to RFC 3339 and time.Duration but also handles days and weeks. Issue: PGO-2197
1 parent 88130ca commit 8dbe427

10 files changed

+373
-2
lines changed

config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -1949,6 +1949,18 @@ spec:
19491949
items:
19501950
type: string
19511951
type: array
1952+
retentionPeriod:
1953+
description: |-
1954+
How long to retain log files locally. An RFC 3339 duration or a number
1955+
and unit: `3d`, `4 weeks`, `12 hr`, etc.
1956+
format: duration
1957+
maxLength: 20
1958+
minLength: 1
1959+
pattern: ^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$
1960+
type: string
1961+
x-kubernetes-validations:
1962+
- message: must be at least one hour
1963+
rule: duration("1h") <= self && self <= duration("8760h")
19521964
type: object
19531965
resources:
19541966
description: Resources holds the resource requirements for the

config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -11482,6 +11482,18 @@ spec:
1148211482
items:
1148311483
type: string
1148411484
type: array
11485+
retentionPeriod:
11486+
description: |-
11487+
How long to retain log files locally. An RFC 3339 duration or a number
11488+
and unit: `3d`, `4 weeks`, `12 hr`, etc.
11489+
format: duration
11490+
maxLength: 20
11491+
minLength: 1
11492+
pattern: ^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$
11493+
type: string
11494+
x-kubernetes-validations:
11495+
- message: must be at least one hour
11496+
rule: duration("1h") <= self && self <= duration("8760h")
1148511497
type: object
1148611498
resources:
1148711499
description: Resources holds the resource requirements for the

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
k8s.io/apimachinery v0.31.0
2828
k8s.io/client-go v0.31.0
2929
k8s.io/component-base v0.31.0
30+
k8s.io/kube-openapi v0.0.0-20240521193020-835d969ad83a
3031
sigs.k8s.io/controller-runtime v0.19.3
3132
sigs.k8s.io/yaml v1.4.0
3233
)
@@ -120,7 +121,6 @@ require (
120121
k8s.io/apiextensions-apiserver v0.31.0 // indirect
121122
k8s.io/apiserver v0.31.0 // indirect
122123
k8s.io/klog/v2 v2.130.1 // indirect
123-
k8s.io/kube-openapi v0.0.0-20240521193020-835d969ad83a // indirect
124124
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
125125
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect
126126
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2021 - 2025 Crunchy Data Solutions, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package validation
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"gotest.tools/v3/assert"
12+
apierrors "k8s.io/apimachinery/pkg/api/errors"
13+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
15+
"sigs.k8s.io/yaml"
16+
17+
"github.com/crunchydata/postgres-operator/internal/controller/runtime"
18+
"github.com/crunchydata/postgres-operator/internal/testing/require"
19+
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
20+
)
21+
22+
func TestPGAdminInstrumentation(t *testing.T) {
23+
ctx := context.Background()
24+
cc := require.Kubernetes(t)
25+
t.Parallel()
26+
27+
namespace := require.Namespace(t, cc)
28+
base := v1beta1.NewPGAdmin()
29+
base.Namespace = namespace.Name
30+
base.Name = "pgadmin-instrumentation"
31+
32+
assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll),
33+
"expected this base to be valid")
34+
35+
t.Run("LogsRetentionPeriod", func(t *testing.T) {
36+
pgadmin := base.DeepCopy()
37+
assert.NilError(t, yaml.UnmarshalStrict([]byte(`{
38+
instrumentation: {
39+
logs: { retentionPeriod: 5m },
40+
},
41+
}`), &pgadmin.Spec))
42+
43+
err := cc.Create(ctx, pgadmin, client.DryRunAll)
44+
assert.Assert(t, apierrors.IsInvalid(err))
45+
assert.ErrorContains(t, err, "retentionPeriod")
46+
assert.ErrorContains(t, err, "hour|day|week")
47+
assert.ErrorContains(t, err, "one hour")
48+
49+
//nolint:errorlint // This is a test, and a panic is unlikely.
50+
status := err.(apierrors.APIStatus).Status()
51+
assert.Assert(t, status.Details != nil)
52+
assert.Equal(t, len(status.Details.Causes), 2)
53+
54+
for _, cause := range status.Details.Causes {
55+
assert.Equal(t, cause.Field, "spec.instrumentation.logs.retentionPeriod")
56+
}
57+
58+
t.Run("Valid", func(t *testing.T) {
59+
for _, tt := range []string{
60+
"28 weeks",
61+
"90 DAY",
62+
"1 hr",
63+
"PT1D2H",
64+
"1 week 2 days",
65+
} {
66+
u, err := runtime.ToUnstructuredObject(pgadmin)
67+
assert.NilError(t, err)
68+
assert.NilError(t, unstructured.SetNestedField(u.Object,
69+
tt, "spec", "instrumentation", "logs", "retentionPeriod"))
70+
71+
assert.NilError(t, cc.Create(ctx, u, client.DryRunAll), tt)
72+
}
73+
})
74+
75+
t.Run("Invalid", func(t *testing.T) {
76+
for _, tt := range []string{
77+
// Amount too small
78+
"0 days",
79+
"0",
80+
81+
// Text too long
82+
"2 weeks 3 days 4 hours",
83+
} {
84+
u, err := runtime.ToUnstructuredObject(pgadmin)
85+
assert.NilError(t, err)
86+
assert.NilError(t, unstructured.SetNestedField(u.Object,
87+
tt, "spec", "instrumentation", "logs", "retentionPeriod"))
88+
89+
err = cc.Create(ctx, u, client.DryRunAll)
90+
assert.Assert(t, apierrors.IsInvalid(err), tt)
91+
assert.ErrorContains(t, err, "retentionPeriod")
92+
}
93+
})
94+
})
95+
}

internal/testing/validation/postgrescluster_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestPostgresUserOptions(t *testing.T) {
2828
base := v1beta1.NewPostgresCluster()
2929

3030
// Start with a bunch of required fields.
31-
assert.NilError(t, yaml.Unmarshal([]byte(`{
31+
assert.NilError(t, yaml.UnmarshalStrict([]byte(`{
3232
postgresVersion: 16,
3333
backups: {
3434
pgbackrest: {

pkg/apis/postgres-operator.crunchydata.com/v1beta1/instrumentation_types.go

+17
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,21 @@ type InstrumentationLogsSpec struct {
5252
// the logs pipeline.
5353
// +optional
5454
Exporters []string `json:"exporters,omitempty"`
55+
56+
// How long to retain log files locally. An RFC 3339 duration or a number
57+
// and unit: `3d`, `4 weeks`, `12 hr`, etc.
58+
// ---
59+
// Kubernetes ensures the value is in the "duration" format, but go ahead
60+
// and loosely validate the format to show some acceptable units.
61+
// +kubebuilder:validation:Pattern=`^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$`
62+
//
63+
// `controller-gen` needs to know "Type=string" to allow a "Pattern".
64+
// +kubebuilder:validation:Type=string
65+
//
66+
// Set a max length to keep rule costs low.
67+
// +kubebuilder:validation:MaxLength=20
68+
// +kubebuilder:validation:XValidation:rule=`duration("1h") <= self && self <= duration("8760h")`,message="must be at least one hour"
69+
//
70+
// +optional
71+
RetentionPeriod *Duration `json:"retentionPeriod,omitempty"`
5572
}

pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types.go

+79
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,89 @@
55
package v1beta1
66

77
import (
8+
"encoding/json"
9+
810
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
912
"k8s.io/apimachinery/pkg/runtime"
13+
"k8s.io/kube-openapi/pkg/validation/strfmt"
1014
)
1115

16+
// ---
17+
// Duration represents a string accepted by the Kubernetes API in the "duration"
18+
// [format]. This format extends the "duration" [defined by OpenAPI] by allowing
19+
// some whitespace and more units:
20+
//
21+
// - nanoseconds: ns, nano, nanos
22+
// - microseconds: us, µs, micro, micros
23+
// - milliseconds: ms, milli, millis
24+
// - seconds: s, sec, secs
25+
// - minutes: m, min, mins
26+
// - hours: h, hr, hour, hours
27+
// - days: d, day, days
28+
// - weeks: w, wk, week, weeks
29+
//
30+
// An empty amount is represented as "0" with no unit.
31+
// One day is always 24 hours and one week is always 7 days (168 hours).
32+
//
33+
// +kubebuilder:validation:Format=duration
34+
// +kubebuilder:validation:MinLength=1
35+
// +kubebuilder:validation:Type=string
36+
//
37+
// During CEL validation, a value of this type is a "google.protobuf.Duration".
38+
// It is safe to pass the value to `duration()` but not necessary.
39+
//
40+
// - https://docs.k8s.io/reference/using-api/cel/#type-system-integration
41+
// - https://github.com/google/cel-spec/blob/-/doc/langdef.md#types-and-conversions
42+
//
43+
// [defined by OpenAPI]: https://spec.openapis.org/registry/format/duration.html
44+
// [format]: https://spec.openapis.org/oas/latest.html#data-type-format
45+
type Duration struct {
46+
parsed metav1.Duration
47+
string
48+
}
49+
50+
// NewDuration creates a duration from the Kubernetes "duration" format in s.
51+
func NewDuration(s string) (*Duration, error) {
52+
td, err := strfmt.ParseDuration(s)
53+
54+
// The unkeyed fields here helpfully raise warnings from the compiler
55+
// if [metav1.Duration] changes shape in the future.
56+
type unkeyed metav1.Duration
57+
umd := unkeyed{td}
58+
59+
return &Duration{metav1.Duration(umd), s}, err
60+
}
61+
62+
// AsDuration returns d as a [metav1.Duration].
63+
func (d *Duration) AsDuration() metav1.Duration {
64+
return d.parsed
65+
}
66+
67+
// MarshalJSON implements [encoding/json.Marshaler].
68+
func (d Duration) MarshalJSON() ([]byte, error) {
69+
if d.parsed.Duration == 0 {
70+
return json.Marshal("0")
71+
}
72+
73+
return json.Marshal(d.string)
74+
}
75+
76+
// UnmarshalJSON implements [encoding/json.Unmarshaler].
77+
func (d *Duration) UnmarshalJSON(data []byte) error {
78+
var next *Duration
79+
var str string
80+
81+
err := json.Unmarshal(data, &str)
82+
if err == nil {
83+
next, err = NewDuration(str)
84+
}
85+
if err == nil {
86+
*d = *next
87+
}
88+
return err
89+
}
90+
1291
// SchemalessObject is a map compatible with JSON object.
1392
//
1493
// Use with the following markers:

0 commit comments

Comments
 (0)