Skip to content

Commit 3bd4936

Browse files
committed
feat(project): validate before create
1 parent b54d978 commit 3bd4936

5 files changed

Lines changed: 138 additions & 30 deletions

File tree

internal/localproject/dashboard_lifecycle_test.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2047,10 +2047,11 @@ func TestProjectDirectoryConflictWarnings(t *testing.T) {
20472047

20482048
type operation string
20492049
const (
2050-
opGet operation = "GetProject"
2051-
opCreate operation = "CreateProject"
2052-
opUpdate operation = "UpdateProject"
2053-
opList operation = "ListProject"
2050+
opGet operation = "GetProject"
2051+
opCreate operation = "CreateProject"
2052+
opValidate operation = "ValidateProject"
2053+
opUpdate operation = "UpdateProject"
2054+
opList operation = "ListProject"
20542055
)
20552056

20562057
tests := []struct {
@@ -2120,7 +2121,7 @@ func TestProjectDirectoryConflictWarnings(t *testing.T) {
21202121
},
21212122
}
21222123

2123-
operations := []operation{opGet, opCreate, opUpdate, opList}
2124+
operations := []operation{opGet, opCreate, opValidate, opUpdate, opList}
21242125

21252126
for _, tt := range tests {
21262127
for _, op := range operations {
@@ -2129,10 +2130,10 @@ func TestProjectDirectoryConflictWarnings(t *testing.T) {
21292130
ctx := context.Background()
21302131
fs := memfs.New()
21312132

2132-
// For CreateProject, we need one less project in the initial config
2133+
// For CreateProject and ValidateProject, we need one less project in the initial config
21332134
initProjects := tt.initProjects
2134-
if op == opCreate {
2135-
// Find the project we're going to create and exclude it from init
2135+
if op == opCreate || op == opValidate {
2136+
// Find the project we're going to create/validate and exclude it from init
21362137
var filtered []*typesv1.ProjectsConfig_Project
21372138
for _, proj := range tt.initProjects {
21382139
if proj.Name != tt.checkProject {
@@ -2185,6 +2186,30 @@ func TestProjectDirectoryConflictWarnings(t *testing.T) {
21852186
require.NotNil(t, resp.Project)
21862187
project = resp.Project
21872188

2189+
case opValidate:
2190+
// Find the project spec we're validating
2191+
var projectToValidate *typesv1.ProjectsConfig_Project
2192+
for _, proj := range tt.initProjects {
2193+
if proj.Name == tt.checkProject {
2194+
projectToValidate = proj
2195+
break
2196+
}
2197+
}
2198+
require.NotNil(t, projectToValidate, "test case must include the project being validated")
2199+
2200+
resp, err := db.ValidateProject(ctx, &projectv1.ValidateProjectRequest{
2201+
Spec: &typesv1.ProjectSpec{
2202+
Name: projectToValidate.Name,
2203+
Pointer: projectToValidate.Pointer,
2204+
},
2205+
})
2206+
require.NoError(t, err)
2207+
require.NotNil(t, resp.Status)
2208+
// Create a dummy project with the returned status for consistent assertion logic
2209+
project = &typesv1.Project{
2210+
Status: resp.Status,
2211+
}
2212+
21882213
case opUpdate:
21892214
// Find the project spec to update
21902215
var projectToUpdate *typesv1.ProjectsConfig_Project

internal/localproject/watch.go

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,62 @@ func (wt *watch) CreateProject(ctx context.Context, req *projectv1.CreateProject
206206
return storage.getProject(ctx, ptr.Name, ptr.Pointer, func(p *typesv1.Project) error {
207207
out = p
208208
// Enrich project with all warnings
209-
wt.enrichProjectWithWarnings(p, ptr)
209+
wt.enrichProjectWithWarnings(p.Status, ptr)
210210
return nil
211211
})
212212
})
213213
return &projectv1.CreateProjectResponse{Project: out}, err
214214
}
215215

216+
func (wt *watch) ValidateProject(ctx context.Context, req *projectv1.ValidateProjectRequest) (*projectv1.ValidateProjectResponse, error) {
217+
name := req.Spec.Name
218+
219+
var status *typesv1.ProjectStatus
220+
err := func() error {
221+
wt.mu.Lock()
222+
defer wt.mu.Unlock()
223+
cfg, err := wt.cfg.Reload()
224+
if err != nil {
225+
return fmt.Errorf("reading config file: %v", err)
226+
}
227+
wt.cfg = cfg
228+
if cfg.Runtime == nil {
229+
cfg.Runtime = &typesv1.RuntimeConfig{}
230+
}
231+
if cfg.Runtime.ExperimentalFeatures == nil {
232+
cfg.Runtime.ExperimentalFeatures = &typesv1.RuntimeConfig_ExperimentalFeatures{}
233+
}
234+
if cfg.Runtime.ExperimentalFeatures.Projects == nil {
235+
cfg.Runtime.ExperimentalFeatures.Projects = &typesv1.ProjectsConfig{}
236+
}
237+
projects := cfg.Runtime.ExperimentalFeatures.Projects
238+
239+
sp := &typesv1.ProjectsConfig_Project{
240+
Name: name,
241+
Pointer: req.Spec.Pointer,
242+
}
243+
244+
storage, err := wt.storageForPointer(req.Spec.Pointer)
245+
if err != nil {
246+
return err
247+
}
248+
// Run the same validation as CreateProject
249+
if err := wt.validateProjectPointer(ctx, projects.Projects, name, sp, storage); err != nil {
250+
return err
251+
}
252+
253+
// Create a status object to compute warnings using the same code path
254+
status = &typesv1.ProjectStatus{}
255+
wt.enrichProjectWithWarnings(status, sp)
256+
return nil
257+
}()
258+
if err != nil {
259+
return nil, err
260+
}
261+
262+
return &projectv1.ValidateProjectResponse{Status: status}, nil
263+
}
264+
216265
func (wt *watch) validateProjectPointer(ctx context.Context, projects []*typesv1.ProjectsConfig_Project, name string, sp *typesv1.ProjectsConfig_Project, storage projectStorage) error {
217266
if name == "" {
218267
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("project name cannot be empty"))
@@ -265,7 +314,7 @@ func (wt *watch) UpdateProject(ctx context.Context, req *projectv1.UpdateProject
265314
return storage.getProject(ctx, ptr.Name, ptr.Pointer, func(p *typesv1.Project) error {
266315
out = p
267316
// Enrich project with all warnings
268-
wt.enrichProjectWithWarnings(p, ptr)
317+
wt.enrichProjectWithWarnings(p.Status, ptr)
269318
return nil
270319
})
271320
})
@@ -311,7 +360,7 @@ func (wt *watch) GetProject(ctx context.Context, req *projectv1.GetProjectReques
311360
alertGroups = ag
312361

313362
// Enrich project with all warnings
314-
wt.enrichProjectWithWarnings(p, ptr)
363+
wt.enrichProjectWithWarnings(p.Status, ptr)
315364

316365
return nil
317366
})
@@ -360,7 +409,7 @@ func (wt *watch) ListProject(ctx context.Context, req *projectv1.ListProjectRequ
360409
}
361410
return storage.getProject(ctx, sp.Name, sp.Pointer, func(p *typesv1.Project) error {
362411
// Enrich project with all warnings
363-
wt.enrichProjectWithWarnings(p, sp)
412+
wt.enrichProjectWithWarnings(p.Status, sp)
364413
out = append(out, &projectv1.ListProjectResponse_ListItem{Project: p})
365414
return nil
366415
})
@@ -720,33 +769,24 @@ func sharedAlertDirWarning(otherProjectName, dirPath string) string {
720769
return fmt.Sprintf("Project %q shares the same alert directory (%s). Changes in one project will affect the other.", otherProjectName, dirPath)
721770
}
722771

723-
// enrichProjectWithWarnings populates all warnings for a project
772+
// enrichProjectWithWarnings populates all warnings for a project status
724773
// This should be called whenever returning a project to ensure warnings are up-to-date
725-
func (wt *watch) enrichProjectWithWarnings(project *typesv1.Project, ptr *typesv1.ProjectsConfig_Project) {
726-
wt.addDirectoryConflictWarnings(project, ptr)
774+
func (wt *watch) enrichProjectWithWarnings(status *typesv1.ProjectStatus, ptr *typesv1.ProjectsConfig_Project) {
775+
wt.addDirectoryConflictWarnings(status, ptr)
727776
// Future warning checks can be added here
728777
}
729778

730-
// addDirectoryConflictWarnings checks if this project shares directories with other projects
731-
// and adds warnings to the project status if conflicts are found
732-
func (wt *watch) addDirectoryConflictWarnings(project *typesv1.Project, currentPtr *typesv1.ProjectsConfig_Project) {
779+
// computeProjectWarnings computes warnings for a project without modifying it
780+
// Used by ValidateProject to preview warnings before creating the project
781+
func (wt *watch) computeProjectWarnings(currentPtr *typesv1.ProjectsConfig_Project, allProjects []*typesv1.ProjectsConfig_Project) []string {
733782
// Only check for localhost projects
734783
localhost := currentPtr.Pointer.GetLocalhost()
735784
if localhost == nil {
736-
return
737-
}
738-
739-
cfg, err := wt.cfg.Reload()
740-
if err != nil {
741-
return // Can't check, skip
742-
}
743-
744-
if cfg.Runtime == nil || cfg.Runtime.ExperimentalFeatures == nil || cfg.Runtime.ExperimentalFeatures.Projects == nil {
745-
return
785+
return nil
746786
}
747787

748788
var conflicts []string
749-
for _, otherProj := range cfg.Runtime.ExperimentalFeatures.Projects.Projects {
789+
for _, otherProj := range allProjects {
750790
if otherProj.Name == currentPtr.Name {
751791
continue // Skip self
752792
}
@@ -771,8 +811,24 @@ func (wt *watch) addDirectoryConflictWarnings(project *typesv1.Project, currentP
771811
}
772812
}
773813

814+
return conflicts
815+
}
816+
817+
// addDirectoryConflictWarnings checks if this project shares directories with other projects
818+
// and adds warnings to the project status if conflicts are found
819+
func (wt *watch) addDirectoryConflictWarnings(status *typesv1.ProjectStatus, currentPtr *typesv1.ProjectsConfig_Project) {
820+
cfg, err := wt.cfg.Reload()
821+
if err != nil {
822+
return // Can't check, skip
823+
}
824+
825+
if cfg.Runtime == nil || cfg.Runtime.ExperimentalFeatures == nil || cfg.Runtime.ExperimentalFeatures.Projects == nil {
826+
return
827+
}
828+
829+
conflicts := wt.computeProjectWarnings(currentPtr, cfg.Runtime.ExperimentalFeatures.Projects.Projects)
774830
if len(conflicts) > 0 {
775-
project.Status.Warnings = append(project.Status.Warnings, conflicts...)
831+
status.Warnings = append(status.Warnings, conflicts...)
776832
}
777833
}
778834

internal/localstate/inmem.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ func (db *Mem) CreateProject(ctx context.Context, req *projectv1.CreateProjectRe
120120
return &projectv1.CreateProjectResponse{Project: out}, nil
121121
}
122122

123+
func (db *Mem) ValidateProject(ctx context.Context, req *projectv1.ValidateProjectRequest) (*projectv1.ValidateProjectResponse, error) {
124+
db.mu.Lock()
125+
defer db.mu.Unlock()
126+
127+
// Run the same validation as CreateProject
128+
for _, project := range db.projects {
129+
if project.project.Spec.Name == req.Spec.Name {
130+
return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("project %q already uses the name %q", project.project.Meta.Id, req.Spec.Name))
131+
}
132+
}
133+
134+
// In-memory projects don't have directory conflicts, so return empty status
135+
return &projectv1.ValidateProjectResponse{
136+
Status: &typesv1.ProjectStatus{},
137+
}, nil
138+
}
139+
123140
func (db *Mem) SyncProject(ctx context.Context, req *projectv1.SyncProjectRequest) (*projectv1.SyncProjectResponse, error) {
124141
var (
125142
out *typesv1.Project

internal/localstate/localstate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
type DB interface {
1212
CreateProject(context.Context, *projectv1.CreateProjectRequest) (*projectv1.CreateProjectResponse, error)
13+
ValidateProject(context.Context, *projectv1.ValidateProjectRequest) (*projectv1.ValidateProjectResponse, error)
1314
GetProject(context.Context, *projectv1.GetProjectRequest) (*projectv1.GetProjectResponse, error)
1415
SyncProject(context.Context, *projectv1.SyncProjectRequest) (*projectv1.SyncProjectResponse, error)
1516
UpdateProject(context.Context, *projectv1.UpdateProjectRequest) (*projectv1.UpdateProjectResponse, error)

internal/localsvc/svc.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,15 @@ func (svc *Service) CreateProject(ctx context.Context, req *connect.Request[proj
663663
return connect.NewResponse(out), nil
664664
}
665665

666+
func (svc *Service) ValidateProject(ctx context.Context, req *connect.Request[projectv1.ValidateProjectRequest]) (*connect.Response[projectv1.ValidateProjectResponse], error) {
667+
msg := req.Msg
668+
out, err := svc.db.ValidateProject(ctx, msg)
669+
if err != nil {
670+
return nil, err
671+
}
672+
return connect.NewResponse(out), nil
673+
}
674+
666675
func (svc *Service) GetProject(ctx context.Context, req *connect.Request[projectv1.GetProjectRequest]) (*connect.Response[projectv1.GetProjectResponse], error) {
667676
msg := req.Msg
668677
out, err := svc.db.GetProject(ctx, msg)

0 commit comments

Comments
 (0)