Skip to content

Commit 4977ef9

Browse files
committed
feat(config): add configurable expiration settings for workflow runs
Signed-off-by: Jordan McClintock <jordan@defenseunicorns.com> Assisted-by: OpenAI Codex
1 parent f8b321f commit 4977ef9

12 files changed

Lines changed: 322 additions & 36 deletions

File tree

‎app/controlplane/cmd/main.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,7 @@ func main() {
158158
}
159159
defer cleanup()
160160

161-
// Run an expiration job every minute that expires unfinished runs older than 1 hour
162-
// TODO: Make it configurable from the application config
163-
app.runsExpirer.Run(ctx, &biz.WorkflowRunExpirerOpts{CheckInterval: 1 * time.Minute, ExpirationWindow: 1 * time.Hour})
161+
app.runsExpirer.Run(ctx, workflowRunExpirerOpts(&bc))
164162

165163
// Sync user access
166164
go func() {
@@ -209,6 +207,18 @@ func toPtr[T any](v T) *T {
209207
return &v
210208
}
211209

210+
func workflowRunExpirerOpts(c *conf.Bootstrap) *biz.WorkflowRunExpirerOpts {
211+
opts := &biz.WorkflowRunExpirerOpts{}
212+
if configured := c.GetAttestations().GetWorkflowRunExpirationWindow(); configured != nil {
213+
opts.ExpirationWindow = configured.AsDuration()
214+
}
215+
if configured := c.GetAttestations().GetWorkflowRunExpirationCheckInterval(); configured != nil {
216+
opts.CheckInterval = configured.AsDuration()
217+
}
218+
219+
return opts
220+
}
221+
212222
type app struct {
213223
*kratos.App
214224
// Periodic job that expires unfinished attestation processes older than a given threshold

‎app/controlplane/cmd/main_test.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2024-2026 The Chainloop Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"os"
19+
"path/filepath"
20+
"testing"
21+
"time"
22+
23+
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
24+
"github.com/go-kratos/kratos/v2/config"
25+
"github.com/go-kratos/kratos/v2/config/file"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
"google.golang.org/protobuf/types/known/durationpb"
29+
)
30+
31+
func TestWorkflowRunExpirerOptsMapping(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
config *conf.Bootstrap
35+
want time.Duration
36+
wantCheckInterval time.Duration
37+
}{
38+
{
39+
name: "missing attestations configuration",
40+
config: &conf.Bootstrap{},
41+
want: 0,
42+
},
43+
{
44+
name: "unset expiration window",
45+
config: &conf.Bootstrap{
46+
Attestations: &conf.Attestations{},
47+
},
48+
want: 0,
49+
},
50+
{
51+
name: "configured expiration window",
52+
config: &conf.Bootstrap{
53+
Attestations: &conf.Attestations{
54+
WorkflowRunExpirationWindow: durationpb.New(2 * time.Hour),
55+
WorkflowRunExpirationCheckInterval: durationpb.New(30 * time.Second),
56+
},
57+
},
58+
wantCheckInterval: 30 * time.Second,
59+
want: 2 * time.Hour,
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
opts := workflowRunExpirerOpts(tt.config)
66+
assert.Equal(t, tt.want, opts.ExpirationWindow)
67+
assert.Equal(t, tt.wantCheckInterval, opts.CheckInterval)
68+
})
69+
}
70+
}
71+
72+
func TestWorkflowRunExpirationDurationValidation(t *testing.T) {
73+
validator, err := newProtoValidator()
74+
require.NoError(t, err)
75+
76+
for _, duration := range []time.Duration{0, -time.Second} {
77+
t.Run(duration.String(), func(t *testing.T) {
78+
bootstrap := &conf.Bootstrap{
79+
Attestations: &conf.Attestations{
80+
WorkflowRunExpirationWindow: durationpb.New(duration),
81+
},
82+
}
83+
require.Error(t, validator.Validate(bootstrap))
84+
85+
bootstrap = &conf.Bootstrap{
86+
Attestations: &conf.Attestations{
87+
WorkflowRunExpirationCheckInterval: durationpb.New(duration),
88+
},
89+
}
90+
require.Error(t, validator.Validate(bootstrap))
91+
})
92+
}
93+
}
94+
95+
func TestMalformedWorkflowRunExpirationWindow(t *testing.T) {
96+
configPath := filepath.Join(t.TempDir(), "config.yaml")
97+
require.NoError(t, os.WriteFile(configPath, []byte(
98+
"attestations:\n workflow_run_expiration_window: definitely-not-a-duration\n",
99+
), 0o600))
100+
101+
c := config.New(config.WithSource(file.NewSource(configPath)))
102+
t.Cleanup(func() {
103+
require.NoError(t, c.Close())
104+
})
105+
require.NoError(t, c.Load())
106+
107+
var bootstrap conf.Bootstrap
108+
require.Error(t, c.Scan(&bootstrap))
109+
}
110+
111+
func TestMalformedWorkflowRunExpirationCheckInterval(t *testing.T) {
112+
configPath := filepath.Join(t.TempDir(), "config.yaml")
113+
require.NoError(t, os.WriteFile(configPath, []byte(
114+
"attestations:\n workflow_run_expiration_check_interval: definitely-not-a-duration\n",
115+
), 0o600))
116+
117+
c := config.New(config.WithSource(file.NewSource(configPath)))
118+
t.Cleanup(func() {
119+
require.NoError(t, c.Close())
120+
})
121+
require.NoError(t, c.Load())
122+
123+
var bootstrap conf.Bootstrap
124+
require.Error(t, c.Scan(&bootstrap))
125+
}

