Skip to content

Commit 8a7feb7

Browse files
authored
feat: ability to archive projects (#2519)
1 parent 0430306 commit 8a7feb7

44 files changed

Lines changed: 1190 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/internal/common/errors.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,34 @@ func (e *ProjectRestartError) Error() string {
790790
return fmt.Sprintf("Failed to restart project: %v", e.Err)
791791
}
792792

793+
type ProjectArchiveError struct {
794+
Err error
795+
}
796+
797+
func (e *ProjectArchiveError) Error() string {
798+
return fmt.Sprintf("Failed to archive project: %v", e.Err)
799+
}
800+
801+
type ProjectUnarchiveError struct {
802+
Err error
803+
}
804+
805+
func (e *ProjectUnarchiveError) Error() string {
806+
return fmt.Sprintf("Failed to unarchive project: %v", e.Err)
807+
}
808+
809+
type ProjectMustBeStoppedError struct{}
810+
811+
func (e *ProjectMustBeStoppedError) Error() string {
812+
return "project must be stopped before archiving"
813+
}
814+
815+
type ProjectArchivedError struct{}
816+
817+
func (e *ProjectArchivedError) Error() string {
818+
return "project is archived and must be unarchived before this action"
819+
}
820+
793821
type ProjectStatusCountsError struct {
794822
Err error
795823
}

backend/internal/huma/handlers/projects.go

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type ListProjectsInput struct {
4343
Limit int `query:"limit" default:"20" doc:"Number of items per page"`
4444
Status string `query:"status" doc:"Filter by status (comma-separated: running,stopped,partially running)"`
4545
Updates string `query:"updates" doc:"Filter by update status (has_update, up_to_date, error, unknown)"`
46+
Archived string `query:"archived" doc:"Archived filter: 'true' (only archived), 'all' (include archived). Default excludes archived."`
4647
}
4748

4849
type ListProjectsOutput struct {
@@ -152,6 +153,24 @@ type RestartProjectOutput struct {
152153
Body base.ApiResponse[base.MessageResponse]
153154
}
154155

156+
type ArchiveProjectInput struct {
157+
EnvironmentID string `path:"id" doc:"Environment ID"`
158+
ProjectID string `path:"projectId" doc:"Project ID"`
159+
}
160+
161+
type ArchiveProjectOutput struct {
162+
Body base.ApiResponse[base.MessageResponse]
163+
}
164+
165+
type UnarchiveProjectInput struct {
166+
EnvironmentID string `path:"id" doc:"Environment ID"`
167+
ProjectID string `path:"projectId" doc:"Project ID"`
168+
}
169+
170+
type UnarchiveProjectOutput struct {
171+
Body base.ApiResponse[base.MessageResponse]
172+
}
173+
155174
type PullProjectImagesInput struct {
156175
EnvironmentID string `path:"id" doc:"Environment ID"`
157176
ProjectID string `path:"projectId" doc:"Project ID"`
@@ -343,6 +362,32 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) {
343362
},
344363
}, h.RestartProject)
345364

365+
huma.Register(api, huma.Operation{
366+
OperationID: "archive-project",
367+
Method: http.MethodPost,
368+
Path: "/environments/{id}/projects/{projectId}/archive",
369+
Summary: "Archive a project",
370+
Description: "Archive a stopped Docker Compose project",
371+
Tags: []string{"Projects"},
372+
Security: []map[string][]string{
373+
{"BearerAuth": {}},
374+
{"ApiKeyAuth": {}},
375+
},
376+
}, h.ArchiveProject)
377+
378+
huma.Register(api, huma.Operation{
379+
OperationID: "unarchive-project",
380+
Method: http.MethodPost,
381+
Path: "/environments/{id}/projects/{projectId}/unarchive",
382+
Summary: "Unarchive a project",
383+
Description: "Unarchive a Docker Compose project",
384+
Tags: []string{"Projects"},
385+
Security: []map[string][]string{
386+
{"BearerAuth": {}},
387+
{"ApiKeyAuth": {}},
388+
},
389+
}, h.UnarchiveProject)
390+
346391
huma.Register(api, huma.Operation{
347392
OperationID: "pull-project-images",
348393
Method: http.MethodPost,
@@ -383,6 +428,9 @@ func (h *ProjectHandler) ListProjects(ctx context.Context, input *ListProjectsIn
383428
if input.Updates != "" {
384429
filters["updates"] = input.Updates
385430
}
431+
if input.Archived != "" {
432+
filters["archived"] = input.Archived
433+
}
386434

387435
params := pagination.QueryParams{
388436
SearchQuery: pagination.SearchQuery{
@@ -432,7 +480,7 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP
432480
return nil, huma.Error500InternalServerError("service not available")
433481
}
434482

435-
_, running, stopped, total, err := h.projectService.GetProjectStatusCounts(ctx)
483+
_, running, stopped, total, archived, err := h.projectService.GetProjectStatusCounts(ctx)
436484
if err != nil {
437485
return nil, huma.Error500InternalServerError((&common.ProjectStatusCountsError{Err: err}).Error())
438486
}
@@ -441,9 +489,10 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP
441489
Body: base.ApiResponse[project.StatusCounts]{
442490
Success: true,
443491
Data: project.StatusCounts{
444-
RunningProjects: int(running),
445-
StoppedProjects: int(stopped),
446-
TotalProjects: int(total),
492+
RunningProjects: int(running),
493+
StoppedProjects: int(stopped),
494+
TotalProjects: int(total),
495+
ArchivedProjects: int(archived),
447496
},
448497
},
449498
}, nil
@@ -507,6 +556,10 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu
507556
}
508557

509558
if err := h.projectService.DownProject(ctx, input.ProjectID, *user); err != nil {
559+
var archivedErr *common.ProjectArchivedError
560+
if errors.As(err, &archivedErr) {
561+
return nil, huma.Error400BadRequest((&common.ProjectDownError{Err: err}).Error())
562+
}
510563
return nil, huma.Error500InternalServerError((&common.ProjectDownError{Err: err}).Error())
511564
}
512565

@@ -547,6 +600,8 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject
547600
response.DirName = utils.DerefString(proj.DirName)
548601
response.RelativePath = h.projectService.GetProjectRelativePath(ctx, proj.Path)
549602
response.GitOpsManagedBy = proj.GitOpsManagedBy
603+
response.IsArchived = proj.IsArchived
604+
response.ArchivedAt = proj.ArchivedAt
550605

551606
return &CreateProjectOutput{
552607
Body: base.ApiResponse[project.CreateReponse]{
@@ -632,6 +687,10 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro
632687
}
633688

634689
if err := h.projectService.RedeployProject(ctx, input.ProjectID, *user); err != nil {
690+
var archivedErr *common.ProjectArchivedError
691+
if errors.As(err, &archivedErr) {
692+
return nil, huma.Error400BadRequest(err.Error())
693+
}
635694
return nil, huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error())
636695
}
637696

@@ -764,6 +823,10 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje
764823
}
765824

766825
if err := h.projectService.RestartProject(ctx, input.ProjectID, *user); err != nil {
826+
var archivedErr *common.ProjectArchivedError
827+
if errors.As(err, &archivedErr) {
828+
return nil, huma.Error400BadRequest(err.Error())
829+
}
767830
return nil, huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error())
768831
}
769832

