Skip to content

Commit 77bce37

Browse files
Merge pull request #3766 from jrvaldes/log-rotation-kubelet
WINC-1635, WINC-1592: enable log rotation for kubelet and kubeproxy services
2 parents 1071658 + 36f6377 commit 77bce37

9 files changed

Lines changed: 552 additions & 20 deletions

File tree

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,31 @@ in a healthy state with no disruptions.
246246

247247
## Enabled features
248248

249+
### Automatic log rotation for managed Windows services
250+
251+
Automatic rotation of the log files for the managed Windows services is available to prevent disk space
252+
exhaustion. Uses [kube-log-runner](https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/component-base/logs/kube-log-runner)
253+
as a wrapper binary that executes the service while capturing stdout/stderr, rotates logs based on size
254+
and automatically cleaning up old files based on age.
255+
256+
For details on the log rotation naming convention, please refer to the [kube-log-runner documentation](https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/component-base/logs/kube-log-runner)
257+
258+
The log rotation functionality is disabled by default, causing log files to grow indefinitely.
259+
260+
Managed Windows services with log rotation capabilities:
261+
- kubelet
262+
- kube-proxy
263+
264+
Not yet supported:
265+
- containerd
266+
- csi-proxy
267+
- windows_exporter
268+
- hybrid-overlay-node
269+
- azure-cloud-node-manager
270+
271+
For instructions to enable, customize or disable log rotation refer to
272+
[log rotation for managed Windows services documentation](docs/log-rotation-managed-services.md).
273+
249274
### Autoscaling Windows nodes
250275
Cluster autoscaling is supported for Windows instances.
251276

@@ -257,7 +282,7 @@ Cluster autoscaling is supported for Windows instances.
257282
Windows instances brought up with WMCO are set up with the containerd container runtime. As WMCO installs and manages the container runtime,
258283
it is recommended not to preinstall containerd in MachineSet or BYOH Windows instances.
259284