‎app/controlplane/configs/config.devel.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,7 @@ ui_dashboard_url: http://localhost:3000
125125

126126
attestations:
127127
skip_db_storage: true
128+
# Unfinished workflow runs older than this are marked as expired.
129+
workflow_run_expiration_window: 1h
130+
# How often unfinished workflow runs are checked for expiration.
131+
workflow_run_expiration_check_interval: 1m

‎app/controlplane/configs/samples/config.yaml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,10 @@ enable_profiler: true
9898
# # When true, skip writing the attestation bundle to the database; the bundle is
9999
# # stored exclusively in the configured CAS backend. Has no effect when the
100100
# # workflow run's CAS backend is inline.
101-
# skip_db_storage: false
101+
# skip_db_storage: false
102+
# # Maximum age of an unfinished workflow run before it is marked as expired.
103+
# # Defaults to 1 hour when omitted.
104+
# # Interval between expiration checks for unfinished workflow runs.
105+
# # Defaults to 1 minute when omitted.
106+
# workflow_run_expiration_check_interval: 1m
107+
# workflow_run_expiration_window: 1h

‎app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go‎

Lines changed: 50 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/internal/conf/controlplane/config/v1/conf.proto‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ message Attestations {
160160
// from exhausting the control plane's memory. Values <= 0 select the
161161
// built-in default.
162162
int64 policy_evaluations_max_inline_bytes = 2;
163+
164+
// Maximum amount of time an unfinished workflow run can remain active before
165+
// it is marked as expired. When unset, the control plane defaults to 1 hour.
166+
google.protobuf.Duration workflow_run_expiration_window = 3
167+
[(buf.validate.field).duration.gt = {}];
168+
169+
// Interval between checks for unfinished workflow runs. When unset, the
170+
// control plane defaults to 1 minute.
171+
google.protobuf.Duration workflow_run_expiration_check_interval = 4
172+
[(buf.validate.field).duration.gt = {}];
163173
}
164174

165175
message OperationAuthorizationProvider {

‎app/controlplane/pkg/biz/workflowrun.go‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,18 +180,43 @@ type WorkflowRunExpirerUseCase struct {
180180
logger *log.Helper
181181
}
182182

183+
const (
184+
defaultWorkflowRunExpirationWindow = time.Hour
185+
defaultWorkflowRunExpirationCheckInterval = time.Minute
186+
)
187+
183188
type WorkflowRunExpirerOpts struct {
184-
// Maximum time threshold for what a workflowRun will be considered expired
189+
// Maximum time threshold for what a workflowRun will be considered expired. Defaults to 1 hour.
185190
ExpirationWindow time.Duration
186-
CheckInterval time.Duration
191+
// Interval between expiration sweeps. Defaults to 1 minute.
192+
CheckInterval time.Duration
187193
}
188194

189195
func NewWorkflowRunExpirerUseCase(wfrRepo WorkflowRunRepo, po PromObservable, logger log.Logger) *WorkflowRunExpirerUseCase {
190196
logger = log.With(logger, "component", "biz.WorkflowRunExpirer")
191197
return &WorkflowRunExpirerUseCase{wfrRepo, po, log.NewHelper(logger)}
192198
}
193199

200+
func resolveWorkflowRunExpirerOpts(opts *WorkflowRunExpirerOpts) WorkflowRunExpirerOpts {
201+
resolved := WorkflowRunExpirerOpts{
202+
ExpirationWindow: defaultWorkflowRunExpirationWindow,
203+
CheckInterval: defaultWorkflowRunExpirationCheckInterval,
204+
}
205+
if opts == nil {
206+
return resolved
207+
}
208+
if opts.ExpirationWindow > 0 {
209+
resolved.ExpirationWindow = opts.ExpirationWindow
210+
}
211+
if opts.CheckInterval > 0 {
212+
resolved.CheckInterval = opts.CheckInterval
213+
}
214+
215+
return resolved
216+
}
217+
194218
func (uc *WorkflowRunExpirerUseCase) Run(ctx context.Context, opts *WorkflowRunExpirerOpts) {
219+
resolvedOpts := resolveWorkflowRunExpirerOpts(opts)
195220
timer := time.NewTimer(0)
196221

197222
go func() {
@@ -201,18 +226,18 @@ func (uc *WorkflowRunExpirerUseCase) Run(ctx context.Context, opts *WorkflowRunE
201226
case <-ctx.Done():
202227
return
203228
case <-timer.C:
204-
threshold := time.Now().Add(-opts.ExpirationWindow)
229+
threshold := time.Now().Add(-resolvedOpts.ExpirationWindow)
205230

206231
if err := uc.ExpirationSweep(ctx, threshold); err != nil {
207232
uc.logger.Error(err)
208233
}
209234
}
210235

211-
timer.Reset(opts.CheckInterval)
236+
timer.Reset(resolvedOpts.CheckInterval)
212237
}
213238
}()
214239

215-
uc.logger.Infof("periodic check enabled. interval=%s, expirationWindow=%s", opts.CheckInterval, opts.ExpirationWindow)
240+
uc.logger.Infof("periodic check enabled. interval=%s, expirationWindow=%s", resolvedOpts.CheckInterval, resolvedOpts.ExpirationWindow)
216241
}
217242

218243
// ExpirationSweep looks for runs older than the provider time and marks them as expired

0 commit comments

Comments
 (0)