Skip to content

Commit deca6b4

Browse files
authored
feat: add workspace default profile (#2366)
1 parent 31261d3 commit deca6b4

21 files changed

Lines changed: 1350 additions & 592 deletions

File tree

api/v1/api.gen.go

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

api/v1/api.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13373,6 +13373,9 @@ components:
1337313373
properties:
1337413374
description:
1337513375
type: string
13376+
defaultProfile:
13377+
$ref: "#/components/schemas/RuntimeProfileOverride"
13378+
description: "Workspace default runtime profile. Only valid for workspace defaults. Empty string clears the setting; omit to leave it unchanged."
1337613379

1337713380
SetRuntimeProfileVariableRequest:
1337813381
type: object
@@ -13477,6 +13480,9 @@ components:
1347713480
$ref: "#/components/schemas/InheritedRuntimeProfileScope"
1347813481
workspace:
1347913482
$ref: "#/components/schemas/WorkspaceName"
13483+
defaultProfile:
13484+
$ref: "#/components/schemas/RuntimeProfileName"
13485+
description: "Workspace default runtime profile used when a run and DAG do not select a profile."
1348013486
description:
1348113487
type: string
1348213488
status:

internal/dagsettings/resolve.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,48 @@ package dagsettings
66
import (
77
"context"
88
"errors"
9-
"fmt"
109
"strings"
1110

1211
"github.com/dagucloud/dagu/internal/profile"
1312
)
1413

15-
func ResolveProfile(ctx context.Context, settingsStore Store, profileStore profile.Store, dagName string) (string, error) {
14+
// ErrProfileStoreUnavailable means a selected runtime profile cannot be checked.
15+
var ErrProfileStoreUnavailable = errors.New("runtime profile store is not configured")
16+
17+
type ProfileReferenceError struct {
18+
Name string
19+
Err error
20+
}
21+
22+
func (e *ProfileReferenceError) Error() string {
23+
return e.Err.Error()
24+
}
25+
26+
func (e *ProfileReferenceError) Unwrap() error {
27+
return e.Err
28+
}
29+
30+
func ResolveProfile(
31+
ctx context.Context,
32+
settingsStore Store,
33+
profileStore profile.Store,
34+
dagName string,
35+
workspaceName string,
36+
) (string, error) {
37+
profileName, err := dagDefaultProfile(ctx, settingsStore, dagName)
38+
if err != nil {
39+
return "", err
40+
}
41+
if profileName == "" {
42+
profileName, err = workspaceDefaultProfile(ctx, profileStore, workspaceName)
43+
if err != nil {
44+
return "", err
45+
}
46+
}
47+
return ensureRunnableProfile(ctx, profileStore, profileName)
48+
}
49+
50+
func dagDefaultProfile(ctx context.Context, settingsStore Store, dagName string) (string, error) {
1651
if settingsStore == nil {
1752
return "", nil
1853
}
@@ -23,16 +58,42 @@ func ResolveProfile(ctx context.Context, settingsStore Store, profileStore profi
2358
}
2459
return "", err
2560
}
26-
profileName := strings.TrimSpace(settings.Profile)
61+
return strings.TrimSpace(settings.Profile), nil
62+
}
63+
64+
func workspaceDefaultProfile(ctx context.Context, profileStore profile.Store, workspaceName string) (string, error) {
65+
workspaceName = strings.TrimSpace(workspaceName)
66+
if workspaceName == "" {
67+
return "", nil
68+
}
69+
if profileStore == nil {
70+
return "", nil
71+
}
72+
ref, err := profile.WorkspaceInheritedRef(workspaceName)
73+
if err != nil {
74+
return "", err
75+
}
76+
defaults, err := profileStore.GetInherited(ctx, ref)
77+
if err != nil {
78+
if errors.Is(err, profile.ErrNotFound) {
79+
return "", nil
80+
}
81+
return "", err
82+
}
83+
return strings.TrimSpace(defaults.DefaultProfile), nil
84+
}
85+
86+
func ensureRunnableProfile(ctx context.Context, profileStore profile.Store, profileName string) (string, error) {
87+
profileName = strings.TrimSpace(profileName)
2788
if profileName == "" {
2889
return "", nil
2990
}
3091
if profileStore == nil {
31-
return "", fmt.Errorf("runtime profile store is not configured")
92+
return "", ErrProfileStoreUnavailable
3293
}
3394
resolved, err := profile.NewManager(profileStore, nil).EnsureRunnable(ctx, profileName)
3495
if err != nil {
35-
return "", err
96+
return "", &ProfileReferenceError{Name: profileName, Err: err}
3697
}
3798
return resolved.Name, nil
3899
}

internal/dagsettings/resolve_test.go

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package dagsettings_test
55

66
import (
77
"context"
8+
"errors"
89
"testing"
910
"time"
1011

@@ -34,7 +35,7 @@ func TestResolveProfile(t *testing.T) {
3435
require.NoError(t, err)
3536
require.NoError(t, settingsStore.Upsert(ctx, settings))
3637

37-
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example")
38+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "")
3839
require.NoError(t, err)
3940
assert.Equal(t, "prod", resolved)
4041
}
@@ -47,11 +48,39 @@ func TestResolveProfileMissingSettingsReturnsEmpty(t *testing.T) {
4748
profileStore, err := store.NewProfileStore(backend.Collection("profiles"))
4849
require.NoError(t, err)
4950

50-
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example")
51+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "")
5152
require.NoError(t, err)
5253
assert.Empty(t, resolved)
5354
}
5455