260-
### Cluster-wide proxy
285+
### Cluster-wide proxy
261286
WMCO supports using a [cluster-wide proxy](https://docs.openshift.com/container-platform/latest/networking/enable-cluster-wide-proxy.html)
262287
to route egress traffic from Windows nodes on OpenShift Container Platform.
263288

@@ -297,7 +322,7 @@ Some valid values could be: `$mirrorRegistry/oss/kubernetes/pause:3.9`, `$mirror
297322

298323
### Horizontal Pod Autoscaling
299324
Horizontal Pod autoscaling is available for Windows workloads.
300-
Please follow the [Horizontal Pod autoscaling docs](https://docs.openshift.com/container-platform/latest/nodes/pods/nodes-pods-autoscaling.html)
325+
Please follow the [Horizontal Pod autoscaling docs](https://docs.openshift.com/container-platform/latest/nodes/pods/nodes-pods-autoscaling.html)
301326
to create a horizontal pod autoscaler object for CPU and memory utilization of Windows workloads.
302327

303328
## Limitations
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Log rotation for managed Windows services
2+
3+
Log rotation for managed Windows services is available for WMCO 10.22+. This feature rotates log files based
4+
on configurable size and age thresholds and is configured via environment variables in the operator.
5+
6+
## Enabling log rotation for managed Windows services
7+
8+
To enable and customize the log rotation behavior, add the following environment variables to the subscription (OLMv0).
9+
The operator will restart to load the newly added environment variables and apply log rotation to the
10+
managed services. This will result in a reconfiguration of the existing Windows nodes, one at a time, until all
11+
nodes have been handled, to minimize disruption.
12+
13+
### Setting environment variables in the subscription:
14+
```yaml
15+
kind: Subscription
16+
spec:
17+
config:
18+
env:
19+
- name: SERVICES_LOG_FILE_SIZE
20+
value: "100M" # Rotate when log reaches this size (suggested: 100M)
21+
- name: SERVICES_LOG_FILE_AGE
22+
value: "168h" # Keep rotated logs for this duration (e.g: 168h/7 days)
23+
- name: SERVICES_LOG_FLUSH_INTERVAL
24+
value: "5s" # Flush logs to disk at this interval (suggested: 5s)
25+
```
26+
27+
### Patching the subscription using the CLI:
28+
```shell script
29+
oc patch subscription <subscription_name> -n <namespace_name> \
30+
--type=merge \
31+
-p '{"spec":{"config":{"env":[{"name":"SERVICES_LOG_FILE_SIZE","value":"100M"},{"name":"SERVICES_LOG_FILE_AGE","value":"168h"},{"name":"SERVICES_LOG_FLUSH_INTERVAL","value":"5s"}]}}}'
32+
```
33+
34+
### Patching the operator deployment using the CLI (OLMv1 or manual installs):
35+
36+
```shell script
37+
oc set env deployment/windows-machine-config-operator -n <namespace_name> -c manager \
38+
SERVICES_LOG_FILE_SIZE="100M" \
39+
SERVICES_LOG_FILE_AGE="168h" \
40+
SERVICES_LOG_FLUSH_INTERVAL="5s"
41+
```
42+
where:
43+
- `<namespace_name>`: The namespace where the operator is installed (e.g., `openshift-windows-machine-config-operator`)
44+
- `<subscription_name>`: The name of the subscription used to install the operator (e.g., `windows-machine-config-operator-subscription`)
45+
46+
## Disabling log rotation for managed Windows services
47+
48+
To disable log rotation, remove the `SERVICES_LOG_FILE_SIZE`, `SERVICES_LOG_FILE_AGE`, and `SERVICES_LOG_FLUSH_INTERVAL`
49+
environment variables from the subscription or operator deployment.
50+
51+
## Behavior when log rotation settings change
52+
53+
**Effect on existing log files:** When rotation settings are changed (enabled, disabled, or updated), any previously
54+
rotated log files are retained according to the `SERVICES_LOG_FILE_AGE` value that was in effect when they were
55+
created. Once that retention period expires, the files are cleaned up automatically. New log files and any future
56+
rotated files will follow the updated rotation rules going forward.
57+
58+
**Operator and node behavior:** Any change to the `SERVICES_LOG_FILE_SIZE`, `SERVICES_LOG_FILE_AGE`, or
59+
`SERVICES_LOG_FLUSH_INTERVAL` environment variables—whether in the subscription (OLMv0) or the operator deployment
60+
(OLMv1 / manual installs)—will cause the operator to restart in order to load the updated configuration. After
61+
restarting, the operator will reconfigure each Windows node one at a time to apply the new log rotation settings,
62+
minimizing disruption. Note that service continuity during reconfiguration is not guaranteed; brief interruptions
63+
to managed services (such as kubelet or kube-proxy) may occur on each node as it is reconfigured.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ require (
3737
k8s.io/apimachinery v0.34.4
3838
k8s.io/client-go v0.34.4
3939
k8s.io/cloud-provider v0.34.4
40+
k8s.io/component-base v0.34.4
4041
k8s.io/klog/v2 v2.130.1
4142
k8s.io/kubectl v0.34.4
4243
k8s.io/kubelet v0.34.4
@@ -153,7 +154,6 @@ require (
153154
k8s.io/apiextensions-apiserver v0.34.4 // indirect
154155
k8s.io/apiserver v0.34.4 // indirect
155156
k8s.io/cli-runtime v0.34.4 // indirect
156-
k8s.io/component-base v0.34.4 // indirect
157157
k8s.io/controller-manager v0.34.4 // indirect
158158
k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect
159159
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect

hack/common.sh

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ deleteParallelUpgradeCheckerResources() {
380380
}
381381

382382

383+
# Enables debug logging and set smaller size for services log file in the operator pod to make it easier to
384+
# troubleshoot issues in CI.
385+
# The method for patching the deployment depends on the OLM version, which is detected by checking for the presence
386+
# of a subscription (OLMv0) or clusterextension (OLMv1).
383387
enable_debug_logging() {
384388
if [[ $(oc get -n $WMCO_DEPLOY_NAMESPACE pod -l name=windows-machine-config-operator -ojson) == *"--debugLogging"* ]]; then
385389
echo "Debug logging already enabled"
@@ -390,13 +394,16 @@ enable_debug_logging() {
390394
WMCO_SUB=$(oc get sub -n "$WMCO_DEPLOY_NAMESPACE" --no-headers 2>/dev/null | awk '{print $1}')
391395
if [[ -n "$WMCO_SUB" ]]; then
392396
echo "Detected OLMv0, patching subscription $WMCO_SUB"
393-
oc patch subscription $WMCO_SUB -n $WMCO_DEPLOY_NAMESPACE --type=merge -p '{"spec":{"config":{"env":[{"name":"ARGS","value":"--debugLogging"}]}}}'
397+
oc patch subscription $WMCO_SUB -n $WMCO_DEPLOY_NAMESPACE --type=merge -p '{"spec":{"config":{"env":[{"name":"ARGS","value":"--debugLogging"},{"name":"SERVICES_LOG_FILE_SIZE","value":"1M"}]}}}'
394398
# delete the deployment to ensure the changes are picked up in a timely matter
395399
oc delete deployment -n $WMCO_DEPLOY_NAMESPACE windows-machine-config-operator
396400
elif oc get clusterextension windows-machine-config-operator &>/dev/null; then
397401
echo "Detected OLMv1, patching deployment directly..."
398-
# Add debug env variable to the WMCO manager container
399-
oc set env deployment/windows-machine-config-operator -n "$WMCO_DEPLOY_NAMESPACE" ARGS="--debugLogging" -c manager
402+
# Add debug env variable and log file limit to the WMCO manager container
403+
oc set env deployment/windows-machine-config-operator -n "$WMCO_DEPLOY_NAMESPACE" \
404+
ARGS="--debugLogging" \
405+
SERVICES_LOG_FILE_SIZE="1M" \
406+
-c manager
400407
# force restart to pick up the env variable change
401408
oc scale deployment/windows-machine-config-operator -n "$WMCO_DEPLOY_NAMESPACE" --replicas=0
402409
oc scale deployment/windows-machine-config-operator -n "$WMCO_DEPLOY_NAMESPACE" --replicas=1

pkg/nodeconfig/nodeconfig.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"k8s.io/apimachinery/pkg/util/wait"
2323
"k8s.io/client-go/kubernetes"
2424
clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1"
25+
logsapi "k8s.io/component-base/logs/api/v1"
2526
"k8s.io/kubectl/pkg/drain"
2627
kubeletconfigv1 "k8s.io/kubelet/config/v1"
2728
kubeletconfig "k8s.io/kubelet/config/v1beta1"
@@ -743,14 +744,20 @@ func generateKubeletConfiguration(clusterDNS string) kubeletconfig.KubeletConfig
743744
Enabled: &falseBool,
744745
},
745746
},
746-
ClusterDomain: "cluster.local",
747-
ClusterDNS: []string{clusterDNS},
748-
CgroupsPerQOS: &falseBool,
749-
RuntimeRequestTimeout: meta.Duration{Duration: 10 * time.Minute},
750-
MaxPods: 250,
751-
KubeAPIQPS: &kubeAPIQPS,
752-
KubeAPIBurst: 100,
753-
SerializeImagePulls: &falseBool,
747+
ClusterDomain: "cluster.local",
748+
ClusterDNS: []string{clusterDNS},
749+
CgroupsPerQOS: &falseBool,
750+
RuntimeRequestTimeout: meta.Duration{Duration: 10 * time.Minute},
751+
MaxPods: 250,
752+
KubeAPIQPS: &kubeAPIQPS,
753+
KubeAPIBurst: 100,
754+
SerializeImagePulls: &falseBool,
755+
Logging: logsapi.LoggingConfiguration{
756+
FlushFrequency: logsapi.TimeOrMetaDuration{
757+
Duration: meta.Duration{Duration: 5 * time.Second},
758+
SerializeAsString: true,
759+
},
760+
},
754761
EnableSystemLogHandler: &trueBool,
755762
EnableSystemLogQuery: &trueBool,
756763
FeatureGates: map[string]bool{

pkg/nodeconfig/nodeconfig_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestCreateKubeletConf(t *testing.T) {
8484
{
8585
name: "valid cidr",
8686
cidr: "10.0.128.8/24",
87-
expectedSpec: "{\"kind\":\"KubeletConfiguration\",\"apiVersion\":\"kubelet.config.k8s.io/v1beta1\",\"syncFrequency\":\"0s\",\"fileCheckFrequency\":\"0s\",\"httpCheckFrequency\":\"0s\",\"rotateCertificates\":true,\"serverTLSBootstrap\":true,\"authentication\":{\"x509\":{\"clientCAFile\":\"C:\\\\k\\\\kubelet-ca.crt\"},\"webhook\":{\"cacheTTL\":\"0s\"},\"anonymous\":{\"enabled\":false}},\"authorization\":{\"webhook\":{\"cacheAuthorizedTTL\":\"0s\",\"cacheUnauthorizedTTL\":\"0s\"}},\"clusterDomain\":\"cluster.local\",\"clusterDNS\":[\"10.0.128.10\"],\"streamingConnectionIdleTimeout\":\"0s\",\"nodeStatusUpdateFrequency\":\"0s\",\"nodeStatusReportFrequency\":\"0s\",\"imageMinimumGCAge\":\"0s\",\"imageMaximumGCAge\":\"0s\",\"volumeStatsAggPeriod\":\"0s\",\"cgroupsPerQOS\":false,\"cpuManagerReconcilePeriod\":\"0s\",\"runtimeRequestTimeout\":\"10m0s\",\"maxPods\":250,\"resolvConf\":\"\",\"kubeAPIQPS\":50,\"kubeAPIBurst\":100,\"serializeImagePulls\":false,\"evictionHard\":{\"imagefs.available\":\"15%\",\"nodefs.available\":\"10%\"},\"evictionPressureTransitionPeriod\":\"0s\",\"featureGates\":{\"NodeLogQuery\":true,\"RotateKubeletServerCertificate\":true},\"memorySwap\":{},\"containerLogMaxSize\":\"50Mi\",\"systemReserved\":{\"cpu\":\"500m\",\"ephemeral-storage\":\"1Gi\",\"memory\":\"2Gi\"},\"enforceNodeAllocatable\":[\"none\"],\"logging\":{\"flushFrequency\":0,\"verbosity\":0,\"options\":{\"text\":{\"infoBufferSize\":\"0\"},\"json\":{\"infoBufferSize\":\"0\"}}},\"enableSystemLogHandler\":true,\"enableSystemLogQuery\":true,\"shutdownGracePeriod\":\"0s\",\"shutdownGracePeriodCriticalPods\":\"0s\",\"crashLoopBackOff\":{},\"registerWithTaints\":[{\"key\":\"os\",\"value\":\"Windows\",\"effect\":\"NoSchedule\"}],\"registerNode\":true,\"containerRuntimeEndpoint\":\"npipe://./pipe/containerd-containerd\"}",
87+
expectedSpec: "{\"kind\":\"KubeletConfiguration\",\"apiVersion\":\"kubelet.config.k8s.io/v1beta1\",\"syncFrequency\":\"0s\",\"fileCheckFrequency\":\"0s\",\"httpCheckFrequency\":\"0s\",\"rotateCertificates\":true,\"serverTLSBootstrap\":true,\"authentication\":{\"x509\":{\"clientCAFile\":\"C:\\\\k\\\\kubelet-ca.crt\"},\"webhook\":{\"cacheTTL\":\"0s\"},\"anonymous\":{\"enabled\":false}},\"authorization\":{\"webhook\":{\"cacheAuthorizedTTL\":\"0s\",\"cacheUnauthorizedTTL\":\"0s\"}},\"clusterDomain\":\"cluster.local\",\"clusterDNS\":[\"10.0.128.10\"],\"streamingConnectionIdleTimeout\":\"0s\",\"nodeStatusUpdateFrequency\":\"0s\",\"nodeStatusReportFrequency\":\"0s\",\"imageMinimumGCAge\":\"0s\",\"imageMaximumGCAge\":\"0s\",\"volumeStatsAggPeriod\":\"0s\",\"cgroupsPerQOS\":false,\"cpuManagerReconcilePeriod\":\"0s\",\"runtimeRequestTimeout\":\"10m0s\",\"maxPods\":250,\"resolvConf\":\"\",\"kubeAPIQPS\":50,\"kubeAPIBurst\":100,\"serializeImagePulls\":false,\"evictionHard\":{\"imagefs.available\":\"15%\",\"nodefs.available\":\"10%\"},\"evictionPressureTransitionPeriod\":\"0s\",\"featureGates\":{\"NodeLogQuery\":true,\"RotateKubeletServerCertificate\":true},\"memorySwap\":{},\"containerLogMaxSize\":\"50Mi\",\"systemReserved\":{\"cpu\":\"500m\",\"ephemeral-storage\":\"1Gi\",\"memory\":\"2Gi\"},\"enforceNodeAllocatable\":[\"none\"],\"logging\":{\"flushFrequency\":\"5s\",\"verbosity\":0,\"options\":{\"text\":{\"infoBufferSize\":\"0\"},\"json\":{\"infoBufferSize\":\"0\"}}},\"enableSystemLogHandler\":true,\"enableSystemLogQuery\":true,\"shutdownGracePeriod\":\"0s\",\"shutdownGracePeriodCriticalPods\":\"0s\",\"crashLoopBackOff\":{},\"registerWithTaints\":[{\"key\":\"os\",\"value\":\"Windows\",\"effect\":\"NoSchedule\"}],\"registerNode\":true,\"containerRuntimeEndpoint\":\"npipe://./pipe/containerd-containerd\"}",
8888
expectedErr: false,
8989
},
9090
{

pkg/services/init.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package services
2+
3+
import ctrl "sigs.k8s.io/controller-runtime"
4+
5+
var logFileSize, logFileAge, flushInterval string
6+
7+
func init() {
8+
log := ctrl.Log.WithName("services").WithName("init")
9+
10+
var err error
11+
logFileSize, err = getEnvQuantityString(logFileSizeEnvVar)
12+
if err != nil {
13+
log.Error(err, "cannot load environment variable", "name", logFileSizeEnvVar)
14+
}
15+
16+
logFileAge, err = getEnvDurationString(logFileAgeEnvVar)
17+
if err != nil {
18+
log.Error(err, "cannot load environment variable", "name", logFileAgeEnvVar)
19+
}
20+
21+
flushInterval, err = getEnvDurationString(logFlushIntervalEnvVar)
22+
if err != nil {
23+
log.Error(err, "cannot load environment variable", "name", logFlushIntervalEnvVar)
24+
}
25+
}

pkg/services/services.go

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package services
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67
"strings"
8+
"time"
79

810
config "github.com/openshift/api/config/v1"
11+
"k8s.io/apimachinery/pkg/api/resource"
912

1013
"github.com/openshift/windows-machine-config-operator/pkg/cluster"
1114
"github.com/openshift/windows-machine-config-operator/pkg/ignition"
@@ -22,6 +25,13 @@ const (
2225
// hostnameOverrideVar is the variable that should be replaced with the value of the desired instance hostname
2326
hostnameOverrideVar = "HOSTNAME_OVERRIDE"
2427
NodeIPVar = "NODE_IP"
28+
29+
// logFileSizeEnvVar is the environment variable name for log file size limit
30+
logFileSizeEnvVar = "SERVICES_LOG_FILE_SIZE"
31+
// logFileAgeEnvVar is the environment variable name for log file age retention
32+
logFileAgeEnvVar = "SERVICES_LOG_FILE_AGE"
33+
// logFlushIntervalEnvVar is the environment variable name for log flush interval
34+
logFlushIntervalEnvVar = "SERVICES_LOG_FLUSH_INTERVAL"
2535
)
2636

2737
// GenerateManifest returns the expected state of the Windows service configmap. If debug is true, debug logging
@@ -143,9 +153,8 @@ func hybridOverlayConfiguration(apiServerEndpoint, vxlanPort string, debug bool)
143153

144154
// kubeProxyConfiguration returns the Service definition for kube-proxy
145155
func kubeProxyConfiguration(debug bool) servicescm.Service {
146-
cmd := fmt.Sprintf("%s -log-file=%s %s --config %s --windows-service", windows.KubeLogRunnerPath, windows.KubeProxyLog,
147-
windows.KubeProxyPath, windows.KubeProxyConfigPath)
148-
156+
cmd := getLogRunnerForCmd(windows.KubeProxyPath, windows.KubeProxyLog)
157+
cmd = fmt.Sprintf("%s --config %s --windows-service", cmd, windows.KubeProxyConfigPath)
149158
verbosity := "0"
150159
if debug {
151160
verbosity = "4"
@@ -222,8 +231,7 @@ func getKubeletServiceConfiguration(argsFromIginition map[string]string, debug b
222231
preScripts = append(preScripts, hostnameOverridePowershellVar)
223232
}
224233

225-
kubeletServiceCmd := fmt.Sprintf("%s -log-file=%s %s",
226-
windows.KubeLogRunnerPath, windows.KubeletLog, windows.KubeletPath)
234+
kubeletServiceCmd := getLogRunnerForCmd(windows.KubeletPath, windows.KubeletLog)
227235

228236
for _, arg := range kubeletArgs {
229237
kubeletServiceCmd += fmt.Sprintf(" %s", arg)
@@ -307,3 +315,71 @@ func getHostnameCmd(platformType config.PlatformType) string {
307315
return ""
308316
}
309317
}
318+
319+
// getLogRunnerForCmd returns the command string to run the given commandPath with kube-log-runner
320+
// logging to the given logfilePath. Log rotation parameters can be configured via environment variables.
321+
func getLogRunnerForCmd(commandPath, logfilePath string) string {
322+
cmdBuilder := strings.Builder{}
323+
cmdBuilder.WriteString(windows.KubeLogRunnerPath)
324+
325+
cmdBuilder.WriteString(" -log-file=")
326+
cmdBuilder.WriteString(logfilePath)
327+
328+
if logFileSize != "" {
329+
cmdBuilder.WriteString(" -log-file-size=")
330+
cmdBuilder.WriteString(logFileSize)
331+
}
332+
333+
if logFileAge != "" {
334+
cmdBuilder.WriteString(" -log-file-age=")
335+
cmdBuilder.WriteString(logFileAge)
336+
}
337+
338+
if flushInterval != "" {
339+
cmdBuilder.WriteString(" -flush-interval=")
340+
cmdBuilder.WriteString(flushInterval)
341+
}
342+
343+
cmdBuilder.WriteString(" " + commandPath)
344+
345+
return cmdBuilder.String()
346+
}
347+
348+
// getEnvQuantityString returns the string value of the environment variable for the given key
349+
// if it represents a valid and non-negative quantity, otherwise returns error
350+
func getEnvQuantityString(key string) (string, error) {
351+
value := os.Getenv(key)
352+
value = strings.TrimSpace(value)
353+
if value == "" {
354+
// not present
355+
return "", nil
356+
}
357+
// validate value as quantity
358+
q, err := resource.ParseQuantity(value)
359+
if err != nil {
360+
return "", fmt.Errorf("invalid quantity value for %s: %w", key, err)
361+
}
362+
if q.Sign() < 0 {
363+
return "", fmt.Errorf("quantity cannot be negative for %s", key)
364+
}
365+
return value, nil
366+
}
367+
368+
// getEnvDurationString returns the string value of the environment variable for the given key
369+
// if it represents a valid and non-negative duration, otherwise returns error
370+
func getEnvDurationString(key string) (string, error) {
371+
value := os.Getenv(key)
372+
value = strings.TrimSpace(value)
373+
if value == "" {
374+
return "", nil
375+
}
376+
if strings.HasPrefix(value, "-") {
377+
return "", fmt.Errorf("duration cannot be negative for %s", key)
378+
}
379+
380+
// validate value as duration
381+
if _, err := time.ParseDuration(value); err != nil {
382+
return "", fmt.Errorf("invalid duration value for %s: %w", key, err)
383+
}
384+
return value, nil
385+
}

0 commit comments

Comments
 (0)