@@ -777,6 +840,62 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje
777840
}, nil
778841
}
779842

843+
func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProjectInput) (*ArchiveProjectOutput, error) {
844+
if h.projectService == nil {
845+
return nil, huma.Error500InternalServerError("service not available")
846+
}
847+
848+
if input.ProjectID == "" {
849+
return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error())
850+
}
851+
852+
user, exists := humamw.GetCurrentUserFromContext(ctx)
853+
if !exists {
854+
return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error())
855+
}
856+
857+
if err := h.projectService.ArchiveProject(ctx, input.ProjectID, *user); err != nil {
858+
var mustStopErr *common.ProjectMustBeStoppedError
859+
if errors.As(err, &mustStopErr) {
860+
return nil, huma.Error400BadRequest(err.Error())
861+
}
862+
return nil, huma.Error500InternalServerError((&common.ProjectArchiveError{Err: err}).Error())
863+
}
864+
865+
return &ArchiveProjectOutput{
866+
Body: base.ApiResponse[base.MessageResponse]{
867+
Success: true,
868+
Data: base.MessageResponse{Message: "Project archived successfully"},
869+
},
870+
}, nil
871+
}
872+
873+
func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveProjectInput) (*UnarchiveProjectOutput, error) {
874+
if h.projectService == nil {
875+
return nil, huma.Error500InternalServerError("service not available")
876+
}
877+
878+
if input.ProjectID == "" {
879+
return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error())
880+
}
881+
882+
user, exists := humamw.GetCurrentUserFromContext(ctx)
883+
if !exists {
884+
return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error())
885+
}
886+
887+
if err := h.projectService.UnarchiveProject(ctx, input.ProjectID, *user); err != nil {
888+
return nil, huma.Error500InternalServerError((&common.ProjectUnarchiveError{Err: err}).Error())
889+
}
890+
891+
return &UnarchiveProjectOutput{
892+
Body: base.ApiResponse[base.MessageResponse]{
893+
Success: true,
894+
Data: base.MessageResponse{Message: "Project unarchived successfully"},
895+
},
896+
}, nil
897+
}
898+
780899
// PullProjectImages pulls all images for a project with streaming progress.
781900
func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProjectImagesInput) (*huma.StreamResponse, error) {
782901
if h.projectService == nil {

backend/internal/models/project.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package models
22

3+
import "time"
4+
35
type ProjectStatus string
46

57
const (
@@ -22,6 +24,8 @@ type Project struct {
2224
RunningCount int `json:"running_count" sortable:"true"`
2325
GitOpsManagedBy *string `json:"gitops_managed_by,omitempty" gorm:"column:gitops_managed_by"`
2426
ComposeProjectName *string `json:"compose_project_name,omitempty" gorm:"column:compose_project_name"`
27+
IsArchived bool `json:"is_archived" gorm:"column:is_archived;default:false;index"`
28+
ArchivedAt *time.Time `json:"archived_at,omitempty" gorm:"column:archived_at"`
2529

2630
BaseModel
2731
}

0 commit comments

Comments
 (0)