56+
func TestResolveProfileWorkspaceDefaultWithoutProfileStoreReturnsEmpty(t *testing.T) {
57+
ctx := context.Background()
58+
backend := testutil.NewMemoryBackend()
59+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
60+
require.NoError(t, err)
61+
62+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, nil, "example", "ops")
63+
require.NoError(t, err)
64+
assert.Empty(t, resolved)
65+
}
66+
67+
func TestResolveProfileDAGDefaultWithoutProfileStoreReturnsUnavailable(t *testing.T) {
68+
ctx := context.Background()
69+
backend := testutil.NewMemoryBackend()
70+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
71+
require.NoError(t, err)
72+
73+
settings, err := dagsettings.New(dagsettings.UpdateInput{
74+
DAGName: "example",
75+
Profile: "prod",
76+
}, time.Now())
77+
require.NoError(t, err)
78+
require.NoError(t, settingsStore.Upsert(ctx, settings))
79+
80+
_, err = dagsettings.ResolveProfile(ctx, settingsStore, nil, "example", "ops")
81+
require.ErrorIs(t, err, dagsettings.ErrProfileStoreUnavailable)
82+
}
83+
5584
func TestResolveProfileReturnsDisabledProfileError(t *testing.T) {
5685
ctx := context.Background()
5786
backend := testutil.NewMemoryBackend()
@@ -71,6 +100,105 @@ func TestResolveProfileReturnsDisabledProfileError(t *testing.T) {
71100
require.NoError(t, err)
72101
require.NoError(t, settingsStore.Upsert(ctx, settings))
73102

74-
_, err = dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example")
103+
_, err = dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "")
75104
require.ErrorIs(t, err, profile.ErrDisabled)
76105
}
106+
107+
func TestResolveProfileDAGDefaultWinsOverWorkspaceDefault(t *testing.T) {
108+
ctx := context.Background()
109+
backend := testutil.NewMemoryBackend()
110+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
111+
require.NoError(t, err)
112+
profileStore, err := store.NewProfileStore(backend.Collection("profiles"))
113+
require.NoError(t, err)
114+
115+
local, err := profile.New(profile.CreateInput{Name: "local"}, time.Now())
116+
require.NoError(t, err)
117+
require.NoError(t, profileStore.Create(ctx, local))
118+
prod, err := profile.New(profile.CreateInput{Name: "prod"}, time.Now())
119+
require.NoError(t, err)
120+
require.NoError(t, profileStore.Create(ctx, prod))
121+
122+
settings, err := dagsettings.New(dagsettings.UpdateInput{
123+
DAGName: "example",
124+
Profile: "local",
125+
}, time.Now())
126+
require.NoError(t, err)
127+
require.NoError(t, settingsStore.Upsert(ctx, settings))
128+
129+
ref, err := profile.WorkspaceInheritedRef("ops")
130+
require.NoError(t, err)
131+
defaults, err := profile.NewInherited(ref, profile.InheritedCreateInput{}, time.Now())
132+
require.NoError(t, err)
133+
defaults.DefaultProfile = "prod"
134+
require.NoError(t, profileStore.Create(ctx, defaults))
135+
136+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "ops")
137+
require.NoError(t, err)
138+
assert.Equal(t, "local", resolved)
139+
}
140+
141+
func TestResolveProfileUsesWorkspaceDefault(t *testing.T) {
142+
ctx := context.Background()
143+
backend := testutil.NewMemoryBackend()
144+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
145+
require.NoError(t, err)
146+
profileStore, err := store.NewProfileStore(backend.Collection("profiles"))
147+
require.NoError(t, err)
148+
149+
prod, err := profile.New(profile.CreateInput{Name: "prod"}, time.Now())
150+
require.NoError(t, err)
151+
require.NoError(t, profileStore.Create(ctx, prod))
152+
ref, err := profile.WorkspaceInheritedRef("ops")
153+
require.NoError(t, err)
154+
defaults, err := profile.NewInherited(ref, profile.InheritedCreateInput{}, time.Now())
155+
require.NoError(t, err)
156+
defaults.DefaultProfile = "prod"
157+
require.NoError(t, profileStore.Create(ctx, defaults))
158+
159+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "ops")
160+
require.NoError(t, err)
161+
assert.Equal(t, "prod", resolved)
162+
}
163+
164+
func TestResolveProfileEmptyWorkspaceDefaultReturnsEmpty(t *testing.T) {
165+
ctx := context.Background()
166+
backend := testutil.NewMemoryBackend()
167+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
168+
require.NoError(t, err)
169+
profileStore, err := store.NewProfileStore(backend.Collection("profiles"))
170+
require.NoError(t, err)
171+
172+
ref, err := profile.WorkspaceInheritedRef("ops")
173+
require.NoError(t, err)
174+
defaults, err := profile.NewInherited(ref, profile.InheritedCreateInput{}, time.Now())
175+
require.NoError(t, err)
176+
require.NoError(t, profileStore.Create(ctx, defaults))
177+
178+
resolved, err := dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "ops")
179+
require.NoError(t, err)
180+
assert.Empty(t, resolved)
181+
}
182+
183+
func TestResolveProfileReturnsStaleWorkspaceDefaultProfileError(t *testing.T) {
184+
ctx := context.Background()
185+
backend := testutil.NewMemoryBackend()
186+
settingsStore, err := store.NewDAGSettingsStore(backend.Collection("dag-settings"))
187+
require.NoError(t, err)
188+
profileStore, err := store.NewProfileStore(backend.Collection("profiles"))
189+
require.NoError(t, err)
190+
191+
ref, err := profile.WorkspaceInheritedRef("ops")
192+
require.NoError(t, err)
193+
defaults, err := profile.NewInherited(ref, profile.InheritedCreateInput{}, time.Now())
194+
require.NoError(t, err)
195+
defaults.DefaultProfile = "prod"
196+
require.NoError(t, profileStore.Create(ctx, defaults))
197+
198+
_, err = dagsettings.ResolveProfile(ctx, settingsStore, profileStore, "example", "ops")
199+
require.ErrorIs(t, err, profile.ErrNotFound)
200+
201+
var refErr *dagsettings.ProfileReferenceError
202+
require.True(t, errors.As(err, &refErr))
203+
assert.Equal(t, "prod", refErr.Name)
204+
}

