Skip to content

Commit 98cba75

Browse files
committed
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 98cba75

File tree

9 files changed

+335
-1
lines changed

9 files changed

+335
-1
lines changed

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

Lines changed: 12 additions & 0 deletions
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+
minLength: 1
1958+
pattern: ^(PT)?(0|[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$
1959+
type: string
1960+
x-kubernetes-validations:
1961+
- message: must be greater than one hour
1962+
rule: self == duration("0") || (self >= duration("1h") &&
1963+
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

Lines changed: 12 additions & 0 deletions
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+
minLength: 1
11491+
pattern: ^(PT)?(0|[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$
11492+
type: string
11493+
x-kubernetes-validations:
11494+
- message: must be greater than one hour
11495+
rule: self == duration("0") || (self >= duration("1h") &&
11496+
self <= duration("8760h"))
1148511497
type: object
1148611498
resources:
1148711499
description: Resources holds the resource requirements for the
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
"0 days",
64+
"0",
65+
"PT1D",
66+
} {
67+
u, err := runtime.ToUnstructuredObject(pgadmin)
68+
assert.NilError(t, err)
69+
assert.NilError(t, unstructured.SetNestedField(u.Object,
70+
tt, "spec", "instrumentation", "logs", "retentionPeriod"))
71+
72+
assert.NilError(t, cc.Create(ctx, u, client.DryRunAll), tt)
73+
}
74+
})
75+
})
76+
}

internal/testing/validation/postgrescluster_test.go

Lines changed: 1 addition & 1 deletion
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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,18 @@ 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|[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+
// +kubebuilder:validation:XValidation:rule=`self == duration("0") || (self >= duration("1h") && self <= duration("8760h"))`,message="must be greater than one hour"
67+
// +optional
68+
RetentionPeriod *Duration `json:"retentionPeriod,omitempty"`
5569
}

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

Lines changed: 79 additions & 0 deletions
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:

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,125 @@ package v1beta1
77
import (
88
"reflect"
99
"testing"
10+
"time"
1011

1112
"gotest.tools/v3/assert"
1213
"sigs.k8s.io/yaml"
1314
)
1415

16+
func TestDurationYAML(t *testing.T) {
17+
t.Parallel()
18+
19+
t.Run("Zero", func(t *testing.T) {
20+
zero, err := yaml.Marshal(Duration{})
21+
assert.NilError(t, err)
22+
assert.DeepEqual(t, zero, []byte(`"0"`+"\n"))
23+
24+
var parsed Duration
25+
assert.NilError(t, yaml.Unmarshal(zero, &parsed))
26+
assert.Equal(t, parsed.AsDuration().Duration, 0*time.Second)
27+
})
28+
29+
t.Run("Small", func(t *testing.T) {
30+
var parsed Duration
31+
assert.NilError(t, yaml.Unmarshal([]byte(`3ns`), &parsed))
32+
assert.Equal(t, parsed.AsDuration().Duration, 3*time.Nanosecond)
33+
34+
b, err := yaml.Marshal(parsed)
35+
assert.NilError(t, err)
36+
assert.DeepEqual(t, b, []byte(`3ns`+"\n"))
37+
})
38+
39+
t.Run("Large", func(t *testing.T) {
40+
var parsed Duration
41+
assert.NilError(t, yaml.Unmarshal([]byte(`52 weeks`), &parsed))
42+
assert.Equal(t, parsed.AsDuration().Duration, 364*24*time.Hour)
43+
44+
b, err := yaml.Marshal(parsed)
45+
assert.NilError(t, err)
46+
assert.DeepEqual(t, b, []byte(`52 weeks`+"\n"))
47+
})
48+
49+
t.Run("UnitsIn", func(t *testing.T) {
50+
for _, tt := range []struct {
51+
input string
52+
result time.Duration
53+
}{
54+
// These can be unmarshaled:
55+
{"1 ns", time.Nanosecond},
56+
{"2 nano", 2 * time.Nanosecond},
57+
{"3 nanos", 3 * time.Nanosecond},
58+
{"4 nanosec", 4 * time.Nanosecond},
59+
{"5 nanosecs", 5 * time.Nanosecond},
60+
{"6 nanopants", 6 * time.Nanosecond},
61+
62+
{"1 us", time.Microsecond},
63+
{"2 µs", 2 * time.Microsecond},
64+
{"3 micro", 3 * time.Microsecond},
65+
{"4 micros", 4 * time.Microsecond},
66+
{"5 micrometer", 5 * time.Microsecond},
67+
68+
{"1 ms", time.Millisecond},
69+
{"2 milli", 2 * time.Millisecond},
70+
{"3 millis", 3 * time.Millisecond},
71+
{"4 millisec", 4 * time.Millisecond},
72+
{"5 millisecs", 5 * time.Millisecond},
73+
{"6 millipede", 6 * time.Millisecond},
74+
75+
{"1s", time.Second},
76+
{"2 sec", 2 * time.Second},
77+
{"3 secs", 3 * time.Second},
78+
{"4 seconds", 4 * time.Second},
79+
{"5 security", 5 * time.Second},
80+
81+
{"1m", time.Minute},
82+
{"2 min", 2 * time.Minute},
83+
{"3 mins", 3 * time.Minute},
84+
{"4 minutia", 4 * time.Minute},
85+
{"5 mininture", 5 * time.Minute},
86+
87+
{"1h", time.Hour},
88+
{"2 hr", 2 * time.Hour},
89+
{"3 hour", 3 * time.Hour},
90+
{"4 hours", 4 * time.Hour},
91+
{"5 hourglass", 5 * time.Hour},
92+
93+
{"1d", 24 * time.Hour},
94+
{"2 day", 2 * 24 * time.Hour},
95+
{"3 days", 3 * 24 * time.Hour},
96+
{"4 dayrock", 4 * 24 * time.Hour},
97+
98+
{"1w", 7 * 24 * time.Hour},
99+
{"2 wk", 2 * 7 * 24 * time.Hour},
100+
{"3 week", 3 * 7 * 24 * time.Hour},
101+
{"4 weeks", 4 * 7 * 24 * time.Hour},
102+
{"5 weekpasta", 5 * 7 * 24 * time.Hour},
103+
} {
104+
var parsed Duration
105+
assert.NilError(t, yaml.Unmarshal([]byte(tt.input), &parsed))
106+
assert.Equal(t, parsed.AsDuration().Duration, tt.result)
107+
}
108+
109+
for _, tt := range []string{
110+
// These cannot be unmarshaled:
111+
"1 nss",
112+
"2 uss",
113+
"3 usec",
114+
"4 usecs",
115+
"5 µsec",
116+
"6 mss",
117+
"7 hs",
118+
"8 hrs",
119+
"9 ds",
120+
"10 ws",
121+
"11 wks",
122+
} {
123+
assert.ErrorContains(t,
124+
yaml.Unmarshal([]byte(tt), new(Duration)), "unable to parse")
125+
}
126+
})
127+
}
128+
15129
func TestSchemalessObjectDeepCopy(t *testing.T) {
16130
t.Parallel()
17131

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ func (p *PGAdmin) Default() {
221221
}
222222
}
223223

224+
func NewPGAdmin() *PGAdmin {
225+
p := &PGAdmin{}
226+
p.Default()
227+
return p
228+
}
229+
224230
//+kubebuilder:object:root=true
225231

226232
// PGAdminList contains a list of PGAdmin

0 commit comments

Comments
 (0)