internal/persis/store/profile.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@ func validateStoredProfile(p *profile.Profile) error {
162162
if err := validateProfileStorageName(p.Name); err != nil {
163163
return err
164164
}
165+
defaultProfile := p.DefaultProfile
166+
if defaultProfile != "" {
167+
if !profile.IsWorkspaceInheritedStorageName(p.Name) {
168+
return errors.New("profile store: default profile is only allowed on workspace inherited profiles")
169+
}
170+
if err := profile.ValidateName(defaultProfile); err != nil {
171+
return err
172+
}
173+
}
165174
if profile.IsInheritedStorageName(p.Name) {
166175
if !p.Protected {
167176
return errors.New("profile store: inherited profiles must be protected")

internal/persis/store/profile_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ func TestProfileStoreInheritedProfilesMustStayActiveProtected(t *testing.T) {
9898
assert.Contains(t, err.Error(), "inherited profiles must be active")
9999
}
100100

101+
func TestProfileStoreDefaultProfileOnlyAllowedOnWorkspaceInheritedProfile(t *testing.T) {
102+
ctx := context.Background()
103+
s := newProfileStore(t)
104+
105+
runtimeProfile, err := profile.New(profile.CreateInput{Name: "local"}, time.Now())
106+
require.NoError(t, err)
107+
runtimeProfile.DefaultProfile = "prod"
108+
err = s.Create(ctx, runtimeProfile)
109+
require.Error(t, err)
110+
assert.Contains(t, err.Error(), "default profile is only allowed")
111+
112+
globalProfile, err := profile.NewInherited(profile.GlobalInheritedRef(), profile.InheritedCreateInput{}, time.Now())
113+
require.NoError(t, err)
114+
globalProfile.DefaultProfile = "prod"
115+
err = s.Create(ctx, globalProfile)
116+
require.Error(t, err)
117+
assert.Contains(t, err.Error(), "default profile is only allowed")
118+
119+
ref, err := profile.WorkspaceInheritedRef("ops")
120+
require.NoError(t, err)
121+
workspaceProfile, err := profile.NewInherited(ref, profile.InheritedCreateInput{}, time.Now())
122+
require.NoError(t, err)
123+
workspaceProfile.DefaultProfile = "prod"
124+
require.NoError(t, s.Create(ctx, workspaceProfile))
125+
126+
got, err := s.GetInherited(ctx, ref)
127+
require.NoError(t, err)
128+
assert.Equal(t, "prod", got.DefaultProfile)
129+
}
130+
101131
func TestProfileStoreCreateRejectsDuplicateName(t *testing.T) {
102132
ctx := context.Background()
103133
s := newProfileStore(t)

internal/profile/inherited.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ func IsInheritedStorageName(name string) bool {
9595
if name == inheritedGlobalStorageName {
9696
return true
9797
}
98+
return IsWorkspaceInheritedStorageName(name)
99+
}
100+
101+
func IsWorkspaceInheritedStorageName(name string) bool {
98102
if !strings.HasPrefix(name, inheritedWorkspaceNamePrefix) {
99103
return false
100104
}

internal/profile/profile.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ var (
4545
)
4646

4747
type Profile struct {
48-
ID string
49-
Name string
50-
Description string
51-
Status Status
52-
Protected bool
53-
Entries []Entry
54-
CreatedBy string
55-
CreatedAt time.Time
56-
UpdatedBy string
57-
UpdatedAt time.Time
48+
ID string
49+
Name string
50+
Description string
51+
Status Status
52+
Protected bool
53+
DefaultProfile string `json:"defaultProfile,omitempty"`
54+
Entries []Entry
55+
CreatedBy string
56+
CreatedAt time.Time
57+
UpdatedBy string
58+
UpdatedAt time.Time
5859
}
5960

6061
type Entry struct {

0 commit comments

Comments
 (0)